{
  "name": "W4.1 - ROUTER (State + Voice)",
  "active": false,
  "settings": {
    "executionTimeout": 300,
    "saveExecutionProgress": true,
    "saveManualExecutions": true
  },
  "nodes": [
    {
      "parameters": {},
      "id": "a4b9f8d3-eddb-4b9f-93ba-ea4b00b520d6",
      "name": "IN - From Adapters",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1,
      "position": [
        -2400,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst required = ['channel','tenantId','restaurantId','userId','conversationKey','message','metadata'];\nconst missing = required.filter(k => e[k] === undefined || e[k] === null);\nif (missing.length) {\n  return [{json: { ...e, _err: { code:'BAD_EVENT', missing } }}];\n}\n// Normalize message fields\ne.message = e.message || {};\ne.message.type = (e.message.type || 'text').toString();\ne.message.text = (e.message.text || '').toString();\ne.message.buttonId = (e.message.buttonId || '').toString();\nreturn [{json: e}];"
      },
      "id": "5594fe8a-92cd-4d2c-909d-c9286e1a26e2",
      "name": "C0 - Validate Event",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2150,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const crypto = require('crypto');\nconst e = $json;\nconst ctx = e.tenant_context || {};\nconst secret = ($env.TENANT_CONTEXT_SECRET || 'fallback-secret-6789').toString();\nconst seal = crypto.createHmac('sha256', secret).update(JSON.stringify(ctx)).digest('hex');\nif (e.tenant_context_seal && seal !== e.tenant_context_seal) {\n  throw new Error('TENANT_CONTEXT_TAMPERED');\n}\nreturn [{json:e}];"
      },
      "id": "verify-seal-router",
      "name": "B0 - Verify Tenant Context Seal",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2075,
        150
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO public.restaurant_users (tenant_id, restaurant_id, channel, user_id, role)\nVALUES ($1, $2, $3, $4, 'customer')\nON CONFLICT (restaurant_id, channel, user_id) DO NOTHING;",
        "additionalFields": {
          "queryParams": "={{[$json.tenantId, $json.restaurantId, $json.channel, $json.userId]}}"
        }
      },
      "id": "e6a4b12c-3d4f-4e5a-8b6c-7d8e9f0a1b2c",
      "name": "Ensure Customer Profile",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -2035,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n  COALESCE((SELECT state_json FROM public.conversation_state WHERE conversation_key=$1), '{}'::jsonb) AS state_json,\n  COALESCE((SELECT cart_json  FROM public.carts WHERE conversation_key=$1),  '{\"items\":[]}'::jsonb) AS cart_json,\n  (SELECT locale FROM public.customer_preferences WHERE tenant_id=$2 AND phone=$3) AS pref_locale,\n  (SELECT row_to_json(sc) FROM public.system_configs sc LIMIT 1) AS system_config,\n  COALESCE((\n    SELECT jsonb_object_agg(x.key||'::'||x.locale, x.content)\n    FROM (\n      SELECT DISTINCT ON (key, locale)\n        key, locale, content\n      FROM public.message_templates\n      WHERE (tenant_id=$2 OR tenant_id='_GLOBAL')\n        AND locale IN ('fr','ar')\n      ORDER BY key, locale, CASE WHEN tenant_id=$2 THEN 0 ELSE 1 END\n    ) x\n  ), '{}'::jsonb) AS templates_json,\n  COALESCE((\n    SELECT jsonb_object_agg(x.key||'::'||x.locale, x.variables)\n    FROM (\n      SELECT DISTINCT ON (key, locale)\n        key, locale, variables\n      FROM public.message_templates\n      WHERE (tenant_id=$2 OR tenant_id='_GLOBAL')\n        AND locale IN ('fr','ar')\n      ORDER BY key, locale, CASE WHEN tenant_id=$2 THEN 0 ELSE 1 END\n    ) x\n  ), '{}'::jsonb) AS template_vars_json;\n",
        "additionalFields": {
          "queryParams": "={{[$json.conversationKey, $json.tenantId, $json.userId]}}"
        }
      },
      "id": "32592903-4875-4883-9115-b570fb587af0",
      "name": "C1 - Load State+Cart (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -1920,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const event = $json;\nconst state = ($json.state_json && typeof $json.state_json === 'object') ? $json.state_json : {};\n\n// FORENSIC-FIX (SEC-009): Ghost Session Detection\n// If a state exists but belongs to a different user ID or tenant ID, it's a \"Ghost\" or Hijack attempt.\nif (state.userId && state.userId !== event.userId) {\n  state.stage = 'START'; // Force reset\n  state._ghost_detected = true;\n}\nif (state.tenantId && state.tenantId !== event.tenantId) {\n  state.stage = 'START'; // Force reset\n  state._tenant_collision_detected = true;\n}\nstate.userId = event.userId; // Sync current owner\nstate.tenantId = event.tenantId; // Sync current tenant\n\nconst cart = ($json.cart_json && typeof $json.cart_json === 'object') ? $json.cart_json : {items:[]};\nconst sys = $json.system_config || {};\n\nconst l10nEnabled = (($env.L10N_ENABLED || sys.l10n_enabled || 'false').toString().toLowerCase() === 'true');\nconst stickyArEnabled = (($env.L10N_STICKY_AR_ENABLED || 'false').toString().toLowerCase() === 'true');\nconst stickyArThreshold = parseInt(($env.L10N_STICKY_AR_THRESHOLD || '2').toString(), 10);\n\nconst prefLocaleDb = ($json.pref_locale || '').toString().trim().toLowerCase();\nconst text = (event?.message?.text || '').toString();\n\n// Arabic script detection (Unicode blocks)\nconst hasArabic = /[\\u0600-\\u06FF\\u0750-\\u077F\\u08A0-\\u08FF]/.test(text);\nconst detectedLocale = hasArabic ? 'ar' : 'fr';\n\n// Persisted preference (only set by LANG command); do not overwrite based on message script.\nconst localePref = (state.localePref || state.locale || prefLocaleDb || event.metadata?.locale || 'fr').toString().trim().toLowerCase();\n\n// Sticky Arabic session rule: if user sent Arabic script N times, allow Darija (Latin) to respond in AR\nconst prevArCount = parseInt((state.arabicScriptCount || 0).toString(), 10) || 0;\nconst newArCount = (l10nEnabled && stickyArEnabled && hasArabic) ? (prevArCount + 1) : prevArCount;\nconst stickyAr = !!state.stickyAr || (l10nEnabled && stickyArEnabled && (newArCount >= (isNaN(stickyArThreshold) ? 2 : stickyArThreshold)));\n\nconst merged = {\n  stage: state.stage || 'START',               // START | COLLECTING | CONFIRMING | PLACED\n  serviceMode: state.serviceMode || null,      // sur_place | a_emporter | livraison\n  localePref,\n  locale: localePref,\n  arabicScriptCount: newArCount,\n  stickyAr: stickyAr,\n  lastIntent: state.lastIntent || null,\n  lastReplyAt: state.lastReplyAt || null\n};\n\nconst safeCart = {\n  serviceMode: cart.serviceMode || merged.serviceMode,\n  items: Array.isArray(cart.items) ? cart.items : [],\n  note: (cart.note || '').toString()\n};\n\n// Attach templates loaded from DB\nconst templates = ($json.templates_json && typeof $json.templates_json === 'object') ? $json.templates_json : {};\nconst templateVars = ($json.template_vars_json && typeof $json.template_vars_json === 'object') ? $json.template_vars_json : {};\n\nreturn [{\n  json: {\n    ...event,\n    state: merged,\n    cart: safeCart,\n    l10n: {\n      enabled: l10nEnabled,\n      prefLocale: localePref,\n      detectedLocale,\n      hasArabic,\n      stickyArEnabled,\n      stickyArThreshold: (isNaN(stickyArThreshold) ? 2 : stickyArThreshold),\n    },\n    templates,\n    templateVars,\n    config: sys\n  }\n}];\n"
      },
      "id": "fe44ee07-cb63-4764-bf72-753efa6e4cce",
      "name": "C2 - Merge State Defaults",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1700,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst cfg = e.config || {};\n\nfunction isIpLiteral(host) {\n  return /^\\d{1,3}(?:\\.\\d{1,3}){3}$/.test(host);\n}\nfunction isPrivateIp(ip) {\n  const parts = ip.split('.').map(x => Number(x));\n  if (parts.length !== 4 || parts.some(n => Number.isNaN(n) || n < 0 || n > 255)) return true;\n  const [a,b] = parts;\n  if (a === 10) return true;\n  if (a === 127) return true;\n  if (a === 0) return true;\n  if (a === 169 && b === 254) return true;\n  if (a === 192 && b === 168) return true;\n  if (a === 172 && b >= 16 && b <= 31) return true;\n  return false;\n}\nfunction normalizeAllowlist(v) {\n  return (v || '').toString()\n    .split(',')\n    .map(s => s.trim().toLowerCase())\n    .filter(Boolean);\n}\nfunction hostAllowed(host, allow) {\n  const h = host.toLowerCase();\n  return allow.some(a => h === a || h.endsWith('.' + a));\n}\nfunction validateAudioUrl(u, config = {}) {\n  try {\n    const url = new URL(u);\n    if (url.protocol !== 'https:') return { ok:false, reason:'https_only' };\n    if (url.username || url.password) return { ok:false, reason:'no_credentials' };\n    const host = url.hostname || '';\n    if (!host) return { ok:false, reason:'missing_host' };\n    if (host === 'localhost' || host.endsWith('.local')) return { ok:false, reason:'localhost_blocked' };\n    if (isIpLiteral(host)) return { ok:false, reason:'ip_literal_blocked', host };\n    if (host.includes(':')) return { ok:false, reason:'ipv6_literal_blocked', host };\n\n    const allow = normalizeAllowlist(config.allowed_audio_domains || $env.ALLOWED_AUDIO_DOMAINS || 'whatsapp.com,fbcdn.net');\n    if (!allow.length) return { ok:false, reason:'allowlist_empty' };\n    if (!hostAllowed(host, allow)) return { ok:false, reason:'domain_not_allowed', host, allow };\n    return { ok:true, host };\n  } catch (err) {\n    return { ok:false, reason:'invalid_url' };\n  }\n}\n\nif (e.message?.type !== 'audio' || !e.message?.audio?.url) {\n  return [{json: {...e, userText: (e.message?.type === 'button') ? e.message.buttonId : e.message.text }}];\n}\n\nconst sttUrl = (cfg.stt_api_url || $env.STT_API_URL || '').toString();\nif (!sttUrl) {\n  return [{json: {...e, userText: '', stt: {ok:false, reason:'STT_API_URL not set'} }}];\n}\n\nconst audioUrl = e.message.audio.url.toString();\nconst v = validateAudioUrl(audioUrl, cfg);\nif (!v.ok) {\n  return [{\n    json: {\n      ...e,\n      userText: '',\n      stt: { ok:false, reason:'AUDIO_URL_BLOCKED', details: v },\n      _sec: { ...(e._sec||{}), audioUrlBlocked:true, audioBlockReason: v.reason, audioHost: v.host || '' }\n    }\n  }];\n}\n\ntry {\n  const sttRes = await $httpRequest({\n    method: 'POST',\n    url: sttUrl,\n    body: { audioUrl, mime: e.message.audio.mime || 'audio/ogg' },\n    json: true,\n    timeout: 60000\n  });\n\n  const transcript = (sttRes.text || sttRes.transcript || '').toString().trim();\n  const confidence = Number(sttRes.confidence ?? 0);\n\n  e.stt = { ok:true, transcript, confidence, provider: sttRes.provider || 'stt' };\n  e.userText = transcript;\n\n  return [{json: e}];\n} catch (err) {\n  return [{json: {...e, userText: '', stt: {ok:false, error: (err && err.message) ? err.message : 'stt_failed'} }}];\n}\n"
      },
      "id": "3245f83b-5ca2-440a-8e4a-f51849433a47",
      "name": "C3 - Voice STT (optional)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1480,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json._sec.audioUrlBlocked}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "8b05a0d1-3841-4d30-91e8-93fa578e3d23",
      "name": "C3x - AudioUrl Blocked?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        420,
        160
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO security_events(tenant_id, restaurant_id, conversation_key, channel, user_id, event_type, severity, payload_json)\nVALUES ($1,$2,$3,$4,$5,'AUDIO_URL_BLOCKED','HIGH',\njsonb_build_object('reason',$6,'host',$7,'audio_url',$8,'trace_id',$9))\nRETURNING 1;",
        "additionalFields": {
          "queryParams": "={{[$json.tenantId, $json.restaurantId, $json.conversationKey, $json.channel, $json.userId, $json._sec.audioBlockReason, $json._sec.audioHost, $json.message.audio.url, $json.metadata.msgId]}}"
        }
      },
      "id": "ed8e840f-3f4c-4908-9d31-b230ff847d9f",
      "name": "C3y - Log SSRF Block (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        640,
        80
      ]
    },
    {
      "id": "3c6591c1-0ab7-4c2b-a34c-4a59aec61ebf",
      "name": "C3b - Menu Cache Get",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -1500,
        -80
      ],
      "parameters": {
        "language": "javascript",
        "jsCode": "const sd = this.getWorkflowStaticData('global');\nconst ttl = Number($env.MENU_CACHE_TTL_SEC || 300);\nconst rid = ($json.restaurantId || '').toString();\nconst key = `menu:${rid}`;\nconst now = Date.now();\nconst entry = sd[key];\nif (entry && entry.ts && (now - entry.ts) < (ttl*1000) && Array.isArray(entry.items) && Array.isArray(entry.options)) {\n  return [{ json: { ...$json, items: entry.items, options: entry.options, _menuCache: 'HIT' } }];\n}\nreturn [{ json: { ...$json, _menuCache: 'MISS' } }];\n"
      }
    },
    {
      "id": "66352fcb-b39c-4002-b3af-f462ebea0d91",
      "name": "C3c - Menu Cache Hit?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -1280,
        -80
      ],
      "parameters": {
        "conditions": {
          "string": [
            {
              "value1": "={{$json._menuCache}}",
              "operation": "equal",
              "value2": "HIT"
            }
          ]
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT\n      $1::text AS restaurant_id,\n      COALESCE((\n        SELECT jsonb_agg(row_to_json(mi)) FROM (\n          SELECT item_code, label, price_cents, category\n          FROM menu_items\n          WHERE restaurant_id=$1 AND active=true\n          ORDER BY category, item_code\n        ) mi\n      ), '[]'::jsonb) AS items,\n      COALESCE((\n        SELECT jsonb_agg(row_to_json(mo)) FROM (\n          SELECT option_code, item_code, label, kind, price_delta_cents\n          FROM menu_item_options\n          WHERE restaurant_id=$1 AND active=true\n          ORDER BY item_code, option_code\n        ) mo\n      ), '[]'::jsonb) AS options;",
        "additionalFields": {
          "queryParams": "={{[$json.restaurantId]}}"
        }
      },
      "id": "8963c7d6-dd8e-49f3-964e-25e0a0ecb2a6",
      "name": "C4 - Load Menu Index (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -1260,
        0
      ]
    },
    {
      "id": "174c9d7e-0915-407e-bc8d-20f7a7861fe6",
      "name": "C4b - Menu Cache Set",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -1040,
        0
      ],
      "parameters": {
        "language": "javascript",
        "jsCode": "const sd = this.getWorkflowStaticData('global');\nconst rid = ($json.restaurant_id || $json.restaurantId || '').toString();\nif (rid) {\n  sd[`menu:${rid}`] = { ts: Date.now(), items: ($json.items||[]), options: ($json.options||[]) };\n}\nreturn [{ json: { ...$json, _menuCache: 'SET' } }];\n"
      }
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst items = Array.isArray($json.items) ? $json.items : [];\nconst options = Array.isArray($json.options) ? $json.options : [];\n\nconst itemMap = {};\nfor (const it of items) itemMap[it.item_code] = it;\n\nconst optionMap = {};\nfor (const op of options) optionMap[op.option_code] = op;\n\n// grouped options by item\nconst optionsByItem = {};\nfor (const op of options) {\n  optionsByItem[op.item_code] = optionsByItem[op.item_code] || [];\n  optionsByItem[op.item_code].push(op);\n}\n\nreturn [{json: {...e, menu: {items, options, itemMap, optionMap, optionsByItem}}}];"
      },
      "id": "2ea6726a-ba2a-4a32-a70e-753d04865b63",
      "name": "C5 - Build Menu Maps",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1040,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\n\n// Raw text inputs\nconst textRaw = (e.userText || '').toString().trim();\nconst buttonId = (e.message?.buttonId || '').toString().trim();\nconst msgText = (e.message?.text || '').toString().trim();\nconst text = (e.message?.type === 'button') ? buttonId : (msgText || textRaw);\nconst isButton = (e.message?.type === 'button');\n\n// ---- L10N: respond AR if message contains Arabic script, else FR.\n// LANG FR / LANG AR still works and persists preference.\nconst l10nEnabled = !!(e.l10n && e.l10n.enabled);\nconst hasArabic = (e.l10n && typeof e.l10n.hasArabic === 'boolean') ? e.l10n.hasArabic : /[\u0600-\u06ff\u0750-\u077f\u08a0-\u08ff]/.test(msgText || text || '');\nlet responseLocale = hasArabic ? 'ar' : 'fr';\n// P2-01: Darija detection (Latin-script Moroccan Arabic)\nconst darijaPatterns = [\n  'chno kayn','chnou kayen','wach kayn','wesh kayn','ash kayn','fin menu',\n  'nchouf','warini','bghit','wakha','wah','iyeh','mzyan','zwina',\n  'kml','kammel','kmel','ncommandi','sift','salam','slm','labas'\n];\nconst isDarija = !hasArabic && darijaPatterns.some(p => lower.includes(p));\nif (isDarija && l10nEnabled) {\n  responseLocale = 'darija';\n}\n\n\n// Buttons carry no script; keep last response locale to avoid flipping language on UI actions\nif (l10nEnabled && isButton) {\n  const prev = ((e.state && (e.state.lastResponseLocale || e.state.localePref || e.state.locale)) || '').toString().trim().toLowerCase();\n  if (prev === 'ar' || prev === 'fr') responseLocale = prev;\n}\n\n// NO REGRESSION: if feature disabled, keep legacy locale selection\nif (!l10nEnabled) {\n  responseLocale = ((e.state && (e.state.localePref || e.state.locale)) || 'fr').toString().toLowerCase();\n}\n\n// Normalize\n// P2-01: Support fr, ar, darija locales\nresponseLocale = ['ar','darija'].includes(responseLocale) ? responseLocale : 'fr';\n\nconst langMatch = /^\\s*lang\\s+(fr|ar)\\s*$/i.exec((msgText || textRaw || '').toString());\nif (l10nEnabled && langMatch) {\n  responseLocale = langMatch[1].toLowerCase() === 'dz' ? 'darija' : langMatch[1].toLowerCase();\n  e.state = e.state || {};\n  e.state.localePref = responseLocale;\n  e.state.locale = responseLocale; // keep legacy field aligned\n  // Sticky AR session control (only if enabled)\n  if (e.l10n && e.l10n.stickyArEnabled && responseLocale === 'fr') {\n    e.state.arabicScriptCount = 0;\n    e.state.stickyAr = false;\n  } else if (e.l10n && e.l10n.stickyArEnabled && responseLocale === 'ar') {\n    const thr = (e.l10n && e.l10n.stickyArThreshold) ? e.l10n.stickyArThreshold : 2;\n    e.state.arabicScriptCount = Math.max(parseInt(e.state.arabicScriptCount || 0, 10) || 0, thr);\n    e.state.stickyAr = true;\n  }\n  e.l10nPersistLocale = true;\n}\n\n// Templates (loaded from DB) + safe renderer\nconst templates = (e.templates && typeof e.templates === 'object') ? e.templates : {};\nconst templateVars = (e.templateVars && typeof e.templateVars === 'object') ? e.templateVars : {};\n\nfunction renderTemplate(content, vars, allowed) {\n  const v = vars && typeof vars === 'object' ? vars : {};\n  const allow = Array.isArray(allowed) ? new Set(allowed.map(String)) : null;\n  return (content || '').replace(/\\{\\{\\s*([a-zA-Z0-9_]+)\\s*\\}\\}/g, (_, k) => {\n    const key = String(k);\n    if (allow && !allow.has(key)) return '';\n    const val = v[key];\n    return (val === undefined || val === null) ? '' : String(val);\n  }).replace(/\\{\\{[^}]+\\}\\}/g,'').trim();\n}\n\nfunction T(key, vars = {}, locale = responseLocale, fallback = '') {\n  const k = `${key}::${locale}`;\n  const kfr = `${key}::fr`;\n  const content = templates[k] || templates[kfr] || fallback || '';\n  const allowed = templateVars[k] || templateVars[kfr] || null;\n  return renderTemplate(content, vars, allowed);\n}\n\nfunction BTN(id, frTitle, arTitle, darijaTitle) {\n  // P2-01: Support darija in buttons\n  const title = responseLocale === 'ar' ? arTitle : (responseLocale === 'darija' ? (darijaTitle || frTitle) : frTitle);\n  return { id, title };\n}\n\n\nconst menuItems = e.menu?.items || [];\nconst itemMap = e.menu?.itemMap || {};\nconst optionMap = e.menu?.optionMap || {};\nconst optionsByItem = e.menu?.optionsByItem || {};\n\nconst riskFlags = [];\nconst lower = (text || '').toLowerCase();\n/**\n * Darija (translit Latin) shortcuts.\n * Note: default replies follow script rule (AR when Arabic script is used).\n * Extra: if sticky AR is active (after N Arabic-script messages), Darija (Latin) can also reply in AR.\n */\nconst darijaMenu = [\n  'chno kayn','chnou kayen','chno kayen','wach kayn','wesh kayn','wesh kayen','wach kayen',\n  'menu','menou','minou','mnu','lmenu','lmnu','carte','la carte','carte svp',\n  'nchouf menu','nchouf lmenu','nchoof menu','nchoof lmenu','bghit nchoof menu','bghit nchouf menu',\n  'show menu','show lmenu','affiche menu','voir menu','menu daba','menu m3ak','menu m3aya'\n];\nconst darijaCheckout = [\n  'kml','kammel','kmel','kmel commande','kml commande','kml daba','kmel daba',\n  'checkout','check out','tchekout','confirm','confirmer','valider','validation',\n  'bghit ncommandi','bghit ncommandi daba','bghit ncmdi','ncommandi','ncmdi','ndir commande','bghit ndir commande',\n  'order','commande'\n];\n\n// Arabic keywords (script) to map to intents\nconst arabicMenuTokens = ['\u0642\u0627\u0626\u0645\u0629','\u0627\u0644\u0642\u0627\u0626\u0645\u0629','\u0645\u0646\u064a\u0648','\u0627\u0644\u0645\u0646\u064a\u0648','menu','\u0645\u064a\u0646\u0648'];\nconst arabicCheckoutTokens = ['\u0643\u0645\u0644','\u0643\u0645\u0651\u0644','\u0627\u0643\u0645\u0644','\u0623\u0643\u0645\u0644','\u062a\u0623\u0643\u064a\u062f','\u062a\u0627\u0643\u064a\u062f','\u0623\u0643\u062f','\u0627\u0643\u0651\u062f','confirm','\u0627\u0637\u0644\u0628','\u0637\u0644\u0628'];\n\nlet normalizedLower = lower;\nlet darijaMatched = false;\n\nif (l10nEnabled && hasArabic) {\n  const t = (msgText || text || '').toString();\n  if (arabicMenuTokens.some(tok => t.includes(tok))) { normalizedLower = 'menu'; darijaMatched = true; }\n  if (arabicCheckoutTokens.some(tok => t.includes(tok))) { normalizedLower = 'checkout'; darijaMatched = true; }\n}\n\nif (darijaMenu.some(p => normalizedLower.includes(p))) { normalizedLower = 'menu'; darijaMatched = true; }\nif (darijaCheckout.some(p => normalizedLower.includes(p))) { normalizedLower = 'checkout'; darijaMatched = true; }\n\n// Sticky Arabic session rule: once user has sent Arabic script enough times (see C2), allow Darija (Latin) messages to be answered in AR for the rest of the session.\n// This is behind L10N_STICKY_AR_ENABLED.\nif (l10nEnabled && (e.l10n && e.l10n.stickyArEnabled) && !hasArabic && darijaMatched && (e.state && e.state.stickyAr)) {\n  responseLocale = 'ar';\n}\n\n\n\n// Prompt injection / jailbreak patterns\nconst inj = /(ignore (all|the) (previous|instructions)|system prompt|reveal|secret|token|api key|dump|sql|drop table|delete from|--|;)/i;\nif (inj.test(text || '')) riskFlags.push('PROMPT_INJECTION_SUSPECT');\n\n// If no reliable text (STT failed)\nif (!text || text.length < 2) {\n  const fallbackFR = \"Je n\u2019ai pas bien compris. Tu peux r\u00e9essayer en \u00e9crivant ou refaire un vocal plus clair ?\";\n  const fallbackAR = \"\u0644\u0645 \u0623\u0641\u0647\u0645 \u062c\u064a\u062f\u0627\u064b. \u062d\u0627\u0648\u0644 \u0643\u062a\u0627\u0628\u0629 \u0631\u0633\u0627\u0644\u062a\u0643 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649 \u0623\u0648 \u0623\u0631\u0633\u0644 \u0631\u0633\u0627\u0644\u0629 \u0635\u0648\u062a\u064a\u0629 \u0623\u0648\u0636\u062d.\";\n  const msg = (responseLocale === 'ar')\n    ? T('CORE_CLARIFY', {}, 'ar', fallbackAR)\n    : T('CORE_CLARIFY', {}, 'fr', fallbackFR);\n\n  return [{json: {\n    ...e,\n    intent: 'CLARIFY',\n    response: {\n      replyText: msg,\n      buttons: [\n        BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629'),\n        BTN('VOICE_RETRY','\ud83c\udfa4 Refaire un vocal','\ud83c\udfa4 \u0623\u0639\u062f \u0625\u0631\u0633\u0627\u0644 \u0631\u0633\u0627\u0644\u0629 \u0635\u0648\u062a\u064a\u0629')\n      ]\n    },\n    debug: {riskFlags, responseLocale}\n  }}];\n}\n\n// EPIC6 flags\nconst cfg = e.config || {};\nconst supportEnabled = (($env.SUPPORT_ENABLED || cfg.support_enabled || 'false').toString().toLowerCase() === 'true');\nconst faqEnabled = (($env.FAQ_ENABLED || cfg.faq_enabled || 'false').toString().toLowerCase() === 'true');\n\n\n\n// Support handoff (explicit)\nif (supportEnabled) {\n  const helpTokens = new Set(['help','aide','agent','support']);\n  const t = (lower || '').trim();\n  if (helpTokens.has(t)) {\n    return [{json:{\n      ...e,\n      intent:'HANDOFF_SUPPORT',\n      action:'SUPPORT_HANDOFF',\n      reason_code:'HELP',\n      response:{\n        replyText: T('SUPPORT_HANDOFF_ACK', {}, responseLocale, (responseLocale==='ar' ? '\ud83e\uddd1\u200d\ud83d\udcac \u0634\u0643\u0631\u0627\u064b. \u0633\u064a\u062a\u0648\u0627\u0635\u0644 \u0645\u0639\u0643 \u0623\u062d\u062f \u0627\u0644\u0645\u0648\u0638\u0641\u064a\u0646 \u0642\u0631\u064a\u0628\u0627\u064b.' : '\ud83e\uddd1\u200d\ud83d\udcac Merci. Un agent va vous contacter rapidement.')),\n        buttons:[ BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629') ]\n      },\n      debug:{riskFlags, responseLocale}\n    }}];\n  }\n}\n\n// Owner/Admin shortcuts (RBAC handled by DB table later; here we just route)\nif (lower === 'admin' || lower.includes('kpi') || text === 'ADMIN_HOME') {\n  return [{json:{\n    ...e,\n    intent:'ADMIN_HOME',\n    response:{\n      replyText:'Menu admin :',\n      buttons:[\n        {id:'ADMIN_KPI_TODAY',title:'\ud83d\udcca KPI 24h'},\n        {id:'ADMIN_TOP_ITEMS',title:'\u2b50 Top ventes'},\n        {id:'ADMIN_ALERTS',title:'\ud83d\udea8 Alertes'}\n      ]\n    },\n    debug:{riskFlags}\n  }}];\n}\n\n// LANG SWITCH\nif (l10nEnabled && langMatch) {\n  const loc = responseLocale;\n  // P2-01: LANG DARIJA response\n  if (loc === 'darija') {\n    const reply = T('CORE_LANG_SET_DARIJA', {}, 'darija', '\u2705 Daba ghadi njawbek b Darija. Kteb \"menu\" bach tchouf la carte.');\n    return [{json:{\n      ...e,\n      intent:'LANG_SET',\n      response:{\n        replyText: reply,\n        buttons:[\n          BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629','\ud83d\udccb Chouf menu')\n        ]\n      },\n      debug:{riskFlags, responseLocale},\n      l10nPersistLocale: true\n    }}];\n  }\n  const reply = (loc === 'ar')\n    ? T('CORE_LANG_SET_AR', {}, 'ar', '\u2705 \u062a\u0645 \u062a\u063a\u064a\u064a\u0631 \u0627\u0644\u0644\u063a\u0629 \u0625\u0644\u0649 \u0627\u0644\u0639\u0631\u0628\u064a\u0629. \u0627\u0643\u062a\u0628 \"menu\" \u0644\u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629.')\n    : T('CORE_LANG_SET_FR', {}, 'fr', '\u2705 Langue d\u00e9finie sur Fran\u00e7ais. Tape \"menu\" pour voir la carte.');\n\n  return [{json:{\n    ...e,\n    intent:'LANG_SET',\n    response:{\n      replyText: reply,\n      buttons:[\n        BTN('HELP_MENU','\ud83d\udccb Voir le menu','\ud83d\udccb \u0639\u0631\u0636 \u0627\u0644\u0642\u0627\u0626\u0645\u0629')\n      ]\n    },\n    debug:{riskFlags, responseLocale},\n    l10nPersistLocale: true\n  }}];\n}\n\n// HELP / MENU\nif (normalizedLower === 'menu' || text === 'HELP_MENU' || lower === 'menu') {\n  const cats = {};\n  for (const it of menuItems) {\n    const cat = it.category || 'Autres';\n    cats[cat] = cats[cat] || [];\n    cats[cat].push(it);\n  }\n  let msg = T('CORE_MENU_HEADER', {}, responseLocale, (responseLocale==='ar' ? '\ud83d\udccb \u0627\u0644\u0642\u0627\u0626\u0645\u0629 (\u0627\u0633\u062a\u062e\u062f\u0645 \u0627\u0644\u0645\u0639\u0631\u0641\u0627\u062a \u0641\u064a \u0631\u0633\u0627\u0644\u062a\u0643)\\n' : '\ud83d\udccb Menu (IDs utilisables dans ton message)\\n'));\n  for (const [cat, arr] of Object.entries(cats)) {\n    msg += `\\n*${cat}*\\n`;\n    for (const it of arr.slice(0, 12)) {\n      msg += `- ${it.item_code} : ${it.label} (${(it.price_cents/100).toFixed(2)}\u20ac)\\n`;\n    }\n  }\n  // P1-UX: Attach Menu Image\n  const menuImg = (cfg.menu_image_url || $env.MENU_IMAGE_URL || '').toString().trim();\n  const attachments = [];\n  if (menuImg) {\n    attachments.push({ type: 'image', url: menuImg, mime: 'image/jpeg' });\n  }\n  return [{json:{...e, intent:'SHOW_MENU', response:{replyText:msg, attachments, buttons:[{id:'MODE_SUR_PLACE',title:'\ud83c\udf7d\ufe0f Sur place'},{id:'MODE_A_EMPORTER',title:'\ud83d\udecd\ufe0f \u00c0 emporter'},{id:'MODE_LIVRAISON',title:'\ud83d\udef5 Livraison'}]}, debug:{riskFlags}}}];\n}\n\n// Service mode selection (buttons or text)\nconst modeFromText = (() => {\n  if (lower.includes('sur place')) return 'sur_place';\n  if (lower.includes('emporter') || lower.includes('\u00e0 emporter') || lower.includes('a emporter')) return 'a_emporter';\n  if (lower.includes('livraison') || lower.includes('livrer')) return 'livraison';\n  return null;\n})();\n\nif (text === 'MODE_SUR_PLACE') e.state.serviceMode = 'sur_place';\nif (text === 'MODE_A_EMPORTER') e.state.serviceMode = 'a_emporter';\nif (text === 'MODE_LIVRAISON') e.state.serviceMode = 'livraison';\nif (modeFromText) e.state.serviceMode = modeFromText;\n\nif (!e.state.serviceMode) {\n  return [{json:{\n    ...e,\n    intent:'ASK_MODE',\n    response:{\n      replyText:\"Tu veux *sur place*, *\u00e0 emporter* ou *livraison* ?\",\n      buttons:[\n        {id:'MODE_SUR_PLACE',title:'\ud83c\udf7d\ufe0f Sur place'},\n        {id:'MODE_A_EMPORTER',title:'\ud83d\udecd\ufe0f \u00c0 emporter'},\n        {id:'MODE_LIVRAISON',title:'\ud83d\udef5 Livraison'}\n      ]\n    },\n    debug:{riskFlags}\n  }}];\n}\n\n// Parse item codes and qty: example \"P01 x2 +S01\"\nconst upper = text.toUpperCase();\nconst codeMatches = upper.match(/[A-Z]{1,3}\\d{2,4}/g) || [];\nconst uniqueCodes = [...new Set(codeMatches)];\n\n// Checkout intents\nconst isCheckout = (lower.includes('valider') || lower.includes('checkout') || lower.includes('commander') || text === 'CHECKOUT');\nconst isConfirmYes = (text === 'CONFIRM_YES' || lower === 'oui');\nconst isConfirmNo  = (text === 'CONFIRM_NO'  || lower === 'non');\n\n// Confirm stage handling\nif (e.state.stage === 'CONFIRMING') {\n  if (isConfirmYes) {\n    // Delivery: collect structured address before checkout\n    const deliveryEnabled = (($env.DELIVERY_ENABLED || cfg.delivery_enabled || 'false').toString() === 'true');\n    if (e.state.serviceMode === 'livraison') {\n      if (!deliveryEnabled) {\n        e.state.serviceMode = 'a_emporter';\n        e.state.stage = 'COLLECTING';\n        return [{json:{...e, intent:'DELIVERY_DISABLED', response:{replyText:\"\ud83d\udeab Livraison indisponible pour le moment. Je passe en \u00c0 emporter.\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'},{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags}}}];\n      }\n      const d = e.state.delivery || {};\n      const missing = [];\n      if (!d.wilaya) missing.push('wilaya');\n      if (!d.commune) missing.push('commune');\n      if (!d.details) missing.push('adresse');\n      if (!d.phone) missing.push('tel');\n      // persist last known total for quote\n      e.state.lastTotalCents = Number((e.debug && e.debug.totalCents) || e.state.lastTotalCents || 0);\n      if (missing.length) {\n        e.state.stage = 'DELIVERY_ADDRESS';\n        const t = \"\ud83d\udccd Pour la livraison, j\u2019ai besoin de : Wilaya, Commune, Adresse (rep\u00e8re), T\u00e9l\u00e9phone.\\n\\nFormat :\\nWilaya: <...>\nCommune: <...>\nAdresse: <...>\nTel: <...>\";\n        return [{json:{...e, intent:'DELIVERY_ADDRESS', action:'ADDRESS_AMBIGUOUS', missing_fields: missing, response:{replyText:t}, debug:{riskFlags}}}];\n      }\n      // Address already present\n      if (d.quote_shown) {\n        return [{json:{...e, intent:'CHECKOUT', action:'CHECKOUT', debug:{riskFlags}}}];\n      }\n      // compute quote/slots before final checkout\n      return [{json:{...e, intent:'DELIVERY_QUOTE', action:'DELIVERY_QUOTE', debug:{riskFlags}}}];\n    }\n    return [{json:{...e, intent:'CHECKOUT', action:'CHECKOUT', debug:{riskFlags}}}];\n  }\n  if (isConfirmNo) {\n    e.state.stage = 'COLLECTING';\n    return [{json:{...e, intent:'BACK_TO_CART', response:{replyText:\"Ok, on modifie le panier. Envoie les IDs (ex: P01 x2) ou tape MENU.\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'},{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags}}}];\n  }\n}\n\n\n// Delivery address clarification stage\nif (e.state.stage === 'DELIVERY_ADDRESS') {\n  const d = (e.state.delivery || {});\n  const lines = text.split(/\\n|,|;/).map(s => s.trim()).filter(Boolean);\n  for (const line of lines) {\n    const l = line.toLowerCase();\n    const val = line.split(':').slice(1).join(':').trim();\n    if (l.startsWith('wilaya') && val) d.wilaya = val;\n    else if (l.startsWith('commune') && val) d.commune = val;\n    else if ((l.startsWith('adresse') || l.startsWith('address') || l.startsWith('rue')) && val) d.details = val;\n    else if ((l.startsWith('tel') || l.startsWith('telephone') || l.startsWith('phone')) && val) d.phone = val;\n  }\n  // Fallback: if user sent 4 parts without labels\n  if (lines.length >= 4 && (!d.wilaya || !d.commune || !d.details || !d.phone)) {\n    const parts = lines.map(x => x.replace(/^[-\u2022]/,'').trim());\n    d.wilaya = d.wilaya || parts[0];\n    d.commune = d.commune || parts[1];\n    d.details = d.details || parts[2];\n    d.phone = d.phone || parts[3];\n  }\n  e.state.delivery = d;\n  const missing = [];\n  if (!d.wilaya) missing.push('wilaya');\n  if (!d.commune) missing.push('commune');\n  if (!d.details) missing.push('adresse');\n  if (!d.phone) missing.push('tel');\n  if (missing.length) {\n    const attempts = Number(e.state.delivery_attempts || 0) + 1;\n    e.state.delivery_attempts = attempts;\n    const maxA = Number(($env.DELIVERY_ADDRESS_MAX_ATTEMPTS || cfg.delivery_address_max_attempts || '3'));\n    if (($env.DELIVERY_ADDRESS_CLARIFY || cfg.delivery_address_clarify || 'true').toString() === 'true' && attempts > maxA) {\n      e.state.stage = 'HANDOFF';\n      return [{json:{...e, intent:'DELIVERY_HANDOFF', action:'ADDRESS_AMBIGUOUS', missing_fields: missing, response:{replyText:\"Je n'arrive pas \u00e0 clarifier l\u2019adresse. Je te mets en relation avec un humain.\"}, debug:{riskFlags}}}];\n    }\n    return [{json:{...e, intent:'DELIVERY_ADDRESS', action:'ADDRESS_AMBIGUOUS', missing_fields: missing, response:{replyText:\"Il manque : \"+missing.join(', ')+\". Reprends le format : Wilaya/Commune/Adresse/Tel.\"}, debug:{riskFlags}}}];\n  }\n  // Address OK \u2192 compute quote/slots\n  e.state.stage = 'CONFIRMING';\n  return [{json:{...e, intent:'DELIVERY_QUOTE', action:'DELIVERY_QUOTE', debug:{riskFlags}}}];\n}\n\n// Delivery slot pick stage\nif (e.state.stage === 'DELIVERY_SLOT_PICK') {\n  if (text.startsWith('SLOT_')) {\n    const slotId = text.slice('SLOT_'.length).trim();\n    e.state.delivery = e.state.delivery || {};\n    e.state.delivery.slot_id = slotId;\n    e.state.stage = 'CONFIRMING';\n    return [{json:{...e, intent:'SLOT_OK', response:{replyText:\"\u2705 Cr\u00e9neau enregistr\u00e9. Clique Valider pour confirmer la commande.\", buttons:[{id:'CHECKOUT',title:'\u2705 Valider'},{id:'CONFIRM_NO',title:'\u270f\ufe0f Modifier'}]}, debug:{riskFlags}}}];\n  }\n}\n// If user asks to checkout but cart empty\nif (isCheckout && (!e.cart.items || e.cart.items.length === 0)) {\n  return [{json:{...e, intent:'EMPTY_CART', response:{replyText:\"Ton panier est vide. Tape MENU pour choisir des plats (ex: P01 x2).\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags}}}];\n}\n\n// If item codes present -> add/update cart\nif (uniqueCodes.length) {\n  const newItems = [...(e.cart.items || [])];\n\n  for (const code of uniqueCodes) {\n    if (!itemMap[code]) {\n      return [{json:{...e, intent:'UNKNOWN_ITEM', response:{replyText:`Je ne trouve pas l\u2019ID *${code}*. Tape MENU ou renvoie l\u2019ID exact.`, buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags}}}];\n    }\n\n    // qty: try CODE xN or CODE N\n    let qty = 1;\n    const r1 = new RegExp(code + '\\\\s*x\\\\s*(\\\\d{1,2})','i');\n    const r2 = new RegExp(code + '\\\\s+(\\\\d{1,2})','i');\n    const m1 = upper.match(r1);\n    const m2 = upper.match(r2);\n    if (m1 && m1[1]) qty = Math.max(1, Math.min(20, parseInt(m1[1],10)));\n    else if (m2 && m2[1]) qty = Math.max(1, Math.min(20, parseInt(m2[1],10)));\n\n    // options: +OPT or -OPT (we accept OPT alone too)\n    const optMatches = upper.match(/[\\+\\-]?[A-Z]{1,3}\\d{2,4}/g) || [];\n    const options = [];\n    for (const raw of optMatches) {\n      const oc = raw.replace('+','').replace('-','');\n      if (optionMap[oc] && optionMap[oc].item_code === code) options.push(oc);\n    }\n\n    const existing = newItems.find(x => x.item === code);\n    if (existing) {\n      existing.qty = Math.max(1, Math.min(20, Number(existing.qty || 1) + qty));\n      const set = new Set([...(existing.options||[]), ...options]);\n      existing.options = [...set];\n    } else {\n      newItems.push({item: code, qty, options});\n    }\n  }\n\n  e.cart.items = newItems;\n  e.state.stage = 'COLLECTING';\n\n  // Build short cart recap\n  let recap = '\ud83e\uddfa Panier :\\n';\n  let total = 0;\n  for (const line of newItems) {\n    const it = itemMap[line.item];\n    const base = Number(it.price_cents||0);\n    let opt = 0;\n    const opts = Array.isArray(line.options)?line.options:[];\n    for (const oc of opts) opt += Number(optionMap[oc]?.price_delta_cents || 0);\n    const lineTotal = (base + opt) * Number(line.qty||1);\n    total += lineTotal;\n    recap += `- ${line.item} ${it.label} x${line.qty} = ${(lineTotal/100).toFixed(2)}\u20ac\\n`;\n  }\n  recap += `\\nTotal estim\u00e9 : ${(total/100).toFixed(2)}\u20ac\\n`;\n  recap += 'Tape *VALIDER* ou clique \u2705';\n\n  return [{json:{...e, intent:'CART_UPDATED', response:{replyText:recap, buttons:[{id:'HELP_MENU',title:'\u2795 Ajouter'},{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags, totalCents: total}}}];\n}\n\n// If checkout requested -> confirm recap\nif (isCheckout) {\n  e.state.stage = 'CONFIRMING';\n  let recap = `\u2705 R\u00e9cap commande (${e.state.serviceMode.replace('_',' ')}) :\\n`;\n  let total = 0;\n  for (const line of (e.cart.items||[])) {\n    const it = itemMap[line.item];\n    const base = Number(it.price_cents||0);\n    let opt = 0;\n    const opts = Array.isArray(line.options)?line.options:[];\n    for (const oc of opts) opt += Number(optionMap[oc]?.price_delta_cents || 0);\n    const lineTotal = (base + opt) * Number(line.qty||1);\n    total += lineTotal;\n    recap += `- ${line.item} ${it.label} x${line.qty} = ${(lineTotal/100).toFixed(2)}\u20ac\\n`;\n  }\n  recap += `\\nTotal : ${(total/100).toFixed(2)}\u20ac\\n`;\n  recap += (responseLocale === 'ar') ? '\u0647\u0644 \u062a\u0624\u0643\u062f \u0627\u0644\u0637\u0644\u0628\u061f' : 'Confirmer la commande ?';\n  return [{json:{...e, intent:'CONFIRM', response:{replyText:recap, buttons:[{id:'CONFIRM_YES',title:'\u2705 Oui'},{id:'CONFIRM_NO',title:'\u270f\ufe0f Modifier'}]}, debug:{riskFlags, totalCents: total}}}];\n}\n\n\n// FAQ (RAG light): detect question-like messages and route to DB full-text search\nif (faqEnabled) {\n  const looksLikeItem = /\\b[A-Z]{1,3}\\d{2,4}\\b/.test((upper || ''));\n  const faqHints = /(horaire|horaires|ouvert|ouvrir|ferme|fermeture|paiement|payment|carte|cash|esp[e\u00e8]ces|livraison|delivery|adresse|where|when|open|close)/i;\n  const isQuestion = /\\?/.test(text) || faqHints.test(text || '');\n  const isAdminWord = (lower === 'admin');\n  const isMenuWord = (normalizedLower === 'menu' || lower === 'menu');\n  if (isQuestion && !looksLikeItem && !isCheckout && !isAdminWord && !isMenuWord) {\n    return [{json:{\n      ...e,\n      intent:'FAQ_QUERY',\n      action:'FAQ_QUERY',\n      faq_question: (text || '').toString(),\n      debug:{riskFlags, responseLocale}\n    }}];\n  }\n}\n\n// Optional LLM fallback (only if enabled and no risk flags)\nconst llmUrl = (cfg.llm_api_url || $env.LLM_API_URL || '').toString();\nconst llmModel = (cfg.llm_model || $env.LLM_MODEL || 'llama3').toString();\nconst useLLM = (!!llmUrl) && (riskFlags.length === 0);\n\nif (useLLM) {\n  const menuCompact = menuItems.slice(0, 120).map(it => ({id:it.item_code, label:it.label, price_cents:it.price_cents, category:it.category}));\n  const optsCompact = (e.menu?.options || []).slice(0, 200).map(op => ({id:op.option_code, item:op.item_code, label:op.label, kind:op.kind, delta:op.price_delta_cents}));\n\n  const prompt = [\n    (cfg.system_prompt_strategy || 'Tu es un assistant commande restaurant. R\u00e9ponds UNIQUEMENT en JSON valide (pas de texte).'),\n    'Objectif: comprendre la demande du client et proposer une action.',\n    'Sch\u00e9ma: {\"action\":\"add|checkout|menu|clarify\",\"lines\":[{\"item\":\"ID\",\"qty\":1,\"options\":[\"OPT\"]}],\"reply\":\"...\"}',\n    'Si tu ne peux pas mapper pr\u00e9cis\u00e9ment \u00e0 des IDs, action=\"clarify\".',\n    'MenuItems=' + JSON.stringify(menuCompact),\n    'Options=' + JSON.stringify(optsCompact),\n    'UserMessage=' + JSON.stringify(text)\n  ].join('\\n');\n\n  try {\n    const llmRes = await $httpRequest({\n      method:'POST',\n      url: llmUrl,\n      body: { model: llmModel, prompt, stream: false },\n      json: true,\n      timeout: 120000\n    });\n\n    const raw = (llmRes.response || llmRes.text || llmRes.output || '').toString();\n    const m = raw.match(/\\{[\\s\\S]*\\}/);\n    if (m) {\n      const parsed = JSON.parse(m[0]);\n      if (parsed.action === 'menu') {\n        return [{json:{...e, intent:'SHOW_MENU', response:{replyText:'Tape MENU pour afficher le menu.', buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags, llm:true}}}];\n      }\n      if (parsed.action === 'checkout') {\n        return [{json:{...e, intent:'CONFIRM', response:{replyText:'Clique \u2705 Valider pour confirmer.', buttons:[{id:'CHECKOUT',title:'\u2705 Valider'}]}, debug:{riskFlags, llm:true}}}];\n      }\n      if (parsed.action === 'add' && Array.isArray(parsed.lines) && parsed.lines.length) {\n        // Convert to same pipeline by simulating codes\n        const codes = parsed.lines.map(l => l.item).filter(Boolean);\n        if (!codes.length) throw new Error('LLM lines missing item');\n        // attach helper field for later handling by user: ask to resend codes (safe)\n        return [{json:{...e, intent:'CLARIFY', response:{replyText:'Pour \u00eatre s\u00fbr, peux-tu renvoyer les IDs comme ceci : ' + codes.join(' ') + ' (ex: P01 x2) ?', buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags, llm:true}}}];\n      }\n    }\n  } catch (err) {\n    // ignore and fallback\n  }\n}\n\n// Default clarification\ne.state = e.state || {}; e.state.lastResponseLocale = responseLocale;\nreturn [{json:{...e, intent:'CLARIFY', response:{replyText:\"Je n\u2019ai pas compris. Envoie l\u2019ID du plat (ex: P01 x2) ou tape MENU.\", buttons:[{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]}, debug:{riskFlags}}}];"
      },
      "id": "1b9e5bc6-421e-40cd-a92c-8f22fe73ba46",
      "name": "C6 - Router (safe, LLM optional)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -820,
        0
      ]
    },
    {
      "parameters": {
        "dataType": "string",
        "value1": "={{$json.intent}}",
        "rules": {
          "rules": [
            {
              "id": "faq_rule",
              "value2": "FAQ_QUERY",
              "output": 0
            },
            {
              "id": "support_rule",
              "value2": "HANDOFF_SUPPORT",
              "output": 0
            },
            {
              "id": "support_del_rule",
              "value2": "DELIVERY_HANDOFF",
              "output": 0
            },
            {
              "id": "cart1",
              "value2": "CART_UPDATED",
              "output": 1
            },
            {
              "id": "cart2",
              "value2": "CONFIRM",
              "output": 1
            },
            {
              "id": "cart3",
              "value2": "CHECKOUT",
              "output": 1
            },
            {
              "id": "cart4",
              "value2": "DELIVERY_ADDRESS",
              "output": 1
            },
            {
              "id": "cart5",
              "value2": "DELIVERY_QUOTE",
              "output": 1
            },
            {
              "id": "cart6",
              "value2": "SLOT_OK",
              "output": 1
            }
          ]
        },
        "fallbackOutput": 2
      },
      "id": "switch-intent",
      "name": "Switch Intent",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 1,
      "position": [
        100,
        0
      ]
    },
    {
      "parameters": {
        "workflowId": "W4.2_CART_MANAGER",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "tenantId",
              "value": "={{ $json.tenantId }}"
            },
            {
              "name": "restaurantId",
              "value": "={{ $json.restaurantId }}"
            },
            {
              "name": "channel",
              "value": "={{ $json.channel }}"
            },
            {
              "name": "userId",
              "value": "={{ $json.userId }}"
            }
          ]
        },
        "options": {
          "waitTillFinished": true
        }
      },
      "id": "exec-cart",
      "name": "Call CART_MANAGER",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        400,
        100
      ]
    },
    {
      "parameters": {
        "workflowId": "W4.3_FAQ_AGENT",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "tenantId",
              "value": "={{ $json.tenantId }}"
            },
            {
              "name": "restaurantId",
              "value": "={{ $json.restaurantId }}"
            },
            {
              "name": "channel",
              "value": "={{ $json.channel }}"
            },
            {
              "name": "userId",
              "value": "={{ $json.userId }}"
            }
          ]
        },
        "options": {
          "waitTillFinished": true
        }
      },
      "id": "exec-faq",
      "name": "Call FAQ_AGENT",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        400,
        -100
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH up_state AS (\n  INSERT INTO public.conversation_state (conversation_key, tenant_id, restaurant_id, channel, user_id, state_json)\n  VALUES ($1,$2,$3,$4,$5,$6::jsonb)\n  ON CONFLICT (conversation_key) DO UPDATE\n    SET state_json=EXCLUDED.state_json, updated_at=now()\n  RETURNING 1\n),\nup_cart AS (\n  INSERT INTO public.carts (conversation_key, cart_json)\n  VALUES ($1, $7::jsonb)\n  ON CONFLICT (conversation_key) DO UPDATE\n    SET cart_json=EXCLUDED.cart_json, updated_at=now()\n  RETURNING 1\n),\nup_pref AS (\n  INSERT INTO public.customer_preferences(tenant_id, phone, locale)\n  SELECT $2, $5, $9\n  WHERE COALESCE($8::boolean,false) = true\n    AND $9 IS NOT NULL\n    AND length(trim($9)) > 0\n  ON CONFLICT (tenant_id, phone) DO UPDATE\n    SET locale=EXCLUDED.locale, updated_at=now()\n  RETURNING 1\n)\nSELECT 1 AS ok;\n",
        "additionalFields": {
          "queryParams": "={{[$json.conversationKey,$json.tenantId,$json.restaurantId,$json.channel,$json.userId, JSON.stringify($json.state), JSON.stringify($json.cart), !!$json.l10nPersistLocale, ($json.state && $json.state.localePref) ? $json.state.localePref : null]}}"
        }
      },
      "id": "c627f006-396c-4b8c-a695-6bfd1389e349",
      "name": "C7 - Save State+Cart (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        -600,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nconst resp = e.response || { replyText: 'OK', buttons: [] };\n\nconst out = {\n  channel: e.channel,\n  tenantId: e.tenantId || '',\n  restaurantId: e.restaurantId,\n  conversationKey: e.conversationKey || '',\n  userId: e.userId,\n  orderId: e.debug?.orderId || null,\n  replyText: resp.replyText || '',\n  buttons: Array.isArray(resp.buttons) ? resp.buttons : [],\n  attachments: Array.isArray(resp.attachments) ? resp.attachments : [],\n  actions: e.actions || { sendToKitchen:false },\n  debug: {\n    traceId: e.metadata?.msgId || '',\n    intent: e.intent || '',\n    riskFlags: e.debug?.riskFlags || []\n  }\n};\nreturn [{json: out}];\n"
      },
      "id": "561fff75-d491-4d33-8295-4d8e5b56425c",
      "name": "C11 - Finalize Response (default)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        300,
        0
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const crypto = require('crypto');\nconst out = $json;\n\nconst ch = (out.channel || '').toString();\nconst orderId = out.orderId ? out.orderId.toString() : '';\nconst traceId = (out.debug?.traceId || '').toString();\n\nconst dedupeKey = orderId\n  ? `order:${orderId}:${ch}:reply`\n  : `msg:${ch}:${traceId || crypto.randomUUID()}`;\n\nconst payload = {\n  channel: ch,\n  to: out.userId,\n  restaurantId: out.restaurantId,\n  text: out.replyText,\n  buttons: out.buttons || [],\n  attachments: out.attachments || [],\n  meta: { intent: out.debug?.intent || '' }\n};\n\nreturn [{\n  json: {\n    ...out,\n    outbox_dedupe_key: dedupeKey,\n    outbox_template: 'reply',\n    outbox_payload: payload\n  }\n}];\n"
      },
      "id": "42e4a69d-d415-45d9-81e3-23c68d422d9b",
      "name": "C12 - Enqueue Outbox (P0)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        520,
        0
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO outbound_messages(dedupe_key, tenant_id, restaurant_id, conversation_key, channel, user_id, order_id, template, payload_json, status, next_retry_at, updated_at)\nVALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'PENDING', now(), now())\nON CONFLICT (dedupe_key) DO UPDATE\n  SET payload_json=EXCLUDED.payload_json,\n      status = CASE WHEN outbound_messages.status='SENT' THEN outbound_messages.status ELSE 'PENDING' END,\n      updated_at=now()\nRETURNING outbound_id, status;",
        "additionalFields": {
          "queryParams": "={{[$json.outbox_dedupe_key, $json.tenantId, $json.restaurantId, $json.conversationKey, $json.channel, $json.userId, $json.orderId, $json.outbox_template, $json.outbox_payload]}}"
        }
      },
      "id": "4c47eb41-69d2-48e4-a924-0f95623bbd65",
      "name": "C12b - Outbox Enqueue (DB)",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2,
      "position": [
        1260,
        420
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "const e = $json;\nreturn [{\n  json: {\n    ...e,\n    _send: {\n      ok: true,\n      queued: true,\n      outboundId: e.outbound_id || e.outboundid || e.outbound_id,\n      status: e.status || 'PENDING'\n    }\n  }\n}];"
      },
      "id": "91548ca5-94ad-4c09-8dc5-a2c6208636cd",
      "name": "C12c - Outbox Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1460,
        420
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{$json.l10nPersistLocale === true}}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "C14_IF_SYNC_DIALECT",
      "name": "C14 - Should Sync Dialect?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1660,
        420
      ]
    },
    {
      "parameters": {
        "workflowId": "W56 - Strapi Dialect Sync",
        "mode": "fireAndForget",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "phone",
              "value": "={{$json.userId}}"
            },
            {
              "name": "locale",
              "value": "={{$json.state?.localePref || $json.state?.locale}}"
            }
          ]
        },
        "options": {
          "waitTillFinished": false
        }
      },
      "id": "C15_EXEC_SYNC_DIALECT",
      "name": "C15 - Execute Dialect Sync",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        1880,
        400
      ]
    },
    {
      "parameters": {
        "operation": "releaseLock",
        "resource": "={{$json.conversationKey}}"
      },
      "id": "release-lock",
      "name": "Release Lock",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        2080,
        420
      ]
    },
    {
      "parameters": {
        "workflowId": "W_TRACKING_FUNNEL",
        "mode": "fireAndForget",
        "workflowInputs": {
          "mappingMode": "defineBelow",
          "value": {},
          "values": [
            {
              "name": "tenantId",
              "value": "={{$json.tenantId}}"
            },
            {
              "name": "restaurantId",
              "value": "={{$json.restaurantId}}"
            },
            {
              "name": "userId",
              "value": "={{$json.userId}}"
            },
            {
              "name": "channel",
              "value": "={{$json.channel}}"
            },
            {
              "name": "eventType",
              "value": "={{($json.intent === 'SHOW_MENU') ? 'menu_viewed' : 'intent_detected'}}"
            },
            {
              "name": "payload",
              "value": "={{JSON.stringify({ intent: $json.intent, text: $json.message.text, referral: $json.inbound_envelope?.meta?.referral })}}"
            }
          ]
        }
      },
      "id": "track-event",
      "name": "Track Event",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1,
      "position": [
        -200,
        -200
      ]
    },
    {
      "parameters": {
        "operation": "set",
        "key": "=lock:session:{{ $json.conversationKey }}",
        "value": "locked",
        "expire": 15
      },
      "id": "acquire-lock",
      "name": "\ud83d\udd11 Acquire Session Lock",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        -2150,
        200
      ],
      "credentials": {
        "redis": {
          "name": "<your credential>"
        }
      },
      "continueOnFail": true
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $json.ok ?? true }}",
              "operation": "isTrue"
            }
          ]
        }
      },
      "id": "lock-success",
      "name": "Lock Success?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        -2000,
        200
      ]
    },
    {
      "parameters": {
        "language": "javascript",
        "jsCode": "return [{\n  json: {\n    ...$json,\n    intent: 'SYSTEM_BUSY',\n    response: {\n      replyText: \"\u23f3 Une op\u00e9ration est d\u00e9j\u00e0 en cours. Attends 2 secondes et r\u00e9essaie.\",\n      buttons: [{id:'HELP_MENU',title:'\ud83d\udccb Menu'}]\n    }\n  }\n}];"
      },
      "id": "lock-failed",
      "name": "Lock Failed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2000,
        400
      ]
    }
  ],
  "connections": {
    "IN - From Adapters": {
      "main": [
        [
          {
            "node": "C0 - Validate Event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C0 - Validate Event": {
      "main": [
        [
          {
            "node": "B0 - Verify Tenant Context Seal",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "B0 - Verify Tenant Context Seal": {
      "main": [
        [
          {
            "node": "\ud83d\udd11 Acquire Session Lock",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\ud83d\udd11 Acquire Session Lock": {
      "main": [
        [
          {
            "node": "Lock Success?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Success?": {
      "main": [
        [
          {
            "node": "Ensure Customer Profile",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Lock Failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lock Failed": {
      "main": [
        [
          {
            "node": "C11 - Finalize Response (default)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ensure Customer Profile": {
      "main": [
        [
          {
            "node": "C1 - Load State+Cart (DB)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C1 - Load State+Cart (DB)": {
      "main": [
        [
          {
            "node": "C2 - Merge State Defaults",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C2 - Merge State Defaults": {
      "main": [
        [
          {
            "node": "C3 - Voice STT (optional)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3 - Voice STT (optional)": {
      "main": [
        [
          {
            "node": "C3x - AudioUrl Blocked?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3x - AudioUrl Blocked?": {
      "main": [
        [
          {
            "node": "C3y - Log SSRF Block (DB)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "C3b - Menu Cache Get",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3y - Log SSRF Block (DB)": {
      "main": [
        [
          {
            "node": "C3b - Menu Cache Get",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3b - Menu Cache Get": {
      "main": [
        [
          {
            "node": "C3c - Menu Cache Hit?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C3c - Menu Cache Hit?": {
      "main": [
        [
          {
            "node": "C5 - Build Menu Maps",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "C4 - Load Menu Index (DB)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C4 - Load Menu Index (DB)": {
      "main": [
        [
          {
            "node": "C4b - Menu Cache Set",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C4b - Menu Cache Set": {
      "main": [
        [
          {
            "node": "C5 - Build Menu Maps",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C5 - Build Menu Maps": {
      "main": [
        [
          {
            "node": "C6 - Router (safe, LLM optional)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C6 - Router (safe, LLM optional)": {
      "main": [
        [
          {
            "node": "Track Event",
            "type": "main",
            "index": 0
          },
          {
            "node": "Switch Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Switch Intent": {
      "main": [
        [
          {
            "node": "Call FAQ_AGENT",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Call CART_MANAGER",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "C7 - Save State+Cart (DB)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C7 - Save State+Cart (DB)": {
      "main": [
        [
          {
            "node": "C11 - Finalize Response (default)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C11 - Finalize Response (default)": {
      "main": [
        [
          {
            "node": "C12 - Enqueue Outbox (P0)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C12 - Enqueue Outbox (P0)": {
      "main": [
        [
          {
            "node": "C12b - Outbox Enqueue (DB)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C12b - Outbox Enqueue (DB)": {
      "main": [
        [
          {
            "node": "C12c - Outbox Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C14 - Should Sync Dialect?": {
      "main": [
        [
          {
            "node": "C15 - Execute Dialect Sync",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "release-lock",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "C15 - Execute Dialect Sync": {
      "main": [
        [
          {
            "node": "release-lock",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "release-lock": {
      "main": [
        []
      ]
    }
  }
}