{
  "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 data."
      },
      "typeVersion": 1
    },
    {
      "id": "46e9f99c-22fb-4f5c-8810-435d8e3b3c96",
      "name": "AI Agent: Analyze",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2064,
        400
      ],
      "parameters": {
        "text": "={{ \"Analyze this JSON bundle and return ONLY valid JSON in the required schema.\\n\\nINPUT:\\n\" + JSON.stringify($json) }}\n",
        "options": {
          "systemMessage": "You are the Revenue Manager and Market Intelligence lead for WESTIN BAYSHORE VANCOUVER.\n\nObjective\nAnalyze competitor pricing alerts versus our hotel, then use Vancouver Convention Centre (VCC) events as contextual demand signals to support (not dictate) pricing recommendations.\n\nImportant guidance\n- Events are NOT guaranteed causes of price movement. Competitor changes may be driven by sell-outs, room-type inventory shifts, holiday/leisure demand, group blocks, channel strategy, or revenue management tactics.\n- Use VCC events ONLY as supplementary context when forming hypotheses and recommendations.\n- Provide operationally feasible actions: price moves, rate fences, restrictions, channel actions, and monitoring steps.\n- Be explicit about assumptions and uncertainty.\n\n\nHow to analyze\nFor each alert item:\n1) Summarize competitor movement: bestTotal vs prevBestTotal (absChange, pctChangePct).\n2) Compare to our hotel: use ourHotelBestTotal vs competitor bestTotal.\n   - priceGapOurMinusCompetitor = ourHotelBestTotal - bestTotal\n   - priceGapPct = (priceGapOurMinusCompetitor / bestTotal) * 100  (if bestTotal > 0)\n3) VCC event context:\n   - Identify notable events whose date ranges plausibly overlap the stay date (checkInDate). Use the provided VCC list as context only.\n4) Provide a recommendation:\n   - priceAction: \"increase\" | \"decrease\" | \"hold\"\n   - targetRange: min/max numeric suggestions (CAD)\n   - tactics: concrete actions (rate fences, LOS, CTA/CTD, channel controls, packages, BAR positioning)\n   - monitoring: what to watch and when (next 24\u201372h, pickup, competitor recheck cadence)\n\nOUTPUT REQUIREMENT\nReturn ONLY a valid JSON object (no markdown, no code fences, no commentary) matching this schema:\n\n{\n  \"executiveSummary\": string,\n  \"alertsReviewed\": number,\n  \"findings\": [\n    {\n      \"hotelName\": string,\n      \"checkInDate\": string,\n      \"competitorNow\": number | null,\n      \"competitorPrev\": number | null,\n      \"competitorAbsChange\": number | null,\n      \"competitorPctChangePct\": number | null,\n      \"ourHotelRate\": number | null,\n      \"priceGapOurMinusCompetitor\": number | null,\n      \"priceGapPct\": number | null,\n      \"vccEventOverlaps\": [\n        { \"title\": string, \"url\": string, \"startISO\": string }\n      ],\n      \"interpretation\": string,\n      \"recommendation\": {\n        \"priceAction\": \"increase\" | \"decrease\" | \"hold\",\n        \"targetRange\": { \"min\": number, \"max\": number },\n        \"tactics\": string[],\n        \"monitoring\": string[]\n      }\n    }\n  ],\n  \"vccEventsUsedSummary\": string\n}\n"
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 3
    },
    {
      "id": "0bf6f56b-ebb6-4851-ae7b-e11046d3c005",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        1920,
        608
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "a391a7ef-8129-48bb-84d7-44cdd48112d5",
      "name": "Daily Market Check",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        96,
        -112
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 0 9 * * *"
            },
            {
              "field": "cronExpression",
              "expression": "0 0 15 * * *"
            },
            {
              "field": "cronExpression",
              "expression": "0 0 21 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "4e185c90-5a0a-4c1d-989b-6309cf263111",
      "name": "Extract Event Details",
      "type": "n8n-nodes-base.html",
      "position": [
        1184,
        480
      ],
      "parameters": {
        "options": {},
        "operation": "extractHtmlContent",
        "dataPropertyName": "events",
        "extractionValues": {
          "values": [
            {
              "key": "title",
              "cssSelector": "=a.event-item .event-details h2",
              "returnArray": true
            },
            {
              "key": "href",
              "attribute": "href",
              "cssSelector": "=a.event-item",
              "returnArray": true,
              "returnValue": "attribute"
            },
            {
              "key": "startDay",
              "cssSelector": "=a.event-item .event-date .day:first-of-type span:first-child",
              "returnArray": true
            },
            {
              "key": "startMonth",
              "cssSelector": "=a.event-item .event-date .day:first-of-type span.month",
              "returnArray": true
            },
            {
              "key": "endDay",
              "cssSelector": "=a.event-item .event-date .day:last-of-type span:first-child",
              "returnArray": true
            },
            {
              "key": "endMonth",
              "cssSelector": "=a.event-item .event-date .day:last-of-type span.month",
              "returnArray": true
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "d8fc6f9e-ac02-44d8-9bac-d0554fe62925",
      "name": "Clean Amadeus Data",
      "type": "n8n-nodes-base.code",
      "position": [
        1312,
        -96
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Normalize Amadeus hotel-offers response into a consistent schema\n// Preserves request context (hotelRole/hotelId/hotelName/dates/cfg fields) for downstream logic.\n\nfunction tryParseJson(x) {\n  if (typeof x !== \"string\") return x;\n  try { return JSON.parse(x); } catch { return x; }\n}\n\nconst rawItem = $json;\n\n// If HTTP Request is set to \"Full Response\", body is at rawItem.body\n// Otherwise, rawItem itself is the payload\nlet resp = rawItem.body ?? rawItem;\n\n// Sometimes the HTTP node returns: { data: \"{\\\"data\\\":[...]}\" }\nresp = tryParseJson(resp);\nif (resp && typeof resp === \"object\" && typeof resp.data === \"string\") {\n  resp = { ...resp, data: tryParseJson(resp.data) };\n}\n\n// Extract HTTP status (success or error-output)\nconst httpStatus =\n  rawItem.statusCode ??\n  resp?.statusCode ??\n  rawItem.error?.status ??\n  rawItem.error?.httpCode ??\n  200;\n\n// Extract Amadeus error details\nlet amadeusCode = resp?.errors?.[0]?.code ?? null;\nlet errorTitle = resp?.errors?.[0]?.title ?? null;\n\nif (!amadeusCode && rawItem.error?.message) {\n  const msg = rawItem.error.message;\n  const firstBrace = msg.indexOf(\"{\");\n  const lastBrace = msg.lastIndexOf(\"}\");\n  if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {\n    const jsonChunk = msg.slice(firstBrace, lastBrace + 1);\n    const parsed = tryParseJson(jsonChunk);\n    if (parsed?.errors?.[0]) {\n      amadeusCode = parsed.errors[0].code ?? null;\n      errorTitle = parsed.errors[0].title ?? null;\n    }\n  }\n}\n\n// Extract hotel + offers\nconst hotelBlock = resp?.data?.[0] ?? null;\nconst offers = hotelBlock?.offers ?? [];\n\n// Prefer request context hotelId/hotelName (because we intentionally set them in generator)\nconst hotelId = rawItem.hotelId ?? hotelBlock?.hotel?.hotelId ?? null;\nconst hotelName = rawItem.hotelName ?? hotelBlock?.hotel?.name ?? null;\n\n// Compute minRate\nlet minRate = null;\nif (Array.isArray(offers) && offers.length) {\n  const totals = offers\n    .map(o => Number(o?.price?.total ?? o?.price?.base ?? NaN))\n    .filter(n => Number.isFinite(n));\n  if (totals.length) minRate = Math.min(...totals);\n}\n\n// Outcome logic\nlet outcome = \"ERROR\";\n\nif (httpStatus === 200) {\n  outcome = (minRate !== null) ? \"OK\" : \"NO_OFFERS\";\n} else if (httpStatus === 400 && amadeusCode === 3664) {\n  outcome = \"NO_OFFERS\";\n} else if (httpStatus === 429) {\n  outcome = \"RATE_LIMIT\";\n} else if (httpStatus === 401) {\n  outcome = \"AUTH_ERROR\";\n}\n\n// Return: preserve important request context + normalized response fields\nreturn {\n  // preserved context\n  hotelRole: rawItem.hotelRole ?? null,\n  hotelId,\n  hotelName,\n  checkInDate: rawItem.checkInDate ?? offers?.[0]?.checkInDate ?? null,\n  checkOutDate: rawItem.checkOutDate ?? offers?.[0]?.checkOutDate ?? null,\n\n  currency: offers?.[0]?.price?.currency ?? rawItem.currency ?? null,\n  adults: rawItem.adults ?? null,\n  roomQuantity: rawItem.roomQuantity ?? null,\n  thresholdPct: rawItem.thresholdPct ?? null,\n\n  // normalized results\n  hasOffer: minRate !== null,\n  minRate,\n  outcome,\n  httpStatus,\n  amadeusCode,\n  errorTitle,\n};\n"
      },
      "typeVersion": 2
    },
    {
      "id": "9214aa2f-f21a-4f9c-b5df-c00591d772e2",
      "name": "Send WhatsApp Alert",
      "type": "n8n-nodes-base.whatsApp",
      "position": [
        2496,
        400
      ],
      "parameters": {
        "textBody": "={{ $json.output.executiveSummary }}",
        "operation": "send",
        "phoneNumberId": "978279622026190",
        "additionalFields": {},
        "recipientPhoneNumber": "x"
      },
      "credentials": {
        "whatsAppApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.1
    },
    {
      "id": "579fe539-4359-44b7-9cd9-c4aa7b14ee40",
      "name": "Filter Text Messages",
      "type": "n8n-nodes-base.if",
      "position": [
        736,
        896
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "be96ecef-2db9-48a8-b178-3d48112eae7b",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.messages[0].from}}",
              "rightValue": "16727551224"
            }
          ]
        }
      },
      "typeVersion": 2.3
    }
  ],
  "active": false,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "eb9e0422-ff67-45ad-8684-c123e72b1e37",
  "connections": {
    "Config": {
      "main": [
        [
          {
            "node": "Amadeus OAuth",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Snapshot": {
      "main": [
        [
          {
            "node": "Combine New Rates vs Prev Rates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Get Prev Rates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent: Q&A": {
      "main": [
        [
          {
            "node": "Q&A",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Amadeus OAuth": {
      "main": [
        [
          {
            "node": "Generate 30 night windows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prev Snapshot": {
      "main": [
        [
          {
            "node": "Combine New Rates vs Prev Rates",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Simple Memory": {
      "ai_memory": [
        [
          {
            "node": "AI Agent: Q&A",
            "type": "ai_memory",
            "index": 0
          }
        ]
      ]
    },
    "Build VCC URLs": {
      "main": [
        [
          {
            "node": "Fetch VCC Month HTML",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Change": {
      "main": [
        [
          {
            "node": "Significant Competitor Change",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Events Extract": {
      "main": [
        [
          {
            "node": "Combine Rates & Events",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Get Prev Rates": {
      "main": [
        [
          {
            "node": "Prev Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "WhatsApp Trigger": {
      "main": [
        [
          {
            "node": "Filter Text Messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent: Analyze": {
      "main": [
        [
          {
            "node": "Send WhatsApp Alert",
            "type": "main",
            "index": 0
          },
          {
            "node": "Combine Summary & Alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Alert Summary": {
      "main": [
        [
          {
            "node": "AI Agent: Q&A",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent: Analyze",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Clean Amadeus Data": {
      "main": [
        [
          {
            "node": "New Snapshot",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily Market Check": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model2": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent: Q&A",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Get Our Hotel Rates": {
      "main": [
        [
          {
            "node": "Prefix Our Hotel Fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Latest Rates": {
      "main": [
        []
      ]
    },
    "Amadeus Hotel Offers": {
      "main": [
        [
          {
            "node": "Combine 30-Day Rates",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Combine 30-Day Rates": {
      "main": [
        [
          {
            "node": "Clean Amadeus Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch VCC Month HTML": {
      "main": [
        [
          {
            "node": "Extract Event Details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Text Messages": {
      "main": [
        [
          {
            "node": "Normalize Whatsapp Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Event Details": {
      "main": [
        [
          {
            "node": "Events Extract",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine Rates & Events": {
      "main": [
        [
          {
            "node": "Prepare AI Agent Input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare AI Agent Input": {
      "main": [
        [
          {
            "node": "AI Agent: Analyze",
            "type": "main",
            "index": 0
          },
          {
            "node": "Combine Summary & Alert",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Combine Summary & Alert": {
      "main": [
        [
          {
            "node": "Upsert Hotel Price Alerts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Hotel_Rates_History": {
      "ai_tool": [
        [
          {
            "node": "AI Agent: Q&A",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Prefix Our Hotel Fields": {
      "main": [
        [
          {
            "node": "Combine Competitor Rates vs Our Rates",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Normalize Whatsapp Input": {
      "main": [
        [
          {
            "node": "Get Alert Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Structured Output Parser": {
      "ai_outputParser": [
        [
          {
            "node": "AI Agent: Analyze",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Generate 30 night windows": {
      "main": [
        [
          {
            "node": "Combine 30-Day Rates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Amadeus Hotel Offers",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Significant Competitor Change": {
      "main": [
        [
          {
            "node": "Get Our Hotel Rates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Combine Competitor Rates vs Our Rates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine New Rates vs Prev Rates": {
      "main": [
        [
          {
            "node": "Upsert Latest Rates",
            "type": "main",
            "index": 0
          },
          {
            "node": "Compute Change",
            "type": "main",
            "index": 0
          },
          {
            "node": "Insert History Rates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Combine Competitor Rates vs Our Rates": {
      "main": [
        [
          {
            "node": "Build VCC URLs",
            "type": "main",
            "index": 0
          },
          {
            "node": "Combine Rates & Events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}