{
  "id": "1VZ9ZVfErajEjLqd",
  "name": "Gymbot Ai Agent Template",
  "tags": [],
  "nodes": [
    {
      "id": "50a4234d-8322-43dc-8614-04d0a4cb1b23",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "position": [
        16,
        416
      ],
      "parameters": {
        "path": "gymbot/conversation",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2
    },
    {
      "id": "83f91755-6754-49b8-a0a0-933f9697a1e5",
      "name": "Normalize Message",
      "type": "n8n-nodes-base.code",
      "position": [
        368,
        416
      ],
      "parameters": {
        "jsCode": "// NORMALIZE MESSAGE - HANDLE DOUBLE NESTING\nfunction toWhatsAddr(v) {\n  if (!v) return '';\n  const s = String(v).trim();\n  if (s.includes('@c.us')) return s;\n  const digits = s.replace(/\\D/g, '');\n  return digits ? digits + '@c.us' : '';\n}\n\nfunction detectLang(t) {\n  if (!t || typeof t !== 'string') return 'en';\n  const s = t.trim();\n  const arChars = s.match(/[\\u0600-\\u06FF\\u0750-\\u077F\\u08A0-\\u08FF\\uFB50-\\uFDFF\\uFE70-\\uFEFF]/g) || [];\n  const arScript = arChars.length >= 3 || (s.length && arChars.length / s.length >= 0.2);\n  if (arScript) return 'ar';\n  const frWords = /\\b(bonjour|salut|merci|svp|comment|pourquoi|oui|non)\\b/i.test(s);\n  if (frWords) return 'fr';\n  return 'en';\n}\n\n// Handle nested webhook data - Ultramsg wraps data in body.data\nlet webhookData = $('Webhook').first().json.body || $('Webhook').first().json;\n\n// If $json has body property, unwrap it\nif ($json.body && typeof $json.body === 'object') {\n  webhookData = $json.body;\n}\n\n// If webhookData has data property, unwrap that too\nif (webhookData.data && typeof webhookData.data === 'object') {\n  webhookData = webhookData.data;\n}\n\n// Filter outgoing messages\nif (webhookData.type === 'sent' || webhookData.fromMe === true) {\n  return [];\n}\n\n// Block group chats\nif (webhookData.from?.includes('@g.us')) {\n  return [];\n}\n\nconst fromRaw = webhookData.from || '';\nconst messageText = String(webhookData.body || '');\n\n// \u2705 FIXED: Extract user name with multiple fallbacks\nconst userName = webhookData.pushname || \n                 webhookData.name || \n                 webhookData.notifyName ||\n                 webhookData.author ||\n                 'WhatsApp User';\n\nlet voice = null;\nif (webhookData.type === 'ptt' || webhookData.type === 'audio') {\n  voice = {\n    downloadUrl: webhookData.media || '',\n    caption: webhookData.caption || ''\n  };\n}\n\nconst senderWhats = toWhatsAddr(fromRaw);\nconst botWhats = toWhatsAddr(webhookData.to || '');\nconst sessionKey = senderWhats.replace('@c.us', '').replace('whatsapp:', '').replace('+', '').replace(/\\D/g, '');\nconst langCode = detectLang(messageText);\n\nconsole.log('=== NORMALIZE MESSAGE ===');\nconsole.log('User Name:', userName);\nconsole.log('Phone:', sessionKey);\nconsole.log('Message:', messageText);\n\nreturn [{\n  json: {\n    profileName: userName,  // Keep for compatibility\n    userName: userName,      // \u2705 ADD THIS - for booking tool\n    text: messageText,\n    senderWhats,\n    botWhats,\n    sessionKey,\n    phone: sessionKey,\n    sessionId: sessionKey,\n    isVoice: !!voice,\n    voice,\n    lang: { code: langCode }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "eade377e-4a26-49aa-af62-b60fe43c3a8f",
      "name": "IF: Is Voice?",
      "type": "n8n-nodes-base.if",
      "position": [
        992,
        944
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 1,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "169d2c3f-ae6e-44cb-bbbd-7cde89d9d343",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.isVoice }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "cc36d0eb-7887-49c4-a5a3-ba90cf836ff6",
      "name": "Download Audio",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1232,
        800
      ],
      "parameters": {
        "url": "={{ $json.voice.downloadUrl }}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "typeVersion": 4
    },
    {
      "id": "3b876c5e-90ac-43f6-9d5c-bbd13333ab5f",
      "name": "Transcribe (Whisper)",
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "position": [
        1440,
        800
      ],
      "parameters": {
        "options": {
          "language": "={{ $('Normalize Message').item.json.lang.code }}"
        },
        "resource": "audio",
        "operation": "transcribe"
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "0d70cea4-223c-46e8-9260-cbecd8c89a3c",
      "name": "Merge Transcription",
      "type": "n8n-nodes-base.code",
      "position": [
        1648,
        800
      ],
      "parameters": {
        "jsCode": "const normalizeData = $node[\"Normalize Message\"].json;\nconst transcription = $json.text || '';\n\nconsole.log('=== MERGE TRANSCRIPTION ===');\nconsole.log('Transcribed text:', transcription);\n\nreturn [{\n  json: {\n    ...normalizeData,\n    text: transcription,\n    isVoiceTranscribed: true\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "a6ca89fd-c5ea-45bd-9c86-d4fbcacc6333",
      "name": "Lookup Session",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1904,
        960
      ],
      "parameters": {
        "options": {},
        "filtersUI": {
          "values": [
            {
              "lookupValue": "={{ $json.sessionKey.replace('@c.us', '').replace('whatsapp:', '') }}",
              "lookupColumn": "Phone"
            }
          ]
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1CWcULthIhOq8kcaCVoVWJTkbKBSJ6aMum-jSt2_fQUc/edit#gid=0",
          "cachedResultName": "Session_State"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "c1425d86-32ab-42c3-bb1e-894deb05d03b",
      "name": "Check Session Expiry",
      "type": "n8n-nodes-base.code",
      "position": [
        2096,
        960
      ],
      "parameters": {
        "jsCode": "const SESSION_TIMEOUT_HOURS = 24;\n\n// Get session data from Lookup (may be empty for new users)\nconst sessionData = $input.first()?.json || {};\n\n// Get current message data from Normalize Message node\nconst currentMessageData = $node[\"Normalize Message\"].json;\n\n// Check if voice was transcribed\nlet messageText = currentMessageData.text || '';\nif (currentMessageData.isVoice && $node[\"Merge Transcription\"]) {\n  const mergeData = $node[\"Merge Transcription\"].json;\n  if (mergeData && mergeData.text) {\n    messageText = mergeData.text;\n  }\n}\n\n// Check if session exists and is not expired\nconst now = new Date();\nlet isSessionValid = false;\nlet existingSession = null;\n\nif (sessionData.Phone) {\n  const updatedAt = sessionData.UpdatedAt ? new Date(sessionData.UpdatedAt) : null;\n  \n  if (updatedAt) {\n    const hoursSinceUpdate = (now - updatedAt) / (1000 * 60 * 60);\n    isSessionValid = hoursSinceUpdate < SESSION_TIMEOUT_HOURS;\n  }\n  \n  if (isSessionValid) {\n    try {\n      existingSession = sessionData.State_JSON ? JSON.parse(sessionData.State_JSON) : {};\n    } catch (e) {\n      existingSession = {};\n    }\n  }\n}\n\n// Read surveySubmitted regardless of session validity\nconst surveySubmitted = sessionData.SurveySubmitted || '';\n\n// Get branch and session info\nlet branch = (isSessionValid && sessionData.Branch) ? sessionData.Branch : null;\nconst lang = (isSessionValid && existingSession) ? existingSession.lang : (currentMessageData.lang?.code || 'en');\nconst lastMentionedClass = (isSessionValid && existingSession) ? existingSession.lastMentionedClass : null;\nconst lastMentionedTime = (isSessionValid && existingSession) ? \n  existingSession.lastMentionedTime : null;\nconst pendingBookings = (isSessionValid && existingSession) ? (existingSession.pendingBookings || []) : [];\nconst latestQuestion = (isSessionValid && sessionData.Latest_Question) ? sessionData.Latest_Question : '';\n\n// ===================================================================\n// EXACT BRANCH DETECTION\n// Add new branches here when needed\n// ===================================================================\nconst cfg = $('Read Config').first().json;\nconst validBranches = [cfg?.branch_1 || 'rabieh', cfg?.branch_2 || 'bikfaya'];\n\nconst currentMessageLower = messageText.toLowerCase().trim();\n\nconst mentionedBranch = validBranches.find(b =>\n  currentMessageLower === b ||\n  currentMessageLower === b + ' branch' ||\n  currentMessageLower === 'the ' + b ||\n  currentMessageLower === 'the ' + b + ' branch'\n);\n\nif (mentionedBranch) {\n  branch = mentionedBranch;\n  console.log('\u2705 Branch detected (exact):', branch);\n} else if (!branch) {\n  const inlineBranch = validBranches.find(b => currentMessageLower.includes(b));\n  if (inlineBranch) {\n    branch = inlineBranch;\n    console.log('\u2705 Branch detected (inline):', branch);\n  }\n}\n\nconsole.log('=== CHECK SESSION EXPIRY ===');\nconsole.log('Session Valid:', isSessionValid);\nconsole.log('Phone:', currentMessageData.phone);\nconsole.log('Message:', messageText);\nconsole.log('Language:', lang);\nconsole.log('Branch:', branch);\nconsole.log('Last Class:', lastMentionedClass);\nconsole.log('Last Time:', lastMentionedTime);\nconsole.log('Latest Question:', latestQuestion);\n\nreturn [{\n  json: {\n    text: messageText,\n    phone: currentMessageData.phone || '',\n    sessionId: currentMessageData.phone || '',\n    sessionKey: currentMessageData.sessionKey || currentMessageData.phone || '',\n    senderWhats: currentMessageData.senderWhats || '',\n    botWhats: currentMessageData.botWhats || '',\n    profileName: currentMessageData.profileName || '',\n    isVoice: currentMessageData.isVoice || false,\n    voice: currentMessageData.voice || null,\n    isNewSession: !isSessionValid,\n    branch: branch,\n    lastMentionedClass: lastMentionedClass,\n    lastMentionedTime: lastMentionedTime,\n    latestQuestion: latestQuestion,\n    pendingBookings: pendingBookings,\n    surveySubmitted: surveySubmitted,\n    lang: { code: lang }\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "56d298f4-fa8f-471d-b614-d73309dedd12",
      "name": "Format Response",
      "type": "n8n-nodes-base.code",
      "position": [
        3744,
        1152
      ],
      "parameters": {
        "jsCode": "const agentOutput = $json.output || $json.text || '';\n\n// Get Normalize Message data properly\nconst normalizeNode = $node[\"Normalize Message\"];\nconst normalizeData = normalizeNode?.first?.()?.json || normalizeNode?.json || {};\n\nconsole.log('=== FORMAT RESPONSE DEBUG ===');\nconsole.log('Agent output:', agentOutput.substring(0, 200));\nconsole.log('Normalize data keys:', Object.keys(normalizeData));\nconsole.log('sessionKey:', normalizeData.sessionKey);\nconsole.log('phone:', normalizeData.phone);\nconsole.log('senderWhats:', normalizeData.senderWhats);\n\nreturn [{\n  json: {\n    replyText: agentOutput,\n    phone: normalizeData.sessionKey || normalizeData.phone || '',\n    senderWhats: normalizeData.senderWhats || '',\n    timestamp: new Date().toISOString()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "7323df07-189f-433a-8387-3aa4298b0e62",
      "name": "Send WhatsApp",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4096,
        1152
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "token",
              "value": "={{ $('Read Config').first().json.ultramsg_token }}"
            },
            {
              "name": "to",
              "value": "={{ $json.senderWhats }}"
            },
            {
              "name": "body",
              "value": "={{ $json.replyText }}"
            }
          ]
        }
      },
      "typeVersion": 4
    },
    {
      "id": "d756d11c-cb07-43a4-b217-dcda6130503f",
      "name": "Read Active Members",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1040,
        128
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1839231317,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1CWcULthIhOq8kcaCVoVWJTkbKBSJ6aMum-jSt2_fQUc/edit#gid=1839231317",
          "cachedResultName": "Members"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "0cdd5103-38bd-42f4-9644-74414daccc82",
      "name": "Filter Active Members",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        128
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\n\nconsole.log('=== FILTER ACTIVE MEMBERS ===');\nconsole.log('Total members:', items.length);\n\nconst activeMembers = items.filter(item => {\n  const json = item.json;\n  \n  // Must have phone\n  if (!json.Phone && !json.phone) return false;\n  \n  // Must be active\n  const status = (json.status || json.Status || '').toLowerCase();\n  if (status && status !== 'active') return false;\n  \n  return true;\n});\n\nconsole.log('Active members to survey:', activeMembers.length);\n\nif (activeMembers.length === 0) {\n  return [];\n}\n\nreturn activeMembers.map(item => ({\n  json: {\n    phone: String(item.json.Phone || item.json.phone || '').replace(/\\D/g, '') + '@c.us',\n    name: item.json.name || item.json.Name || 'Member',\n    branch: item.json.branch || item.json.Branch || 'rabieh'\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "e8c3d4bf-436b-4ac2-8f67-f1fbe0a7dd12",
      "name": "Send Survey",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1408,
        128
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "token",
              "value": "={{ $('Read Config').first().json.ultramsg_token }}"
            },
            {
              "name": "to",
              "value": "={{ $json.phone }}"
            },
            {
              "name": "body",
              "value": "\"Hi! \ud83d\udc4b\n\nQuick survey from Stamina Gym \ud83d\udcaa\n\nPlease rate each from 1 to 4:\n1\ufe0f\u20e3 Very Satisfied  2\ufe0f\u20e3 Satisfied  3\ufe0f\u20e3 Neutral  4\ufe0f\u20e3 Unsatisfied\n\n1. Overall satisfaction with the gym\n2. Class variety\n3. Instructors quality\n4. Facilities & equipment\n5. Likelihood to recommend us\n\nReply with 5 numbers separated by commas, and optionally add a suggestion.\nExample: 1,2,1,3,2 or 1,2,1,3,2, great gym!\n\nThank you! \ud83d\ude4f\""
            }
          ]
        }
      },
      "typeVersion": 4
    },
    {
      "id": "f14613fa-574c-4309-a639-e595b4fb004b",
      "name": "Mark Survey Sent",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1600,
        128
      ],
      "parameters": {
        "columns": {
          "value": {
            "phone": "={{ $('Filter Active Members').item.json.phone.replace('@c.us', '') }}",
            "row_number": 0,
            "last_survey_date": "={{ new Date().toISOString() }}"
          },
          "schema": [
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "membership_type",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "membership_type",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "join_date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "join_date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "last_survey_date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "last_survey_date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "type": "number",
              "display": true,
              "removed": false,
              "readOnly": true,
              "required": false,
              "displayName": "row_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "phone"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1839231317,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1CWcULthIhOq8kcaCVoVWJTkbKBSJ6aMum-jSt2_fQUc/edit#gid=1839231317",
          "cachedResultName": "Members"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": false
    },
    {
      "id": "94f6d388-b9a7-4051-b74c-a7a0f4b2e43b",
      "name": "Parse Survey Response",
      "type": "n8n-nodes-base.code",
      "position": [
        1216,
        416
      ],
      "parameters": {
        "jsCode": "// Check if already submitted\nconst surveySubmitted = ($json.SurveySubmitted || $json.surveySubmitted || '').toLowerCase();\nif (surveySubmitted === 'yes') {\n  return [{\n    json: {\n      ...$json,\n      valid: false,\n      alreadySubmitted: true,\n      replyText: \"You have already submitted your feedback. Thank you! \ud83d\ude4f\"\n    }\n  }];\n}\n\n// Read message from original WhatsApp input, not session lookup\nconst originalMsg = $('Detect Staff Survey Command').item.json;\nconst text = (originalMsg.text || $json.text || '').trim();\nconst phone = originalMsg.phone || originalMsg.sessionKey || $json.Phone || '';\nconst lang = originalMsg.lang?.code || $json.Lang || 'en';\nconst name = originalMsg.userName || originalMsg.profileName || 'Member';\nconst branch = $json.Branch || originalMsg.branch || '';\n\nconsole.log('=== PARSE SURVEY RESPONSE ===');\nconsole.log('Text:', text, 'Phone:', phone);\n\n// Extract all digits 1-4 from the message and any text after last digit as suggestion\n// Handles: \"1,2,3,4,2\", \"1,2,3,4,2 great gym\", \"1,2,3,4\", \"1 2 3 4 2\"\nconst tokens = text.split(/[,\\s]+/);\nconst ratings = [];\nlet suggestion = '';\nlet suggestionStarted = false;\n\nfor (const token of tokens) {\n  if (!suggestionStarted) {\n    const n = parseInt(token);\n    if (!isNaN(n) && n >= 1 && n <= 4 && ratings.length < 5) {\n      ratings.push(n);\n    } else if (token.length > 0) {\n      // Non-number token \u2014 rest is suggestion\n      suggestionStarted = true;\n      suggestion = tokens.slice(tokens.indexOf(token)).join(' ').trim();\n      break;\n    }\n  }\n}\n\nconsole.log('Ratings extracted:', ratings);\nconsole.log('Suggestion:', suggestion);\n\nif (ratings.length === 0) {\n  return [{\n    json: {\n      ...$json,\n      valid: false,\n      replyText: \"Please reply with numbers 1-4 separated by commas.\\nExample: 1,2,1,3,2\\n\\n1=Very Satisfied  2=Satisfied  3=Neutral  4=Unsatisfied \ud83d\ude0a\"\n    }\n  }];\n}\n\n// Fill missing ratings with empty string\nwhile (ratings.length < 5) ratings.push('');\n\nconst satisfactionMap = { 1: 'Very Satisfied', 2: 'Satisfied', 3: 'Neutral', 4: 'Unsatisfied' };\n\nreturn [{\n  json: {\n    ...$json,\n    valid: true,\n    ResponseID: 'SURV_' + Date.now(),\n    Phone: phone,\n    Name: name,\n    Branch: branch,\n    Q1: ratings[0],\n    Q2: ratings[1],\n    Q3: ratings[2],\n    Q4: ratings[3],\n    Q5: ratings[4],\n    Suggestion: suggestion,\n    Rating: ratings[0],\n    Satisfaction: satisfactionMap[ratings[0]] || '',\n    Language: lang,\n    SubmittedAt: new Date().toLocaleString('en-US', { timeZone: 'Asia/Beirut' }),\n    Status: 'completed'\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "ecca0cb5-0c84-435c-8ee1-1bce89a60c9e",
      "name": "Append Survey Response",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1632,
        320
      ],
      "parameters": {
        "columns": {
          "value": {
            "Q1": "={{ $json.Q1 }}",
            "Q2": "={{ $json.Q2 }}",
            "Q3": "={{ $json.Q3 }}",
            "Q4": "={{ $json.Q4 }}",
            "Q5": "={{ $json.Q5 }}",
            "Name": "={{ $json.Name }}",
            "Phone": "={{ $json.Phone }}",
            "Branch": "={{ $json.Branch }}",
            "Rating": "={{ $json.Rating }}",
            "Status": "={{ $json.Status }}",
            "ResponseID": "={{ $json.ResponseID }}",
            "Suggestion": "={{ $json.Suggestion }}",
            "SubmittedAt": "={{ $json.SubmittedAt }}",
            "Satisfaction": "={{ $json.Satisfaction }}"
          },
          "schema": [
            {
              "id": "ResponseID",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "ResponseID",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Rating",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Rating",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Satisfaction",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Satisfaction",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "SubmittedAt",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "SubmittedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Q1",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Q1",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Q2",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Q2",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Q3",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Q3",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Q4",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Q4",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Q5",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Q5",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Suggestion",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Suggestion",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 462947745,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1CWcULthIhOq8kcaCVoVWJTkbKBSJ6aMum-jSt2_fQUc/edit#gid=462947745",
          "cachedResultName": "Survey_Responses"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "25e43a43-bef8-47b4-a2e0-99271f7266c8",
      "name": "Format Thank You",
      "type": "n8n-nodes-base.code",
      "position": [
        1808,
        320
      ],
      "parameters": {
        "jsCode": "const lang = $json.Language || 'en';\nconst suggestion = $json.Suggestion || '';\n\nlet msg = '';\nif (lang === 'ar') {\n  msg = '\u0634\u0643\u0631\u0627\u064b \u0639\u0644\u0649 \u062a\u0642\u064a\u064a\u0645\u0643! \ud83d\ude4f \u0631\u0623\u064a\u0643 \u064a\u0647\u0645\u0646\u0627 \u0643\u062a\u064a\u0631. \ud83d\udcaa';\n} else if (lang === 'fr') {\n  msg = 'Merci pour votre \u00e9valuation! \ud83d\ude4f Votre avis compte beaucoup pour nous. \ud83d\udcaa';\n} else {\n  msg = 'Thank you for your feedback! \ud83d\ude4f Your opinion means a lot to us. \ud83d\udcaa';\n}\n\nif (suggestion) {\n  msg += `\\n\\n\ud83d\udcdd We noted your suggestion: \"${suggestion}\"`;\n}\n\nreturn [{ json: { ...$json, replyText: msg, surveySubmitted: 'yes' } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "27e34247-65ba-481e-a1d9-00ab1fcec8d5",
      "name": "Send Thank You",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1968,
        320
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "token",
              "value": "={{ $('Read Config').first().json.ultramsg_token }}"
            },
            {
              "name": "to",
              "value": "={{ $('Detect Staff Survey Command').item.json.senderWhats }}"
            },
            {
              "name": "body",
              "value": "={{ $json.replyText }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "17da0a9b-c6c0-4c5a-aed2-a33cfdfe92a8",
      "name": "Format Staff Notification",
      "type": "n8n-nodes-base.code",
      "position": [
        2160,
        320
      ],
      "parameters": {
        "jsCode": "// FORMAT STAFF NOTIFICATION\nconst rating = $('Parse Survey Response').item.json.Rating || 0;\nconst satisfaction = $('Parse Survey Response').item.json.Satisfaction || '';\nconst phone = $('Parse Survey Response').item.json.Phone || '';\nconst responseId = $('Parse Survey Response').item.json.ResponseID || '';\nconst name = $('Parse Survey Response').item.json.Name || 'Unknown';\n\nconsole.log('=== FORMAT STAFF NOTIFICATION ===');\nconsole.log('Rating:', rating, 'Phone:', phone);\n\n// Rating 1 = Very Satisfied (best), 4 = Unsatisfied (worst)\nconst ratingStars = rating === 1 ? '\u2b50\u2b50\u2b50\u2b50' : rating === 2 ? '\u2b50\u2b50\u2b50' : rating === 3 ? '\u2b50\u2b50' : '\u2b50';\nconst needsAttention = rating >= 3;\n\nconst staffMessage = `\ud83d\udcca *New Survey Response*\\n\\n` +\n  `\ud83d\udc64 Name: ${name}\\n` +\n  `\ud83d\udcf1 Phone: ${phone}\\n` +\n  `\u2b50 Rating: ${rating}/4 ${ratingStars}\\n` +\n  `\ud83d\ude0a Satisfaction: ${satisfaction}\\n` +\n  `\ud83c\udd94 Response ID: ${responseId}\\n\\n` +\n  `${needsAttention ? '\u26a0\ufe0f Needs attention!' : '\u2705 Great feedback!'}\\n\\n` +\n  `\ud83d\udd50 Submitted: ${new Date().toLocaleString('en-US', { timeZone: 'Asia/Beirut' })}`;\n\nreturn [{\n  json: {\n    ...$json,\n    staffMessage: staffMessage,\n    staffPhones: (() => {\n      const cfg = $('Read Config').first().json;\n      const br = ($json.branch || $json.Branch || '').toLowerCase();\n      return [br === cfg.branch_2 ? cfg.staff_phone_branch_2 : cfg.staff_phone_branch_1];\n    })()\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "28ce69e5-c186-42c7-9e4e-aa94bd3d8620",
      "name": "Notify Staff Survey",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2560,
        320
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "token",
              "value": "={{ $('Read Config').first().json.ultramsg_token }}"
            },
            {
              "name": "to",
              "value": "={{ $json.chatId }}"
            },
            {
              "name": "body",
              "value": "={{ $json.message }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "85a6c632-92e0-4c08-932f-852d615d3bb2",
      "name": "Prepare Staff Listt",
      "type": "n8n-nodes-base.code",
      "position": [
        2368,
        320
      ],
      "parameters": {
        "jsCode": "// PREPARE STAFF NOTIFICATIONS\nconst message = $json.staffMessage;\nconst staffPhones = $json.staffPhones || [];\n\nconsole.log('=== PREPARE STAFF NOTIFICATIONS ===');\nconsole.log('Staff members:', staffPhones.length);\n\n// Create one item per staff member\nreturn staffPhones.map(phone => {\n  const whatsappId = String(phone).includes('@c.us') ? String(phone) : String(phone) + '@c.us';\n  return {\n    json: {\n      chatId: whatsappId,\n      message: message,\n      staffPhone: phone\n    }\n  };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "e7789075-2dc1-4bcf-be56-f9903e597407",
      "name": "Detect Staff Survey Command",
      "type": "n8n-nodes-base.code",
      "position": [
        560,
        416
      ],
      "parameters": {
        "jsCode": "// DETECT STAFF COMMANDS\nconst text = ($json.text || '').trim().toLowerCase();\nconst phone = String($json.phone || $json.sessionKey || '');\n\nconsole.log('=== DETECT STAFF COMMAND ===');\nconsole.log('Text:', text);\nconsole.log('Phone:', phone);\n\n// Define staff phone numbers (without @c.us, just digits)\n// Per-branch staff phones \u2014 all staff can trigger survey\nconst cfg = $('Read Config').first().json;\nconst STAFF_PHONES = [\n  String(cfg.staff_phone_branch_1 || ''),\n  String(cfg.staff_phone_branch_2 || '')\n];\n\nconst isStaff = STAFF_PHONES.includes(phone);\n\nconsole.log('Is staff:', isStaff);\n\n// Check for survey command\nconst surveyCommands = [\n  'survey',\n  'send survey',\n  'start survey',\n  'trigger survey',\n  'questionnaire',\n  '\u0627\u0633\u062a\u0628\u064a\u0627\u0646',           // Arabic\n  '\u0627\u0633\u062a\u0645\u0627\u0631\u0629',          // Arabic\n  'sondage'           // French\n];\n\nconst isSurveyCommand = surveyCommands.some(cmd => \n  text === cmd || text.includes(cmd)\n);\n\nconsole.log('Is survey command:', isSurveyCommand);\n\nif (isStaff && isSurveyCommand) {\n  console.log('\u2705 STAFF SURVEY TRIGGER DETECTED!');\n  \n  return [{\n    json: {\n      ...$json,\n      isStaffCommand: true,\n      commandType: 'trigger_survey',\n      route: 'staff_trigger_survey'\n    }\n  }];\n}\n\n// Check for survey response (existing logic)\n// Detect survey response: starts with a digit 1-4, may have commas and text\nconst isSurveyResponse = /^[1-4][\\s,]/.test(text.trim()) || /^[1-4]$/.test(text.trim());\nconst sessionState = $json.sessionState || {};\nconst awaitingSurvey = sessionState.surveyAwaiting === true;\n\nif (isSurveyResponse) {\n  console.log('\u2705 Survey response detected!');\n  \n  return [{\n    json: {\n      ...$json,\n      isSurveyResponse: true,\n      surveyRating: parseInt(text),\n      route: 'survey_response'\n    }\n  }];\n}\n\n// Not a staff command or survey response\nreturn [{\n  json: {\n    ...$json,\n    isStaffCommand: false,\n    isSurveyResponse: false\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "1ab10ef2-fb0a-4527-b070-1dff75f61171",
      "name": "Format Staff Confirmation",
      "type": "n8n-nodes-base.code",
      "position": [
        1792,
        128
      ],
      "parameters": {
        "jsCode": "// FORMAT STAFF CONFIRMATION\nconst items = $input.all();\nconst surveyCount = items.length;\n\nconsole.log('=== FORMAT STAFF CONFIRMATION ===');\nconsole.log('Surveys sent:', surveyCount);\n\nconst confirmMessage = `\u2705 *Survey Sent Successfully!*\\n\\n` +\n  `\ud83d\udcca Sent to: ${surveyCount} active member(s)\\n\\n` +\n  `Members will receive:\\n` +\n  `\"Hi! \ud83d\udc4b Quick survey from Stamina Gym...\"\\n\\n` +\n  `\u23f0 ${new Date().toLocaleString('en-US', { timeZone: 'Asia/Beirut' })}\\n\\n` +\n  `You'll be notified when members respond! \ud83d\udcec`;\n\n// Get staff phone from the original trigger\nconst staffPhone = $('Detect Staff Survey Command').first().json.phone;\n\nreturn [{\n  json: {\n    chatId: staffPhone + '@c.us',\n    message: confirmMessage,\n    surveyCount: surveyCount\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "f8637fe3-c153-4d5e-8fd3-9337527b759b",
      "name": "Send Staff Confirmation",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2000,
        128
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "token",
              "value": "={{ $('Read Config').first().json.ultramsg_token }}"
            },
            {
              "name": "to",
              "value": "={{ $json.chatId }}"
            },
            {
              "name": "body",
              "value": "={{ $json.message }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "8b4cc23d-288f-4f88-a1d6-0e1ef89e72ea",
      "name": "Switch: Staff Command or Survey",
      "type": "n8n-nodes-base.switch",
      "position": [
        768,
        400
      ],
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "5846478c-4419-4e10-a06d-9568474a15d3",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.isStaffCommand }}",
                    "rightValue": ""
                  }
                ]
              }
            },
            {
              "conditions": {
                "options": {
                  "version": 3,
                  "leftValue": "",
                  "caseSensitive": true,
                  "typeValidation": "strict"
                },
                "combinator": "and",
                "conditions": [
                  {
                    "id": "267978ec-dec4-4a45-b30c-9e2cbab4ee52",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    },
                    "leftValue": "={{ $json.isSurveyResponse }}",
                    "rightValue": ""
                  }
                ]
              }
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "98135687-dcbb-46b8-a8c5-0df83be219fb",
      "name": "Determine Latest Question",
      "type": "n8n-nodes-base.code",
      "position": [
        4800,
        1168
      ],
      "parameters": {
        "jsCode": "// ===================================================================\n// DETERMINE LATEST QUESTION - FIXED\n// ===================================================================\n\nconst normalizeData = $node[\"Normalize Message\"].json;\nconst formatResponseData = $node[\"Format Response\"].json;\nconst lookupData = $node[\"Lookup Session\"].json || {};\nconst checkSessionData = $node[\"Check Session Expiry\"].json || {};\n\n// Get current message\nconst currentMessage = (normalizeData.text || '').toLowerCase().trim();\n\n// Get previous Latest_Question from Google Sheets\n// \u2705 FIXED: Always cast to string to prevent .toLowerCase() errors\nconst previousQuestion = String(checkSessionData.latestQuestion || '');\n\n// Get AI's response\nconst aiOutput = formatResponseData.replyText || '';\n\n// Get branch from session\nlet detectedBranch = checkSessionData.branch || null;\n\n// ===================================================================\n// EXTRACT lastMentionedClass and lastMentionedTime from AI response\n// \u2705 FIX: Match time to the SAME LINE as the class\n// ===================================================================\n\nlet lastMentionedClass = checkSessionData.lastMentionedClass || null;\nlet lastMentionedTime = checkSessionData.lastMentionedTime || null;\n\nconst classPatterns = ['trx', 'yoga', 'pilates', 'boxing', 'zumba', 'spinning', 'hiit', 'spin', 'dance'];\n\nfor (const cls of classPatterns) {\n  if (aiOutput.toLowerCase().includes(cls)) {\n    lastMentionedClass = cls;\n\n    const lines = aiOutput.split('\\n');\n    for (const line of lines) {\n      if (line.toLowerCase().includes(cls)) {\n        const timeOnLine = line.match(/\\b(\\d{1,2}:\\d{2}(?:\\s?[AP]M)?)\\b/i);\n        if (timeOnLine) {\n          lastMentionedTime = timeOnLine[1];\n        }\n        break;\n      }\n    }\n    break;\n  }\n}\n\nconsole.log('=== DETERMINE LATEST QUESTION ===');\nconsole.log('Current message:', currentMessage);\nconsole.log('Previous question:', previousQuestion);\nconsole.log('AI output (first 300):', aiOutput.substring(0, 300));\nconsole.log('Branch:', detectedBranch);\nconsole.log('Last Class:', lastMentionedClass);\nconsole.log('Last Time:', lastMentionedTime);\n\n// ===================================================================\n// STEP 1: If AI asked \"Which branch?\" \u2014 keep previousQuestion (covers ALL cases)\n// This handles greetings, typos, emojis \u2014 anything that triggered a branch ask\n// ===================================================================\n\nconst aiAskedBranch = (\n  aiOutput.toLowerCase().includes('which branch') ||\n  aiOutput.toLowerCase().includes('rabieh or bikfaya')\n);\n\nif (aiAskedBranch) {\n  // If previousQuestion is empty, the CURRENT message is the question to preserve\n  const greetingWords = ['hi', 'hello', 'hey', 'hii', 'hiii', 'heyy', 'heyyy', 'helloz', 'salam', '\u0645\u0631\u062d\u0628\u0627', '\u0623\u0647\u0644\u0627', 'bonjour', 'salut'];\n  const prevIsGreeting = greetingWords.includes(previousQuestion.toLowerCase().trim());\n  let questionToSave = (previousQuestion && !prevIsGreeting) ? previousQuestion : normalizeData.text;\n  // Strip leading greeting words from questionToSave so AI receives clean intent\n  // e.g. \"Hello classes plz\" \u2192 \"classes plz\"\n  const greetingPrefixPattern = /^(hi+|hello|hey+|heyy|heyyy|helloz|salam|\u0645\u0631\u062d\u0628\u0627|\u0623\u0647\u0644\u0627|bonjour|salut|bonsoir)[,!.\\s]+/i;\n  questionToSave = questionToSave.replace(greetingPrefixPattern, '').trim();\n  console.log('AI asked for branch \u2014 saving:', questionToSave);\n  return [{\n    json: {\n      ...formatResponseData,\n      latestQuestion: questionToSave,\n      branch: detectedBranch,\n      lastMentionedClass: lastMentionedClass,\n      lastMentionedTime: lastMentionedTime\n    }\n  }];\n}\n\nconst isGreeting = false; // placeholder kept for Step 5 reference\n\n// ===================================================================\n// STEP 4: Exact branch detection\n// ===================================================================\n\nconst validBranches = ['rabieh', 'bikfaya'];\n\nconst mentionedBranch = validBranches.find(b =>\n  currentMessage === b ||\n  currentMessage === b + ' branch' ||\n  currentMessage === 'the ' + b ||\n  currentMessage === 'the ' + b + ' branch'\n);\n\nconst isBranchAnswer = !!mentionedBranch;\n\nif (isBranchAnswer) {\n  detectedBranch = mentionedBranch;\n  console.log('Branch answer detected:', detectedBranch);\n}\n\nif (isBranchAnswer && previousQuestion) {\n  console.log('Keeping original question:', previousQuestion);\n\n  return [{\n    json: {\n      ...formatResponseData,\n      latestQuestion: previousQuestion,\n      branch: detectedBranch,\n      lastMentionedClass: lastMentionedClass,\n      lastMentionedTime: lastMentionedTime\n    }\n  }];\n}\n\n// ===================================================================\n// STEP 4b: \u2705 FIXED \u2014 User replied with a day name to a booking question\n// Check previousQuestion (not aiOutput) for booking intent\n// aiOutput is the CURRENT turn response, not the previous one\n// ===================================================================\n\nconst dayNames = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];\nconst isDay = dayNames.includes(currentMessage.trim());\n\nconst previousHasBooking = (\n  previousQuestion.toLowerCase().includes('book') ||\n  previousQuestion.toLowerCase().includes('reserve') ||\n  previousQuestion.toLowerCase().includes('\u0627\u062d\u062c\u0632')\n);\n\nif (isDay && previousHasBooking) {\n  // User answered \"which day?\" with a day name\n  // Combine: \"Book me yoga plz\" + \"Monday\" \u2192 \"Book me yoga plz on Monday\"\n  const combinedQuestion = previousQuestion + ' on ' + normalizeData.text;\n  console.log('\u2705 Day answer for booking \u2014 combining into:', combinedQuestion);\n\n  return [{\n    json: {\n      ...formatResponseData,\n      latestQuestion: combinedQuestion,\n      branch: detectedBranch,\n      lastMentionedClass: lastMentionedClass,\n      lastMentionedTime: lastMentionedTime\n    }\n  }];\n}\n\n// ===================================================================\n// STEP 5: Determine if short answer or new question\n// ===================================================================\n\nconst botAskedBooking = (\n  aiOutput.toLowerCase().includes('would you like to book') ||\n  aiOutput.toLowerCase().includes('shall i book') ||\n  aiOutput.toLowerCase().includes('want me to book')\n);\n\nconst isConfirmation = (\n  currentMessage === 'yes' ||\n  currentMessage === 'no' ||\n  currentMessage === 'sure' ||\n  currentMessage === 'ok' ||\n  currentMessage === 'okay' ||\n  currentMessage === 'yeah' ||\n  currentMessage === 'yep' ||\n  currentMessage === 'yup' ||\n  currentMessage === 'nope' ||\n  currentMessage === 'please' ||\n  currentMessage === 'book it' ||\n  currentMessage === 'go ahead' ||\n  currentMessage === '\u0646\u0639\u0645' ||\n  currentMessage === '\u0627\u0648\u0643' ||\n  currentMessage === '\u062a\u0645\u0627\u0645' ||\n  currentMessage === 'oui' ||\n  currentMessage === 'non'\n);\n\nconst isShortAnswer = previousQuestion && (\n  isConfirmation ||\n  (botAskedBooking && isConfirmation)\n);\n\nlet finalLatestQuestion;\n\nif (isShortAnswer) {\n  finalLatestQuestion = previousQuestion;\n  console.log('Short answer, keeping:', previousQuestion);\n} else if (isGreeting) {\n  finalLatestQuestion = previousQuestion || '';\n  console.log('Greeting, keeping previous:', finalLatestQuestion);\n} else {\n  const greetingWords = ['hi', 'hello', 'hey', 'hii', 'hiii', 'heyy', 'heyyy', 'salam', '\u0645\u0631\u062d\u0628\u0627', '\u0623\u0647\u0644\u0627', 'bonjour', 'salut', 'bonsoir'];\n  const isGreetingMsg = greetingWords.some(g => currentMessage === g);\n  // If branch already set and user sent a direct request \u2014 clear latestQuestion\n// No need to preserve it since branch is already known\nif (detectedBranch && !isShortAnswer) {\n  finalLatestQuestion = '';\n} else {\n  finalLatestQuestion = isGreetingMsg ? (previousQuestion || '') : normalizeData.text;\n}\n  console.log('New question:', finalLatestQuestion);\n}\n\n// ===================================================================\n// STEP 6: Detect branch from current message if not already set\n// ===================================================================\n\nif (!detectedBranch && isBranchAnswer) {\n  detectedBranch = mentionedBranch;\n}\n\n// \u2705 NEW: Also detect branch when user mentions it anywhere in the message\n// e.g. \"what classes at Rabieh\" or \"book yoga at Bikfaya\"\nif (!detectedBranch) {\n  for (const b of validBranches) {\n    if (currentMessage.includes(b)) {\n      detectedBranch = b;\n      console.log('Branch detected from message text:', detectedBranch);\n      break;\n    }\n  }\n}\n\nconsole.log('========================================');\nconsole.log('FINAL:');\nconsole.log('latestQuestion:', finalLatestQuestion);\nconsole.log('branch:', detectedBranch);\nconsole.log('lastMentionedClass:', lastMentionedClass);\nconsole.log('lastMentionedTime:', lastMentionedTime);\nconsole.log('========================================');\n\nreturn [{\n  json: {\n    ...formatResponseData,\n    latestQuestion: finalLatestQuestion,\n    branch: detectedBranch,\n    lastMentionedClass: lastMentionedClass,\n    lastMentionedTime: lastMentionedTime\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "5a4ab336-d66a-4787-8902-3834ca93357d",
      "name": "If",
      "type": "n8n-nodes-base.if",
      "position": [
        4240,
        1152
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "97b70fc2-7319-4648-a9f6-4a10045156f9",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $('Split Response').item.json.hasImage }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "28f69592-e6f9-484a-89b5-c4f0b2bbc521",
      "name": "Send Price List",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4496,
        1024
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"token\": \"{{ $('Read Config').first().json.ultramsg_token }}\",\n  \"to\": \"{{ $('Split Response').item.json.senderWhats }}\",\n  \"image\": \"{{ $('Split Response').item.json.imageUrl }}\",\n  \"caption\": \"\ud83d\udccb Price List\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.3
    },
    {
      "id": "578a845b-5b56-433f-8893-a8ed9729697a",
      "name": "Cancel Booking",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        2768,
        1424
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "47svkah7woHaC8Tf",
          "cachedResultUrl": "/workflow/47svkah7woHaC8Tf",
          "cachedResultName": "Cancel Booking"
        },
        "description": "Cancel a gym class booking.\n\nThis tool handles ALL cases and always returns a final response:\n- If booking found and cancelled: returns cancellation confirmation\n- If booking not found: returns \"No active booking found\"  \n- If already cancelled: returns \"already cancelled\" message\n\nWhen you receive the response from this tool:\n1. The task_status field will say \"COMPLETE\"\n2. Send the response field directly to the user\n3. Your job is done - do not call this tool again under any circumstances\n4. Do not verify, do not check again, do not retry",
        "workflowInputs": {
          "value": {
            "date": "={{ $fromAI('date', 'Optional: specific date in YYYY-MM-DD format. Pass this when user specifies a day e.g. cancel yoga on Tuesday. Use pre-calculated dates from session context.', 'string') }}",
            "phone": "={{ $('Check Session Expiry').item.json.phone || '' }}",
            "branch": "={{ $('Check Session Expiry').item.json.branch || '' }}",
            "class_name": "={{ $fromAI('class_name', 'Optional: class name to cancel e.g. yoga, trx. Leave empty to cancel most recent booking.', 'string') }}"
          },
          "schema": [
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "class_name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "class_name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "dc8c531c-3a40-444f-956d-366490d1c5b2",
      "name": "Get Bookings",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        2896,
        1424
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "8AuMVeJN7DrXOxJF",
          "cachedResultUrl": "/workflow/8AuMVeJN7DrXOxJF",
          "cachedResultName": "Get Bookings"
        },
        "description": "Retrieve a user's confirmed gym class bookings. Use when user asks to see/view/show/list/check their bookings or reservations. Call ONCE, then send the response directly to the user and stop.",
        "workflowInputs": {
          "value": {
            "phone": "={{ $('Check Session Expiry').item.json.phone || '' }}",
            "branch": "={{ $('Check Session Expiry').item.json.branch || '' }}"
          },
          "schema": [
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "ae6126ce-651e-4fbf-b69d-dab17271b44f",
      "name": "AI Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2880,
        1152
      ],
      "parameters": {
        "text": "={{ \"today: \" + $now.toFormat('yyyy-MM-dd') + \" (\" + $now.toFormat('cccc') + \")\\nnext Monday: \" + $now.plus({days: (8 - $now.weekday) % 7 || 7}).toFormat('yyyy-MM-dd') + \"\\nnext Tuesday: \" + $now.plus({days: (9 - $now.weekday) % 7 || 7}).toFormat('yyyy-MM-dd') + \"\\nnext Wednesday: \" + $now.plus({days: (10 - $now.weekday) % 7 || 7}).toFormat('yyyy-MM-dd') + \"\\nnext Thursday: \" + $now.plus({days: (11 - $now.weekday) % 7 || 7}).toFormat('yyyy-MM-dd') + \"\\nnext Friday: \" + $now.plus({days: (12 - $now.weekday) % 7 || 7}).toFormat('yyyy-MM-dd') + \"\\nnext Saturday: \" + $now.plus({days: (13 - $now.weekday) % 7 || 7}).toFormat('yyyy-MM-dd') + \"\\nnext Sunday: \" + $now.plus({days: (14 - $now.weekday) % 7 || 7}).toFormat('yyyy-MM-dd') + \"\\nbranch: \" + ($json.branch || \"null\") + \"\\nlatestQuestion: \" + ($json.latestQuestion || \"\") + \"\\nphone: \" + $json.phone + \"\\nuserName: \" + ($json.profileName || \"WhatsApp User\") + \"\\nlastMentionedClass: \" + ($json.lastMentionedClass || \"null\") + \"\\nlastMentionedTime: \" + ($json.lastMentionedTime || \"null\") + \"\\npendingBookings: \" + JSON.stringify($json.pendingBookings || []) + \"\\n\\nUser message: \" + $json.text }}",
        "options": {
          "maxIterations": 10,
          "systemMessage": "## \u26a0\ufe0f TOOL COMPLETION RULE\nWhen ANY tool returns a response containing `done: true` or `status: completed` or `_stop: true`, you MUST:\n1. Send the response field to the user\n2. STOP - do not call any more tools\n3. Your turn is complete\n\n## \u26a0\ufe0f MANDATORY FLOW FOR EVERY QUESTION\n1. NEVER answer any gym question from your own knowledge without calling a tool first\n2. NEVER say \"I'll forward your message\" or \"I'll check with the team\" without actually calling Log Question\n3. If you find yourself about to answer without calling a tool \u2192 STOP and call the right tool first\n4. When the user replies with ONLY a branch name and latestQuestion is not empty \u2192 call the appropriate tool immediately using latestQuestion as the query \u2014 never respond conversationally without calling a tool\n\nFor CLASSES, SCHEDULE requests \u2192 call Classes tool directly (do NOT call FAQ first)\nFor LOCATION, ADDRESS, MAP requests \u2192 call Location tool directly (do NOT call FAQ first)\nFor BOOKING, CANCEL, MY BOOKINGS requests \u2192 call booking tools directly (do NOT call FAQ first)\nFor PRICING, POLICIES, FACILITIES, STAFF, or anything GENERAL \u2192 call FAQ tool first\nIf FAQ returns no useful answer \u2192 ALWAYS call Log Question immediately\n\n## \u26a0\ufe0f MODIFY ORDER IS MANDATORY \u2014 NEVER VIOLATE THIS\nFor ANY modification request (\"modify\", \"change\", \"move\", \"switch\", \"reschedule\"):\n1. Call Cancel Booking FIRST \u2014 always, no exceptions. For class_name, use ONLY the class name the user explicitly mentioned as the one to cancel/modify (e.g. \"modify my tabata\" \u2192 class_name: tabata). If the user only mentions a day (e.g. \"modify my Thursday booking\"), leave class_name empty and pass only the date. NEVER guess or invent a class name.\n2. Wait for Cancel Booking to complete successfully\n3. ONLY THEN call Create Booking for the new class\n4. NEVER call Create Booking before Cancel Booking\n5. NEVER call Create Booking and Cancel Booking simultaneously\n6. If you find yourself about to Create before Cancelling \u2014 STOP and Cancel first\nThis order is non-negotiable. Violating it causes the wrong booking to be cancelled.\n\n## \u26a0\ufe0f BOOKING TOOL RESPONSE RULE\nWhen Create Booking or Cancel Booking returns ANY response:\n1. If the user's message started with a greeting, prepend a brief greeting (e.g. \"Hey [Name]! \ud83d\udc4b\") before the tool response\n2. Copy the ENTIRE response field exactly as returned by the tool \u2014 character for character. Do NOT rewrite, shorten, or summarize it under any circumstances.\n3. The response field may contain a calendar link \u2014 include it exactly as-is, do not remove or summarize it\n4. Do NOT add any other text beyond the greeting + tool response\n5. Do NOT add your own text, apologies, explanations, or follow-up\n6. Do NOT say \"technical issue\", \"logged your request\", or \"team will get back to you\"\n7. Do NOT improvise \u2014 the tool response is the complete message\n8. STOP immediately after sending the tool response\nThis applies to ALL booking outcomes: confirmed, waitlisted, duplicate, cancelled, not found.\n\n# GYMBOT \u2014 Stamina Gym Assistant\n\nYou are GymBot, the AI assistant for Stamina Gym (Lebanon). You serve customers on WhatsApp for the Rabieh and Bikfaya branches.\n\n## YOUR IDENTITY & GOAL\nHelp gym members and prospects get answers, book classes, manage bookings, and feel welcome \u2014 all in real time via WhatsApp. Be warm, efficient, and match the user's language (English, Arabic, French, or Lebanese dialect).\n\n## SESSION DATA YOU RECEIVE\nEvery message includes:\n- **text**: what the user just sent\n- **branch**: \"rabieh\" / \"bikfaya\" / null\n- **phone**: user's WhatsApp number (always available \u2014 never ask for it)\n- **userName**: user's name (may be \"WhatsApp User\")\n- **latestQuestion**: the question the user asked before you asked for their branch\n- **lastMentionedClass**: last class name shown to user\n- **lastMentionedTime**: last class time shown to user\n- **today + next [day] dates**: pre-calculated dates \u2014 always use these for bookings, never calculate dates yourself. CRITICAL: always use the dates from THIS message only \u2014 never use dates from latestQuestion, session state, or previous turns.\n- **pendingBookings**: list of classes requested but not yet booked \u2014 book ALL of these when the user replies\n\n## TOOLS\nCall the right tool based on what the user needs. Never answer from your own knowledge \u2014 always use a tool.\n\n- **Classes** (branch, query, class_type?) \u2014 schedule, class types, today/tomorrow classes\n- **FAQ** (branch, category=\"\") \u2014 prices, membership, facilities, policies, anything general. Always pass category as empty string.\n- **Location** (branch) \u2014 address, map, directions, opening hours, contact. Always send the exact response field from the tool output word for word \u2014 do not rephrase, summarize, or add fields that aren't in the tool response.\n- **Create Booking** (branch, class_name, date, time, phone, user_name?, instructor?) \u2014 book a class. date must be YYYY-MM-DD, time must be HH:MM\n- **Cancel Booking** (phone, branch, class_name?, date?) \u2014 cancel a booking. When user specifies a day (e.g. 'cancel on Tuesday', 'cancel yoga on Monday'), pass date as YYYY-MM-DD using the pre-calculated dates in this session.\n- **Get Bookings** (phone, branch?) \u2014 show user's upcoming bookings\n- **Log Question** (question, phone, language?, confidence?) \u2014 use this when FAQ returns no useful answer\n\n## CORE PRINCIPLES\n\n**1. Always use tools \u2014 never answer from memory**\nEvery question about the gym needs a tool call. Classes, prices, location, policies \u2014 always fetch, never guess.\nThis includes booking requests \u2014 always fetch the current class schedule before booking if you haven't already shown it in this conversation.\n\n**2. Branch is required for all tool calls**\nIf branch is null and the message isn't a greeting, ask: \"Which branch - Rabieh or Bikfaya? \ud83d\udccd\"\nSave what the user asked so you can answer it after they reply with a branch.\nException: greetings never need a branch.\nException: if branch is already set in session, never ask for it again \u2014 use it directly for all tool calls.\n\n**3. Understand intent, don't match keywords**\nRead what the user actually wants. \"What do you offer on Mondays?\" \u2192 Classes tool. \"How much is a monthly sub?\" \u2192 FAQ tool with category=\"\". \"Where are you?\" \u2192 Location tool. Use judgment.\nWhen calling the FAQ tool, always pass category as empty string \"\" \u2014 never filter by category.\n**3b. Common requests with branch set \u2014 act immediately**\n- \"classes\" / \"schedule\" \u2192 call Classes tool immediately with query=\"all classes\". The tool will return the full weekly schedule \u2014 send it directly without asking for timeframe or day preference.\n- \"location\" / \"where are you\" / \"address\" \u2192 call Location tool immediately, do not ask for branch\n- \"my bookings\" / \"show bookings\" \u2192 call Get Bookings immediately\n- \"prices\" / \"membership\" \u2192 call FAQ tool immediately\nNever ask a clarifying question when the intent is clear and branch is already set.\n\n**4. Handle multiple requests in one message**\nIf the user asks or requests multiple things at once, handle each one. Book two classes \u2192 call Create Booking twice. Cancel two classes \u2192 call Cancel Booking twice. Greeting + question \u2192 greet and answer. Never ignore part of a message.\n\n**5. Dates come from session data**\nThe message always includes today's date and pre-calculated next [day] dates. Use them directly. Never calculate dates yourself. Never pass a day name \u2014 always convert to YYYY-MM-DD. Always use the dates from THIS message \u2014 never reuse dates from previous turns or latestQuestion.\n\n**5b. Same-day booking rule**\nWhen booking a class for today's weekday:\n- If the class start time has already passed (e.g. it's 21:00 and class is at 20:00) \u2192 use next week's date for that day\n- If the class start time has NOT passed (e.g. it's 18:00 and class is at 20:00) \u2192 use today's date\n- Always compare current time against the actual class StartTime from the schedule before deciding which date to use\n\n**6. Booking time must match class + day**\nWhen booking, find the time that matches both the class name AND the day from the schedule you showed. Never use lastMentionedTime for an explicit booking request unless the user was vague (\"book it\", \"book that one\").\n\n**7. If a class is on multiple days, ask which day**\nUnless the user already specified a day, list the options and ask before booking.\n\n**7b. Multiple sessions of same class on same day \u2192 ask then book on reply**\nIf the same class runs more than once on the requested day (different times or instructors):\n1. List the options and ask which one\n2. When the user replies with ANY of: an instructor name, a time, \"first\", \"second\", a number \u2192 IMMEDIATELY call Create Booking with that session. No clarifying questions. No re-fetching schedule.\n3. The reply is ALWAYS a selection \u2014 never treat it as a greeting or new request\n4. Use the time and instructor from the option they selected\n5. Never book before the user selects\n6. Never ask \"are you booking or asking?\" \u2014 assume they are booking since that was the last question\n\nExample: You asked \"Yoga at 18:00 with Elie or Yoga at 20:00 with Charbel?\"\nUser replies \"Elie\" \u2192 immediately call Create Booking with class_name=yoga, time=18:00, instructor=Elie\nUser replies \"18:00\" \u2192 immediately call Create Booking with class_name=yoga, time=18:00, instructor=Elie\n\n**7c. Multiple class request with one ambiguous day \u2014 book unambiguous first**\nWhen the user requests classes on multiple days and only ONE day has ambiguity (multiple sessions), you MUST:\n1. IMMEDIATELY call Create Booking for ALL unambiguous days first \u2014 do not wait for user input\n2. In the SAME response, ask ONLY about the ambiguous day\n3. When user replies with their selection \u2192 call Create Booking ONCE for that selection only\n4. Each booking uses the date matching its own day \u2014 \"Thursday tabata\" uses \"Thursday date\", \"Tuesday yoga\" uses \"Tuesday date\"\n5. NEVER use a Monday date for a Tuesday class \u2014 always match class day to its exact date label\n\nExample:\nUser: \"book yoga Tuesday and tabata Thursday\"\n\u2192 IMMEDIATELY call Create Booking: tabata, Thursday date, 18:00, Jane\n\u2192 Then ask: \"Which Tuesday yoga? 18:00 with Elie or 20:00 with Charbel?\"\nUser: \"18 with Elie\"\n\u2192 call Create Booking ONCE: yoga, Tuesday date, 18:00, Elie\n\u2192 Send confirmation for yoga only (tabata already confirmed earlier)\n**7d. When booking after session selection, use the date matching the DAY of the selected session**\nIf the user originally requested \"Tuesday and Thursday\" and selects a Tuesday session:\n- The Tuesday booking date must use the pre-calculated \"next Tuesday\" date\n- The Thursday booking date must use the pre-calculated \"next Thursday\" date\n- NEVER use the same date for both bookings\n- Cross-check: the date passed to Create Booking must match the day of the class being booked\n\n**8. After any tool returns a result, send it and stop**\nDon't call the same tool twice. Don't call Log Question after Cancel Booking. Don't add unnecessary follow-up tools.\nEXCEPTION: During a MODIFICATION request, after Cancel Booking succeeds, you MUST immediately call Create Booking for the new class \u2014 do not stop after cancelling.\nEXCEPTION: During a MULTIPLE BOOKING request per rule 7c, after the first Create Booking succeeds, you MUST immediately call Create Booking again for the remaining class.\n\n**9. When FAQ has no answer, ALWAYS log immediately**\nIf FAQ tool returns no useful answer, you MUST call Log Question in the same response \u2014 do not wait, do not answer from memory, do not improvise.\nThen tell the user:\n\"I don't have that information right now. I've forwarded your question to the team and they'll get back to you soon! \ud83d\ude4f\"\n\n**10. Images from FAQ**\nIf a FAQ result has an image_url field that is not empty, output the raw URL on its own line. If the user asked multiple questions, answer the other questions BEFORE the URL line.\nThe URL must always be the LAST line of your response, with no text after it.\nIMPORTANT: Only include the image_url in your response when the user is asking GENERALLY about membership pricing, price list, or overall prices. If the user asks about a SPECIFIC item (classes, solarium, padel, basketball, kids gymnastics, pilates, personal training), respond with the text answer only \u2014 do NOT include the image URL even if the FAQ result contains one.\n\n## BRANCH HANDLING\n\n- If the user's message contains a branch name (rabieh/bikfaya), use it even if session branch is null \u2014 don't ask again.\n- If the user's message contains a branch name AND a gym request in the same message (e.g. \"what classes at rabieh\", \"book yoga at bikfaya\") \u2192 extract the branch, then handle the request immediately by calling the appropriate tool. Do NOT output a welcome message or ask for branch again.\n- If user sends only a branch name and latestQuestion is empty \u2192 \"Got it! How can I help you with [branch]? \ud83d\ude0a\"\n- If branch is already set in session and the user replies with a single word that matches an instructor name or time \u2192 never ask to confirm branch again. Use the session branch directly and book.\n- If user sends only a branch name and latestQuestion has content \u2192 call the right tool using that latestQuestion.\n- Check if latestQuestion is stale (unrelated to current conversation) \u2014 if stale, treat it as empty.\n- When latestQuestion contains words like \"location\", \"address\", \"where\", \"directions\", \"map\" \u2192 call Location tool immediately with the confirmed branch. Do NOT ask \"how can I help\" \u2014 just fetch and send the location.\n- If branch is already set in session and the user's message contains a location/address/map request \u2192 call Location tool immediately. Never ask for branch again when it's already stored in session.\n- When processing a branch reply, strip any greeting from latestQuestion and focus on the intent only.\n\n## BOOKING RULES\n\n- **Before booking any class**, if you don't have the current schedule visible in this conversation, call Classes tool first to get the real schedule. Never assume a class time or day from memory or session context.\n- **Explicit request** (\"book me yoga on Monday\") \u2192 use what user said, ignore lastMentionedClass/Time\n- **Vague request** (\"book it\", \"book that\") \u2192 use lastMentionedClass and lastMentionedTime\n**Confirmation** (user says \"yes/ok/sure/confirm/yep/yeah\" after you showed a class and asked \"Ready to book it?\") \u2192 immediately call Create Booking using the exact class/day/time/instructor from your previous message. Do NOT ask \"what would you like to say yes to?\". Do NOT ask for clarification. The answer is always the class you just showed them.\n- **Session selection** (your previous message listed multiple sessions and asked which one) \u2192 the user's reply is a selection, NOT a confirmation to book the default. Match their reply to the correct session:\n  - If they reply with an instructor name \u2192 find that instructor's time and book it\n  - If they reply with a time \u2192 book that time\n  - If they reply with \"first/second/1/2\" \u2192 book accordingly\n  - NEVER book using lastMentionedTime when awaiting a session selection \u2014 always use the time that matches their chosen session\n  - Do NOT ask \"would you like me to book?\" \u2014 just book it directly\n- **Multiple bookings** \u2192 follow rule 7c strictly.\n- **Modification** (\"modify my booking to Tuesday\", \"change yoga to spin\", \"move yoga from Monday to Tuesday\"):\n  \u2192 Step 1: Call Cancel Booking FIRST for the old booking \u2014 MANDATORY, no exceptions\n  \u2192 Step 2: ONLY AFTER Cancel Booking completes \u2014 call Create Booking for the new class/day\n  \u2192 Step 3: Send ONE combined message: \"\u2705 Cancelled [old class] on [old day]. \u2705 Booked [new class] on [new day] at [time]!\"\n  \u2192 The new day/class comes from what the user said in their message\n  \u2192 Find the correct time from the schedule you showed earlier\n  \u2192 WARNING: If you call Create Booking before Cancel Booking, the wrong booking will be cancelled. Always cancel first.\n- The tool response is the ONLY source of the confirmation message \u2014 copy it character for character including any URLs or links it contains. Never compose your own confirmation message.\n\n## CANCEL RULES\n\n- Always use phone and branch from session \u2014 never ask the user for these\n- **Pure cancel request** (no modify intent) \u2192 Call Cancel Booking ONCE, send response, STOP\n- **Modify request** \u2192 Call Cancel Booking EXACTLY ONCE, then Call Create Booking EXACTLY ONCE immediately after. Total tool calls for a modify: 2 (one Cancel + one Create). Never call Cancel more than once.\n- Cancel Booking is ONE call per class \u2014 never retry it, never call it twice\n- Never call Cancel Booking more than once for the same class in one request \u2014 if Cancel already returned a response, proceed directly to Create Booking\n- **Cancel all request** (\"cancel my bookings\", \"cancel all my classes\") \u2192 Call Get Bookings first to see all confirmed bookings, then call Cancel Booking once for EACH confirmed booking found. Never cancel just one when the user said \"bookings\" (plural).\n\n## WAITLIST RULE\n\n- When Create Booking returns a waitlist response (\"currently full\", \"added you to the waitlist\") \u2192 send that message ONLY and STOP immediately\n- Do NOT add any apology, explanation, or \"technical issue\" text\n- Do NOT say the team will get back to them \u2014 the waitlist message is complete as-is\n\n## TONE & LANGUAGE\n\n- Match the user's language exactly\n- Be warm and friendly with emojis where natural\n- Keep responses concise \u2014 don't over-explain\n- Never expose technical details, tool names, or internal errors to the user\n- If the user's message starts with a greeting (Hi, Hello, Hey, \u0645\u0631\u062d\u0628\u0627, Bonjour, etc.), always acknowledge it briefly at the start of your response before answering their request. E.g. \"Hey Oscar! \ud83d\udc4b Here's the location...\" \u2014 never skip the greeting.\n- Never send a standalone welcome message when the user's message already contains a request \u2014 just answer the request directly with a brief greeting inline.\n- For new sessions (isNewSession = true) with only a greeticonfirmationng and no request, always respond with exactly:\n\"Hey [userName]! \ud83d\udc4b Welcome to Stamina Gym!\n\nHow can I help you today? Are you looking for class schedules, booking information, or something else? \ud83d\ude0a\n\n(Which branch are you interested in \u2014 Rabieh or Bikfaya? \ud83d\udccd)\""
        },
        "promptType": "define"
      },
      "typeVersion": 3.1
    },
    {
      "id": "2caae3cb-1378-4324-81d5-f3068c694f22",
      "name": "Log Question",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        3024,
        1424
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "sT_r1crAVZMMFi1qd16RD",
          "cachedResultUrl": "/workflow/sT_r1crAVZMMFi1qd16RD",
          "cachedResultName": "Log Unanswered Question"
        },
        "description": "Log a question you cannot answer confidently for staff review. Use when FAQ search returns no relevant answers or you are not confident. After calling this, tell the user that staff will get back to them soon.",
        "workflowInputs": {
          "value": {
            "phone": "={{ $('Check Session Expiry').item.json.phone || '' }}",
            "branch": "={{ $('Check Session Expiry').item.json.branch || '' }}",
            "language": "={{ $fromAI('language', `Language code: 'en', 'ar', or 'fr'`, 'string') }}",
            "question": "={{ $fromAI('question', `The user's exact question text`, 'string') }}",
            "confidence": "={{ $fromAI('confidence', `Your confidence score 0-1 on why you could not answer`, 'number') }}"
          },
          "schema": [
            {
              "id": "question",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "question",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "language",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "language",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "confidence",
              "type": "number",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "confidence",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "bd408327-f4f5-4cd9-907f-449775dd4618",
      "name": "Classes",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        3152,
        1424
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "AhL3OikjwwQOQPfk0DJ3g",
          "cachedResultUrl": "/workflow/AhL3OikjwwQOQPfk0DJ3g",
          "cachedResultName": "Search Classes"
        },
        "description": "Search available gym classes. Use for questions about classes, schedule, today's/tomorrow's classes, or specific class types (yoga, pilates, boxing, TRX, spin, dance, zumba, hiit). ALWAYS pass the user's original question in 'query' \u2014 the sub-workflow uses it to detect time keywords like 'today', 'tomorrow', day names.",
        "workflowInputs": {
          "value": {
            "query": "={{ $fromAI('query', `The user's original question text, e.g. 'what classes today', 'show yoga schedule'. Used for day/time keyword detection.`, 'string') }}",
            "branch": "={{ $fromAI('branch', 'The gym branch: rabieh or bikfaya', 'string') }}",
            "class_type": "={{ '' }}"
          },
          "schema": [
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "query",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "query",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "class_type",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "class_type",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "8c1adaf0-983c-4621-a9c5-9fc7b1cd15cf",
      "name": "Location",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        3280,
        1424
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "Hto0lycXs0vj-CjGpZDCq",
          "cachedResultUrl": "/workflow/Hto0lycXs0vj-CjGpZDCq",
          "cachedResultName": "Get Location"
        },
        "description": "Get complete location and contact information for a gym branch: address, map link, phone numbers, opening hours. Use for location/address/directions/map/contact questions.",
        "workflowInputs": {
          "value": {
            "branch": "={{ $('Check Session Expiry').item.json.branch || '' }}"
          },
          "schema": [
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "branch"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "823f87a3-4289-47e0-b027-e467130d3bfb",
      "name": "FAQ",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        3408,
        1424
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "DBOCXuTYe1UulESii7ETA",
          "cachedResultUrl": "/workflow/DBOCXuTYe1UulESii7ETA",
          "cachedResultName": "Get FAQ Data"
        },
        "description": "Get FAQ answers from the gym's knowledge base. Use for questions about membership types, pricing, payment methods, policies (cancellation, freeze, refund), facilities (parking, showers, lockers), personal training, trial sessions, or anything not covered by classes/location/bookings. Returns structured FAQ data the AI should match to the user's question.",
        "workflowInputs": {
          "value": {
            "branch": "={{ $('Check Session Expiry').item.json.branch || '' }}",
            "category": "={{ $fromAI('category', `Optional category filter: 'pricing', 'membership', 'facilities', 'policies', etc. Leave empty to return all FAQs.`, 'string') }}"
          },
          "schema": [
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "category",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "category",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "5c5bead5-4cf3-4c3e-8997-62160171e90a",
      "name": "Create Booking",
      "type": "@n8n/n8n-nodes-langchain.toolWorkflow",
      "position": [
        3536,
        1424
      ],
      "parameters": {
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "eyDOTlR-XQEzUSMdMxySH",
          "cachedResultUrl": "/workflow/eyDOTlR-XQEzUSMdMxySH",
          "cachedResultName": "Create Booking"
        },
        "description": "Book a gym class for the user. Call ONCE per class, then send the response directly. date MUST be YYYY-MM-DD format. time MUST be HH:MM 24-hour. If the class appears on multiple days and user did not specify one, ask the user to choose a day first.\nINSTRUCTOR RULE: Always pass the exact instructor name from the Classes tool result. If you do not have a Classes tool result visible in the current conversation showing the instructor, call Classes tool FIRST before calling this tool. NEVER pass \"placeholder\", \"TBD\", \"unknown\", \"N/A\" or any invented value \u2014 omit the instructor field entirely if genuinely not found in the schedule.",
        "workflowInputs": {
          "value": {
            "date": "={{ $fromAI('date', `Booking date in YYYY-MM-DD format (compute next occurrence of the class day)`, 'string') }}",
            "time": "={{ $fromAI('time', `Class start time in HH:MM 24-hour format matching the class+day from schedule`, 'string') }}",
            "phone": "={{ $('Check Session Expiry').item.json.phone || '' }}",
            "branch": "={{ $('Check Session Expiry').item.json.branch || '' }}",
            "user_name": "={{ $('Check Session Expiry').item.json.profileName || 'WhatsApp User' }}",
            "class_name": "={{ $fromAI('class_name', `Exact class name from the schedule (e.g. 'yoga', 'trx', 'pilates')`, 'string') }}",
            "instructor": "={{ $fromAI('instructor', 'Instructor name from the class schedule', 'string') }}"
          },
          "schema": [
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "class_name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "class_name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "time",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "time",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "user_name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "user_name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "instructor",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "instructor",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "6b349472-6875-4ca5-9026-8270a73bc5fe",
      "name": "Anthropic Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "position": [
        2624,
        1424
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "claude-haiku-4-5-20251001",
          "cachedResultName": "Claude Haiku 4.5"
        },
        "options": {}
      },
      "typeVersion": 1.3
    },
    {
      "id": "11d6d586-3de7-4754-b938-828ec4b1ef3f",
      "name": "Split Response",
      "type": "n8n-nodes-base.code",
      "position": [
        3904,
        1152
      ],
      "parameters": {
        "jsCode": "const replyText = $('Format Response').item.json.replyText || '';\n\nconst urlMatch = replyText.match(/https:\\/\\/drive\\.google\\.com\\/[^\\s]+/);\nconst imageUrl = urlMatch ? urlMatch[0] : null;\n\nconst textOnly = replyText\n  .split('\\n')\n  .filter(line => !line.includes('drive.google.com'))\n  .join('\\n')\n  .trim();\n\nreturn [{\n  json: {\n    replyText: textOnly,\n    imageUrl: imageUrl,\n    hasImage: !!imageUrl,\n    phone: $('Format Response').item.json.phone,\n    senderWhats: $('Format Response').item.json.senderWhats\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "2e24b2cb-c914-4fae-acde-bd8dc77b5d62",
      "name": "Update  Members Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2752,
        320
      ],
      "parameters": {
        "columns": {
          "value": {
            "phone": "={{ $('Parse Survey Response').item.json.Phone }}",
            "last_survey_date": "={{ $('Parse Survey Response').item.json.SubmittedAt }}"
          },
          "schema": [
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "name",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "name",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "membership_type",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "membership_type",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "join_date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "join_date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "status",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "last_survey_date",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "last_survey_date",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "type": "number",
              "display": true,
              "removed": true,
              "readOnly": true,
              "required": false,
              "displayName": "row_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "phone"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "update",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": 1839231317,
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1CWcULthIhOq8kcaCVoVWJTkbKBSJ6aMum-jSt2_fQUc/edit#gid=1839231317",
          "cachedResultName": "Members"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "eeb2899c-79b7-4139-ad85-a74003cdeb35",
      "name": "Update Session_State Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2944,
        320
      ],
      "parameters": {
        "columns": {
          "value": {
            "Phone": "={{ $('Parse Survey Response').item.json.Phone }}",
            "SurveySubmitted": "=Yes"
          },
          "schema": [
            {
              "id": "Phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Branch",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Lang",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Lang",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "State_JSON",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "State_JSON",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Latest_Question",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Latest_Question",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "UpdatedAt",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "UpdatedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "SurveySubmitted",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "SurveySubmitted",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "SurveyResponseDate",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "SurveyResponseDate",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "HandledBy",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "HandledBy",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "LastStaffReply",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "LastStaffReply",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "type": "number",
              "display": true,
              "removed": true,
              "readOnly": true,
              "required": false,
              "displayName": "row_number",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Phone"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1CWcULthIhOq8kcaCVoVWJTkbKBSJ6aMum-jSt2_fQUc/edit#gid=0",
          "cachedResultName": "Session_State"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "76c19950-3934-4a0e-b13c-2e3314c5691d",
      "name": "Update Session State",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        5008,
        1168
      ],
      "parameters": {
        "columns": {
          "value": {
            "Lang": "={{ $node[\"Normalize Message\"].json.lang.code }}",
            "Phone": "={{ $node[\"Normalize Message\"].json.sessionKey }}",
            "Branch": "={{ $json.branch }}",
            "UpdatedAt": "={{ $now.toISO() }}",
            "State_JSON": "={{ JSON.stringify({ lastMentionedClass: $json.lastMentionedClass, lastMentionedTime: $json.lastMentionedTime, pendingBookings: $json.pendingBookings || [] }) }}",
            "Latest_Question": "={{ $json.latestQuestion }}"
          },
          "schema": [
            {
              "id": "Phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "Phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Branch",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Lang",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Lang",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "State_JSON",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "State_JSON",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "Latest_Question",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "Latest_Question",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "UpdatedAt",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "UpdatedAt",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "SurveySubmitted",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "SurveySubmitted",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "SurveyResponseDate",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "SurveyResponseDate",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "HandledBy",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "HandledBy",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "LastStaffReply",
              "type": "string",
              "display": true,
              "required": false,
              "displayName": "LastStaffReply",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [
            "Phone"
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "appendOrUpdate",
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultUrl": "https://docs.google.com/spreadsheets/d/1CWcULthIhOq8kcaCVoVWJTkbKBSJ6aMum-jSt2_fQUc/edit#gid=0",
          "cachedResultName": "Session_State"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "ef384dc2-4f61-420b-a496-493c2ee68f95",
      "name": "IF Waitlist Handled",
      "type": "n8n-nodes-base.if",
      "position": [
        2560,
        800
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "waitlist-handled-check",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.handled }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "e2aa683c-df72-47d0-b0fb-b0d0a290af29",
      "name": "Send Waitlist Response",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2784,
        688
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"token\": \"{{ $('Read Config').first().json.ultramsg_token }}\",\n  \"to\": \"{{ $('Check Session Expiry').item.json.senderWhats }}\",\n  \"body\": \"{{ $json.response.replace(/\\n/g, '\\\\n') }}\"\n}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "typeVersion": 4.2
    },
    {
      "id": "6ee041d2-703c-418e-8767-befec71cead9",
      "name": "Check Waitlist Offer",
      "type": "n8n-nodes-base.executeWorkflow",
      "position": [
        2368,
        960
      ],
      "parameters": {
        "options": {},
        "workflowId": {
          "__rl": true,
          "mode": "list",
          "value": "2w5N38FCScivrpoV",
          "cachedResultUrl": "/workflow/2w5N38FCScivrpoV",
          "cachedResultName": "Handle Waitlist Yes"
        },
        "workflowInputs": {
          "value": {
            "text": "={{ $json.text }}",
            "phone": "={{ $json.phone }}",
            "branch": "={{ $json.branch || '' }}"
          },
          "schema": [
            {
              "id": "phone",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "phone",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "branch",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "branch",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            },
            {
              "id": "text",
              "type": "string",
              "display": true,
              "removed": false,
              "required": false,
              "displayName": "text",
              "defaultMatch": false,
              "canBeUsedToMatch": true
            }
          ],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": true
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "ebd42311-b626-4498-8723-1b5a813a4004",
      "name": "Lookup Session For Survey",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        1024,
        416
      ],
      "parameters": {
        "options": {},
        "filtersUI": {
          "values": [
            {
              "lookupValue": "={{ $json.phone || $json.sessionKey }}",
              "lookupColumn": "Phone"
            }
          ]
        },
        "sheetName": {
          "__rl": true,
          "mode": "list",
          "value": "gid=0",
          "cachedResultName": "Session_State"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7,
      "alwaysOutputData": true
    },
    {
      "id": "80e0db13-d474-4aa2-b056-1d6507265221",
      "name": "IF Valid Survey",
      "type": "n8n-nodes-base.if",
      "position": [
        1408,
        416
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "valid-check",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.valid }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "877f2861-f779-4d87-9559-63880525892d",
      "name": "Send Already Submitted Reply",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        1632,
        512
      ],
      "parameters": {
        "url": "={{ 'https://api.ultramsg.com/' + $('Read Config').first().json.ultramsg_instance + '/messages/chat' }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "token",
              "value": "={{ $('Read Config').first().json.ultramsg_token }}"
            },
            {
              "name": "to",
              "value": "={{ $json.Phone ? String($json.Phone) + '@c.us' : $json.senderWhats }}"
            },
            {
              "name": "body",
              "value": "={{ $json.replyText }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "bd3da473-e2d2-4c8a-892b-d97c3970b696",
      "name": "Restore Session Data",
      "type": "n8n-nodes-base.code",
      "position": [
        2688,
        1152
      ],
      "parameters": {
        "jsCode": "const session = $node[\"Check Session Expiry\"].json;\nconst waitlistResult = $input.item.json;\nreturn [{ json: { ...session, handled: waitlistResult.handled } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "060da515-c4a1-4423-a6f7-c91a8d148e2a",
      "name": "Read Config",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        192,
        416
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Gymbot_Config"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "YOUR_GOOGLE_SHEET_ID"
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "989cf9eb-a0c8-4eef-a212-f04237f4f57e",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -736,
        256
      ],
      "parameters": {
        "color": 3,
        "width": 712,
        "height": 716,
        "content": "## \ud83c\udfcb\ufe0f GymBot Pro \u2014 WhatsApp AI Booking System\n\n**Who is this for:** Gym owners and fitness studios who want to automate class bookings, cancellations, FAQ responses, and staff notifications via WhatsApp \u2014 without expensive booking software.\n\n**What it does:**\n- Customers message your gym WhatsApp in plain language (English, Arabic, or French)\n- AI understands the request and books/cancels/modifies classes automatically\n- Staff receive instant WhatsApp notifications for every action\n- Google Calendar events are created automatically\n- All data stored in Google Sheets \u2014 you own it\n\n**How it works:**\n1. WhatsApp message received via UltraMsg webhook\n2. Voice messages are transcribed automatically (Whisper)\n3. Session state is loaded from Google Sheets\n4. AI Agent (Claude Haiku) processes the request using 7 tools\n5. Response is sent back via WhatsApp\n\n## \u2699\ufe0f Setup Requirements\n- **n8n** Cloud Starter or self-hosted\n- **UltraMsg** account \u2014 set webhook to this workflow's URL\n- **Anthropic API key** \u2014 Claude Haiku model\n- **Google Sheets** \u2014 copy the included template, add Sheet ID to Gymbot_Config tab\n- **Google Calendar** \u2014 one calendar per branch, add Calendar IDs to Gymbot_Config\n\n## \ud83d\udccb Gymbot_Config Sheet Columns Required\n`ultramsg_token` \u00b7 `ultramsg_instance` \u00b7 `anthropic_api_key` \u00b7 `sheet_id` \u00b7 `branch_1` \u00b7 `branch_2` \u00b7 `staff_phone_branch_1` \u00b7 `staff_phone_branch_2` \u00b7 `calendar_id_branch_1` \u00b7 `calendar_id_branch_2`\n\n> \ud83d\udca1 The full system (15 workflows + Google Sheets template + 40-page setup guide) is available at **oscarbek.gumroad.com/l/gymbot**"
      },
      "typeVersion": 1
    },
    {
      "id": "3524e9dd-7906-4b66-a0e5-b4c4c1272e69",
      "name": "Section: Entry Point",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        288
      ],
      "parameters": {
        "color": 5,
        "width": 588,
        "height": 308,
        "content": "## \ud83d\udce5 Entry Point\nReceives all incoming WhatsApp messages via UltraMsg webhook.\nReads config from Gymbot_Config sheet. Normalizes message text."
      },
      "typeVersion": 1
    },
    {
      "id": "e1f921b1-850e-4b6d-91b8-ae119f1a5706",
      "name": "Section: Voice",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        992,
        720
      ],
      "parameters": {
        "color": 5,
        "width": 792,
        "height": 376,
        "content": "## \ud83c\udfa4 Voice Message Handling\nIf the message is a voice note, downloads the audio and transcribes it using OpenAI Whisper before continuing."
      },
      "typeVersion": 1
    },
    {
      "id": "9a4c81dc-e87c-4d12-90ae-d18a44c727aa",
      "name": "Section: Survey",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        960,
        -64
      ],
      "parameters": {
        "color": 6,
        "width": 1400,
        "height": 740,
        "content": "## \ud83d\udcca Survey & Staff Commands\nHandles two special flows:\n- **Staff command** (`!survey`): triggers satisfaction survey to all active members\n- **Survey response**: collects 1-4 ratings from members, saves to Members sheet, notifies staff\n\n> \u26a0\ufe0f Survey responses must be in format: `X,X,X,X,X` (5 comma-separated numbers 1-4)"
      },
      "typeVersion": 1
    },
    {
      "id": "fe29034c-9f9c-46c5-9ead-a9ac51683550",
      "name": "Section: Session",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1840,
        736
      ],
      "parameters": {
        "color": 4,
        "width": 480,
        "height": 364,
        "content": "## \ud83d\udcbe Session Management\nLoads user session from Session_State sheet. Detects expired sessions.\nRestores branch, language, and conversation context for returning users."
      },
      "typeVersion": 1
    },
    {
      "id": "fd7cc246-1c82-4c69-a0e8-9bd8956c48b9",
      "name": "Section: Waitlist",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2512,
        576
      ],
      "parameters": {
        "color": 2,
        "width": 560,
        "height": 344,
        "content": "## \ud83d\udd14 Waitlist Handler\nChecks if incoming message is a waitlist offer response (Yes/No).\nIf Yes \u2014 calls Handle Waitlist sub-workflow to complete booking."
      },
      "typeVersion": 1
    },
    {
      "id": "e839eba6-9f42-44b3-b56e-7b0939e0345c",
      "name": "Section: AI Agent",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2592,
        976
      ],
      "parameters": {
        "color": 3,
        "width": 1100,
        "height": 620,
        "content": "## \ud83e\udd16 AI Agent \u2014 Claude Haiku\nThe brain of the system. Uses 7 tool sub-workflows:\n- **Create Booking** \u00b7 **Cancel Booking** \u00b7 **Classes** (search schedule)\n- **Get Bookings** \u00b7 **FAQ** \u00b7 **Location** \u00b7 **Log Question**\n\n> \u26a0\ufe0f All tool sub-workflows must be imported and active before this workflow runs.\n> \u26a0\ufe0f Each sub-workflow must have exactly ONE terminal node to prevent infinite retry loops."
      },
      "typeVersion": 1
    },
    {
      "id": "5bcaf0be-99fa-490a-8af8-5ef0b3d66e00",
      "name": "Section: Response",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3696,
        976
      ],
      "parameters": {
        "color": 5,
        "width": 640,
        "height": 620,
        "content": "## \ud83d\udce4 Response & Session Save\nSplits long responses on `---` delimiter to send as separate WhatsApp messages.\nUpdates Session_State sheet with latest conversation context."
      },
      "typeVersion": 1
    },
    {
      "id": "32396898-159f-4528-a6b7-c1dd47cb926e",
      "name": "Section: Session Update",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4752,
        1008
      ],
      "parameters": {
        "color": 4,
        "width": 500,
        "height": 344,
        "content": "## \ud83d\udcbe Session & Question Logging\nSaves updated session state back to Google Sheets after every conversation turn.\nLogs unanswered questions to Unanswered_QA sheet for staff review and FAQ auto-append."
      },
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "executionOrder": "v1"
  },
  "versionId": "66eeda6c-004a-483b-a852-ee9ae500b544",
  "connections": {
    "If": {
      "main": [
        [
          {
            "node": "Send Price List",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Determine Latest Question",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "FAQ": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Classes": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Webhook": {
      "main": [
        [
          {
            "node": "Read Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Agent": {
      "main": [
        [
          {
            "node": "Format Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Location": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Read Config": {
      "main": [
        [
          {
            "node": "Normalize Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Survey": {
      "main": [
        [
          {
            "node": "Mark Survey Sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Bookings": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Log Question": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "IF: Is Voice?": {
      "main": [
        [
          {
            "node": "Download Audio",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Lookup Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send WhatsApp": {
      "main": [
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cancel Booking": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Create Booking": {
      "ai_tool": [
        [
          {
            "node": "AI Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Download Audio": {
      "main": [
        [
          {
            "node": "Transcribe (Whisper)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lookup Session": {
      "main": [
        [
          {
            "node": "Check Session Expiry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Thank You": {
      "main": [
        [
          {
            "node": "Format Staff Notification",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Response": {
      "main": [
        [
          {
            "node": "Send WhatsApp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Response": {
      "main": [
        [
          {
            "node": "Split Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Valid Survey": {
      "main": [
        [
          {
            "node": "Append Survey Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send Already Submitted Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Price List": {
      "main": [
        [
          {
            "node": "Determine Latest Question",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Thank You": {
      "main": [
        [
          {
            "node": "Send Thank You",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark Survey Sent": {
      "main": [
        [
          {
            "node": "Format Staff Confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Message": {
      "main": [
        [
          {
            "node": "Detect Staff Survey Command",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Waitlist Handled": {
      "main": [
        [
          {
            "node": "Send Waitlist Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Restore Session Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Transcription": {
      "main": [
        [
          {
            "node": "Lookup Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notify Staff Survey": {
      "main": [
        [
          {
            "node": "Update  Members Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Staff Listt": {
      "main": [
        [
          {
            "node": "Notify Staff Survey",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Active Members": {
      "main": [
        [
          {
            "node": "Filter Active Members",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Anthropic Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Check Session Expiry": {
      "main": [
        [
          {
            "node": "Check Waitlist Offer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Waitlist Offer": {
      "main": [
        [
          {
            "node": "IF Waitlist Handled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Restore Session Data": {
      "main": [
        [
          {
            "node": "AI Agent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Transcribe (Whisper)": {
      "main": [
        [
          {
            "node": "Merge Transcription",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Active Members": {
      "main": [
        [
          {
            "node": "Send Survey",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Survey Response": {
      "main": [
        [
          {
            "node": "IF Valid Survey",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update  Members Sheet": {
      "main": [
        [
          {
            "node": "Update Session_State Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Append Survey Response": {
      "main": [
        [
          {
            "node": "Format Thank You",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Determine Latest Question": {
      "main": [
        [
          {
            "node": "Update Session State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Staff Confirmation": {
      "main": [
        [
          {
            "node": "Send Staff Confirmation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Staff Notification": {
      "main": [
        [
          {
            "node": "Prepare Staff Listt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lookup Session For Survey": {
      "main": [
        [
          {
            "node": "Parse Survey Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect Staff Survey Command": {
      "main": [
        [
          {
            "node": "Switch: Staff Command or Survey",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch: Staff Command or Survey": {
      "main": [
        [
          {
            "node": "Read Active Members",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Lookup Session For Survey",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "IF: Is Voice?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}