{
  "createdAt": "2025-08-14T08:17:18.929Z",
  "updatedAt": "2025-10-12T08:47:43.000Z",
  "id": "tjk89cBgzinji59E",
  "name": "Bubu WhatsApp Companion",
  "active": true,
  "isArchived": false,
  "nodes": [
    {
      "parameters": {
        "path": "whatsapp",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "fc6abf81-7b16-4ed8-99bf-21500037cdd4",
      "name": "Webhook (GET)",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        -2336,
        160
      ]
    },
    {
      "parameters": {
        "language": "JavaScript",
        "jsCode": "const query = $json.query || {};\nconst mode = query['hub.mode'];\nconst token = query['hub.verify_token'];\nconst challenge = query['hub.challenge'];\n\nif (mode === 'subscribe' && token === 'bubu-verify-2024') {\n  return [{json: {status: 200, body: challenge}}];\n}\nreturn [{json: {status: 403, body: 'Forbidden'}}];"
      },
      "id": "b5e7bff8-ce4e-4e03-8cc3-b8ca157e4b66",
      "name": "Verify (GET)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -2112,
        160
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{$json.body}}",
        "options": {
          "responseCode": "={{$json.status}}"
        }
      },
      "id": "9231fca4-9b9b-45a7-8f71-07324bc31f7b",
      "name": "Respond (GET)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        -1888,
        160
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "whatsapp",
        "options": {}
      },
      "id": "616ad17b-b640-4477-bf3f-6700e5fb44a3",
      "name": "Webhook (POST)",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        -2320,
        496
      ],
      "notes": "Purpose: Entry point for new messages (WA and TG).\nUse: Emits raw provider payload to \u201cParse payload\u201d.\nMeta: Immediate 200 only if provider requires an ack."
    },
    {
      "parameters": {
        "language": "JavaScript",
        "jsCode": "const payload = $json.body || $json;\nconst entry = payload?.entry?.[0]?.changes?.[0]?.value;\nconst message = entry?.messages?.[0];\n\nif (!message) {\n  return [{json: {skip: true, reason: entry?.statuses?.[0]?.status || 'no_message'}}];\n}\n\nreturn [{\n  json: {\n    phone_number: message.from,\n    whatsapp_id: message.id,\n    type: message.type,\n    content: message.text?.body || '',\n    is_voice: message.type === 'audio',\n    media_id: message.audio?.id || null,\n    timestamp: message.timestamp\n  }\n}];"
      },
      "id": "398454b7-83e2-4bf1-9258-53ca15356574",
      "name": "Parse payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -2112,
        496
      ],
      "notes": "Purpose: Normalize provider payload to a single internal shape.\nUse: WhatsApp: Output { phone_number, whatsapp_id, type, content, is_voice, media_id, timestamp }.\nMeta: Voice path enabled for both (media id passed forward for download). TG group visibility depends on BotFather Privacy Mode (DMs unaffected).\n"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 1
          },
          "conditions": [
            {
              "id": "198ea951-649c-4838-9846-9a7968691717",
              "leftValue": "={{ $json.skip }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {
          "looseTypeValidation": true
        }
      },
      "id": "3ea6f7bf-ae22-448e-a7ac-268f8e383fb8",
      "name": "Skip?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        -1440,
        512
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/record_inbound",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"p_msg\": {\n    \"provider\": \"whatsapp\",\n    \"phone_number\": \"{{ $node['Parse payload'].json.phone_number }}\",\n    \"whatsapp_id\": \"{{ $node['Parse payload'].json.whatsapp_id }}\",\n    \"content\": \"{{ $node['Parse payload'].json.content }}\",\n    \"is_voice\": {{ !!$node['Parse payload'].json.is_voice }},\n    \"timestamp\": {{ Number($node['Parse payload'].json.timestamp) }}\n  }\n}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "id": "56f4f512-bf8f-4a88-a707-062a307a8e34",
      "name": "Supabase: record_inbound",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 3,
      "position": [
        -944,
        528
      ],
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Idempotent insert of inbound; auto-create user if new.\nUse: Body { p_msg: normalized message + provider }.\nMeta: SECURITY DEFINER; idempotent on provider message id. Identity key stored in users.phone_number:\n\u2022 WA: digits (no plus).\nTimestamp accepts ISO or epoch seconds. media_url intentionally null."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/get_context",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={ \"p_phone\": \"{{ $node['Parse payload'].json.phone_number }}\" }",
        "options": {}
      },
      "id": "cd81574c-dd01-4412-9228-f6d5afe7b224",
      "name": "Supabase: get_context",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 3,
      "position": [
        -768,
        528
      ],
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Deterministic memory for prompts.\nUse: { p_phone } \u2192 { user, children[], recent_messages[], recent_interventions[], failed_by_category[], history_summary }.\nMeta: Shared across channels."
    },
    {
      "parameters": {
        "language": "JavaScript",
        "jsCode": "// --- ctx from Supabase:get_context ---\nconst ctx = $json;\n\n// Normalize legacy key\nif (ctx.failed_slugs && !ctx.failed_by_category) {\n  ctx.failed_by_category = ctx.failed_slugs;\n  delete ctx.failed_slugs;\n}\n\n// --- parse payload (webhook) ---\nconst parse = $node['Parse payload'].json; // has phone_number, whatsapp_id, is_voice, content (may be empty for voice)\n\n// --- choose message text (DB-first, match on whatsapp_id) ---\nconst msgs = Array.isArray(ctx.recent_messages) ? ctx.recent_messages : [];\n\n// try exact match by WA ID (handles m.whatsapp_id or m.wamid)\nconst inboundExact = [...msgs].reverse().find(m =>\n  (m.direction === 'in' || m.direction === 'inbound' || m.is_inbound === true) &&\n  (\n    (m.whatsapp_id && parse.whatsapp_id && m.whatsapp_id === parse.whatsapp_id) ||\n    (m.wamid && parse.whatsapp_id && m.wamid === parse.whatsapp_id)\n  )\n);\n\n// fallback: last inbound message in the window\nconst inboundLast = inboundExact || [...msgs].reverse().find(m =>\n  (m.direction === 'in' || m.direction === 'inbound' || m.is_inbound === true)\n) || null;\n\n// prefer DB text (already the transcript for voice), else webhook content\nconst dbText = inboundLast?.content ?? inboundLast?.text ?? null;\nconst text = String(dbText ?? parse.content ?? '').trim();\n\n// --- children / language ---\nconst children = Array.isArray(ctx.children) ? ctx.children : [];\nconst lang = ctx.user?.preferred_language || 'en';\n\n// --- pick active child ---\nlet active = null;\nif (children.length === 1) active = children[0];\nelse if (children.length > 1) {\n  const lower = text.toLowerCase();\n  const hit = children.find(c => lower.includes(String(c.name || '').toLowerCase()));\n  if (hit) active = hit;\n}\nif (!active && ctx.user?.last_active_child_id) {\n  active = children.find(c => c.id === ctx.user.last_active_child_id) || null;\n}\nconst active_child_hint = active ? { id: active.id, name: active.name } : null;\n\n// --- build orchestrator_input ---\nconst orchestrator_input = {\n  lang,\n  children: children.map(c => ({\n    id: c.id,\n    name: String(c.name || '').trim(),\n    date_of_birth: c.date_of_birth || null,\n  })),\n  last_active_child_id: ctx.user?.last_active_child_id || null,\n  active_child_hint,\n  recent_messages: ctx.recent_messages || [],\n  recent_interventions: ctx.recent_interventions || [],\n  history_summary: ctx.user?.history_summary || null,\n  failed_by_category: Array.isArray(ctx.failed_by_category) ? ctx.failed_by_category : [],\n  message: text,\n  is_voice: !!parse.is_voice,\n};\n\n// --- heuristics ---\nconst msgLen = text.length;\nconst recentTurns = msgs.length;\nconst conf = inboundLast?.transcription_confidence; // often undefined with your current model\norchestrator_input.offer_voice_note = (msgLen >= 320) || (recentTurns >= 12);\norchestrator_input.voice_low_confidence = (typeof conf === 'number') && (conf < 0.7);\n\nreturn [{\n  json: {\n    phone_number: parse.phone_number,\n    orchestrator_input,\n  }\n}];"
      },
      "id": "f8594a25-3c1a-4133-a2d4-d82c772a006e",
      "name": "Resolve child + build",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -576,
        528
      ],
      "notes": "Purpose: Choose likely child; build clean LLM input.\nUse: DB-first selection: match exact inbound by provider message id (WA: whatsapp_id; TG: telegram_message_id stored in the same compat slot), else latest inbound, else webhook text.\nOutput orchestrator_input = { lang, children[], last_active_child_id, active_child_hint?, failed_by_category, history_summary, recent_messages, recent_interventions, message, is_voice, offer_voice_note, voice_low_confidence }.\nMeta: Heuristic offer_voice_note = (message length \u2265 320) OR (recent_messages length \u2265 12). voice_low_confidence wired to transcription confidence when present. Emits p_phone identity (\u201ctg:\u201d for TG) for downstream RPCs."
    },
    {
      "parameters": {
        "language": "JavaScript",
        "jsCode": "const ctx = $node['Supabase: get_context'].json;\nconst src = $json; // orchestrator raw JSON\n\n// Verify draft (Code) \u2014 replace the final return\nreturn [{\n  json: {\n    draft: $json,\n    context: {\n      children: $node['Supabase: get_context'].json.children || [],\n      last_active_child_id: $node['Supabase: get_context'].json.user?.last_active_child_id || null,\n      failed_by_category: $node['Supabase: get_context'].json.failed_by_category || [],\n      language: $node['Supabase: get_context'].json.user?.preferred_language || 'en',\n      history_summary: $node['Supabase: get_context'].json.user?.history_summary || null\n    }\n  }\n}];"
      },
      "id": "59041dae-d85e-4426-9503-636b2dfea143",
      "name": "Verify draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 1,
      "position": [
        -112,
        528
      ],
      "notes": "Purpose: Package context for Verifier.\nUse: { draft, context:{ children[], last_active_child_id, failed_by_category[], language, history_summary } }."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/apply_updates",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{$json}}",
        "options": {
          "response": {
            "response": {
              "responseFormat": "text"
            }
          }
        }
      },
      "id": "574c1bc8-f674-4bff-a537-ef920ddd7ae5",
      "name": "Supabase: apply_updates",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 3,
      "position": [
        2704,
        544
      ],
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Atomic persist (children upsert, outbound insert, issues/interventions, follow-up queue).\nUse: De-dupes follow-ups on write; outbound idempotent via client_msg_id.\nMeta: Strict v2 envelope."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://graph.facebook.com/v22.0/742665365605812/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "whatsAppApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{\n  ({\n    messaging_product: 'whatsapp',\n    to: $node['Format (strict)'].json.phone_number,\n    type: 'text',\n    text: {\n      preview_url: false,\n      body: $node['Format (strict)'].json.response\n    }\n  })\n}}",
        "options": {}
      },
      "id": "ca610657-d719-4592-813a-6a114d16fdf6",
      "name": "WhatsApp: send text",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 3,
      "position": [
        2912,
        544
      ],
      "credentials": {
        "whatsAppApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Send reply to parent.\nUse: Reads phone_number and response from Formatter.\nMeta: Templates are used only by the WA Follow-up Sender."
    },
    {
      "parameters": {
        "jsCode": "// ===== Keep your existing identity + reply guardrails =====\nconst phone = $json.phone_number;\nconst reply = String($json.response || '');\nif (!phone) throw new Error('Build p_updates: missing phone');\nif (!reply) throw new Error('Build p_updates: outbound.text missing');\n\nconst waId = $node['Parse payload'].json.whatsapp_id || 'no-waid';\n\n// Deterministic, no external libs:\nfunction tinyHash(s){\n  let h = 0; for (let i=0;i<s.length;i++) h = (h*31 + s.charCodeAt(i)) >>> 0;\n  return h.toString(16).padStart(8,'0');\n}\nconst client_msg_id = `${waId}:v2:${tinyHash(reply)}`;\n\n// ===== NEW: tiny helpers for safe, short parameters =====\nfunction cleanText(v, max = 60) {\n  if (v == null) return '';\n  let s = String(v).trim().replace(/\\s+/g, ' ');\n  if (s.length > max) s = s.slice(0, max - 1) + '\u2026';\n  return s;\n}\nfunction prettyCategory(slug) {\n  if (!slug) return '';\n  const s = String(slug).toLowerCase();\n  // very small map so we never send empty topic when category is known\n  const map = {\n    'sleep': 'sleep',\n    'sleep.naps': 'naps',\n    'feeding': 'feeding',\n    'feeding.solids': 'solids',\n    'behavior': 'behavior',\n    'behavior.tantrums': 'tantrums',\n    'potty': 'potty training',\n    'health': 'health',\n    'development': 'development',\n    'routine': 'routine',\n    'childcare': 'childcare',\n    'travel': 'travel',\n    'screen_time': 'screen time'\n  };\n  return map[s] || s.split('.').pop();\n}\nfunction pickTopic() {\n  // Prefer any short/structured fields the Verifier/Formatter passes through,\n  // then fall back to the freeform text, then category label.\n  const t =\n    $json.topic_short ??\n    $json.topic_label ??\n    $json.topic_freeform ??\n    prettyCategory($json.category_slug);\n  return cleanText(t, 60);\n}\nfunction pickChildName() {\n  // Prefer normalized child object from Verifier/Formatter\n  const n = $json.child?.name\n        || ($json.children && Array.isArray($json.children) ? $json.children[0]?.name : null)\n        || null;\n  return cleanText(n, 40);\n}\nfunction pickWinShort() {\n  // If Verifier/Formatter ever provides a concise success label\n  const w = $json.win_short ?? $json.flags?.win_short ?? null;\n  // fall back to a generic, still safe\n  return cleanText(w || 'that win', 50);\n}\n\n// ===== Build base p_updates (unchanged) =====\nconst p_updates = {\n  outbound: {\n    client_msg_id,\n    text: reply,\n    lang: $json.lang || 'en',\n    child_id: Array.isArray($json.interventions) && $json.interventions[0]?.child_id\n      ? $json.interventions[0].child_id\n      : null\n  }\n};\n\n// children upsert (optional)\nif (Array.isArray($json.children) && $json.children.length) {\n  p_updates.children = $json.children;\n}\n\n// interventions (optional)\nif (Array.isArray($json.interventions) && $json.interventions.length) {\n  p_updates.interventions = $json.interventions.map(iv => ({\n    category_slug: iv.category_slug,\n    technique_slug: iv.technique_slug ?? null,\n    solution: iv.solution ?? null,\n    expected_outcome: iv.expected_outcome ?? null,\n    child_id: iv.child_id ?? null\n  }));\n}\n\n// ===== Follow-up (optional) \u2014 prefer ISO from Policy Clamp/Formatter =====\nconst templateName = $json.template_name || null;\nconst sfIso = $json.followup_scheduled_for_iso || null;  // set in Formatter if present\nconst hasHours = $json.followup_hours != null;\n\nif (templateName && (sfIso || hasHours)) {\n  const hours = Number($json.followup_hours ?? 12);\n  const scheduled_for = sfIso || new Date(Date.now() + hours * 3600 * 1000).toISOString();\n\n  // Start from any parameters already provided upstream; we only fill what\u2019s missing.\n  const params = Object.assign({}, $json.followup_params || {});\n  const childName = pickChildName();\n  const topic = pickTopic();\n\n  // ===== NEW: deterministic mapping per template (fills only if missing) =====\n  switch (templateName) {\n    case 'open_loop_nudge':\n      if (params.topic == null || params.topic === '') params.topic = topic;\n      break;\n\n    case 'check_in_generic':\n      if (params.topic == null || params.topic === '') params.topic = topic;\n      if (params.child_name == null || params.child_name === '') params.child_name = childName;\n      break;\n\n    case 'reengage_weekly':\n      if (params.child_name == null || params.child_name === '') params.child_name = childName;\n      if (params.topic_hint == null || params.topic_hint === '') params.topic_hint = topic;\n      break;\n\n    case 'success_celebration':\n      if (params.child_name == null || params.child_name === '') params.child_name = childName;\n      if (params.win_short == null || params.win_short === '') params.win_short = pickWinShort();\n      break;\n\n    // keep other templates working (no-op) \u2014 add cases here when you add new ones\n    default:\n      break;\n  }\n\n  // Final safety: never send undefined; use empty strings for absent params\n  for (const k of Object.keys(params)) {\n    if (params[k] == null) params[k] = '';\n  }\n\n  p_updates.followup = {\n    create: true,\n    template_name: templateName,\n    scheduled_for,\n    language: $json.lang || 'en',\n    parameters: params,\n  };\n}\n\nreturn [{ json: { p_phone: phone, p_updates } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2512,
        544
      ],
      "id": "a1c6df27-4288-4679-8a09-312d180ad731",
      "name": "Build p_updates",
      "notes": "Purpose: Create v2 payload for DB.\nUse: { p_phone, p_updates: { outbound:{ client_msg_id, text, lang, child_id?, provider }, children?, interventions?, followup? } }.\nMeta: Deterministic client_msg_id derived from provider message id + reply. Guards on missing outbound.text."
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4o",
          "mode": "list",
          "cachedResultName": "GPT-4O"
        },
        "messages": {
          "values": [
            {
              "content": "You are **Bubu**, a warm, practical parenting companion.\n\nINPUT (JSON from the previous node):\n- lang\n- children[]: { id, name, date_of_birth|null }\n- last_active_child_id, active_child_hint\n- recent_messages[] (last 14 days)\n- recent_interventions[]\n- failed_by_category[]: [{ child_id, category_slug, slugs:[technique_slug] }]\n- history_summary: string|null\n- message: parent\u2019s current message\n- is_voice: boolean\n- offer_voice_note: boolean\n- voice_low_confidence: boolean\n\nBEHAVIOR:\n- Be brief, kind, and actionable. 40\u201390 words. Use a child\u2019s name only if present in `children[]`, `active_child_hint`, or explicitly in `message`.\n- Do not guess a name. If unknown, say \u201cyour child\u201d.\n- One clarifier max if advice is blocked; ask one crisp question.\n- Avoid techniques listed in `failed_by_category` for that child/category.\n- Use `history_summary` only for continuity (don\u2019t restate it).\n- Detect medical red flags; if present, set flags.medical_red_flag=true and add a short safety line (no diagnosis).\n- If a check-in will help, suggest it and set a follow-up.\n- Tone: warm coach; 2\u20134 concrete steps.\n- Language = input message language; else reuse last known.\n\nIMPORTANT OUTPUT RULES:\n- Never invent names/ages. If a field isn\u2019t known, output **null**.\n- Only include `intervention` when you give a concrete plan.\n- Only include `child_update` when the parent introduces a *new* child.\n- Only include `schedule_followup` when you explicitly want a check-in; parameters must not contain a child_name unless it is known.\n\nOUTPUT \u2014 return ONLY this JSON (no markdown, no extra text):\n{\n  \"reply\": string,                         // 40\u201390 words, plain text\n  \"lang\": \"en\" | \"es\" | \"it\" | \"...\",\n  \"topic_freeform\": string|null,\n  \"category_slug\": \"sleep\"|\"sleep.naps\"|\"feeding.solids\"|\"feeding\"|\"behavior.tantrums\"|\"behavior\"|\"potty\"|\"health\"|\"development\"|\"routine\"|\"childcare\"|\"travel\"|\"screen_time\"|null,\n\n  \"child\": { \"id\": string|null, \"name\": string|null },\n\n  \"intervention\": {\n    \"technique_slug\": string,\n    \"solution\": string,                    // 2\u20134 short steps\n    \"expected_outcome\": string|null\n  } | null,\n\n  \"clarification\": { \"ask\": boolean, \"question\": string|null },\n\n  \"schedule_followup\": {\n    \"create\": boolean,\n    \"template_name\": string|null,\n    \"scheduled_for_hours\": number|null,\n    \"parameters\": { \"child_name\": string|null } | null\n  },\n\n  \"child_update\": {\n    \"add_or_update\": boolean,\n    \"name\": string|null,\n    \"date_of_birth\": string|null,\n    \"approx_age_months\": number|null\n  } | null,\n\n  \"flags\": { \"medical_red_flag\": boolean, \"upsell\": \"never\"|\"maybe\"|\"now\" }\n}",
              "role": "system"
            },
            {
              "content": "={{ JSON.stringify($node[\"Resolve child + build\"].json.orchestrator_input) }}"
            }
          ]
        },
        "jsonOutput": true,
        "options": {
          "temperature": 0.2
        }
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        -416,
        528
      ],
      "id": "55b8a696-d801-40ab-98e2-036738ff9817",
      "name": "OpenAI: Orchestrator",
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Draft human reply + structure.\nUse: Returns reply, lang, topic_freeform, category_slug?, child{id|name?}, intervention?, clarification?, schedule_followup?, child_update?, flags.\nMeta: \u201cPlan + Probe\u201d; \u22645 lines; use history_summary for continuity (don\u2019t echo). Never guess child names; unknowns must be null. May append a single non-gated \u201csend a quick voice note\u201d line when offer_voice_note=true."
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4o",
          "mode": "list",
          "cachedResultName": "GPT-4O"
        },
        "messages": {
          "values": [
            {
              "content": "You are a deterministic verifier/fixer that outputs ONE JSON object named pipeline_ready.\n\nRules (do all):\n- Reply length: \u22645 lines. If longer, condense to \u22645 lines.\n- Safety line: add ONLY if flags.medical_red_flag=true.\n- Resolve child in this order: (1) explicit id; (2) match name in context.children; (3) context.last_active_child_id; else null.\n- Interventions require BOTH category_slug and child.id; otherwise set intervention=null for this turn.\n- If intervention.technique_slug exists in context.failed_by_category for this child+category, replace with a reasonable alternative or drop technique_slug but keep solution.\n- If category_slug=null, try a simple keyword map from topic_freeform; if still null, do NOT include an intervention.\n- If child_update.add_or_update=true in the draft, include children:[{name, date_of_birth|null, approx_age_months|null}].\n- You may use context.history_summary only to resolve names/continuity. Never echo it.\n\nOutput EXACTLY:\n{\n  \"pipeline_ready\": {\n    \"reply\": string,\n    \"lang\": string,\n    \"topic_freeform\": string|null,\n    \"category_slug\": string|null,\n    \"child\": {\"id\": \"uuid|null\", \"name\": \"string|null\"},\n    \"children\": [{\"name\":\"string\",\"date_of_birth\":null|\"YYYY-MM-DD\",\"approx_age_months\":number|null}]|null,\n    \"intervention\": {\"technique_slug\":\"string|null\",\"solution\":\"string|null\",\"expected_outcome\":\"string|null\"}|null,\n    \"schedule_followup\": {\"create\":bool,\"template_name\":\"string|null\",\"scheduled_for_hours\":number|null,\"parameters\":object}|null,\n    \"flags\": {\"medical_red_flag\":bool}\n  }\n}\nReturn ONLY pipeline_ready. No commentary.",
              "role": "system"
            },
            {
              "content": "=Context:\n{{JSON.stringify($json.context)}}\nModel output to verify:\n{{JSON.stringify($json.draft)}}\n\nReturn ONLY pipeline_ready."
            }
          ]
        },
        "jsonOutput": true,
        "options": {
          "maxTokens": 600,
          "temperature": 0,
          "topP": 1
        }
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        64,
        528
      ],
      "id": "1b11084a-704b-42c6-a3d2-dfdc65b82018",
      "name": "OpenAI: Verifier",
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Normalize to DB-ready pipeline_ready.\nUse: Returns pipeline_ready = { reply, lang, topic_freeform?, category_slug?, child{id|name?}, children[]?, intervention?, schedule_followup?{create, template_name?, scheduled_for_hours?, parameters?}, flags }.\nMeta: Enforces \u22645 lines; safety line only on flag; interventions require category_slug + child.id. Unknown names remain null."
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "value": "gpt-4o",
          "mode": "list",
          "cachedResultName": "GPT-4O"
        },
        "messages": {
          "values": [
            {
              "content": "Produce a compact, factual relationship summary of the last 10\u201315 turns (child focus, topics, techniques tried, outcomes, next check-in). \u22641100 characters hard cap. No fluff, no advice, no emojis. Return plain text only.",
              "role": "system"
            },
            {
              "content": "=Phone: {{$node[\"Resolve child + build\"].json.phone_number}}\nRecent messages:\n{{JSON.stringify($node[\"Supabase: get_context\"].json.recent_messages || [])}}\nRecent interventions:\n{{JSON.stringify($node[\"Supabase: get_context\"].json.recent_interventions || [])}}"
            }
          ]
        },
        "options": {}
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        3088,
        544
      ],
      "id": "d506f3b3-2bd7-4ad4-bea6-249754f42e6f",
      "name": "OpenAI: Summarizer",
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Short factual relationship summary.\nUse: \u22641100 chars; no advice."
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.errorTrigger",
      "typeVersion": 1,
      "position": [
        -2320,
        720
      ],
      "id": "8919edef-8a83-4f51-86c5-e4a29d4f12b7",
      "name": "Error Trigger",
      "notes": "Purpose: Capture workflow failure.\nUse: Error Trigger \u2192 Code (build log) \u2192 log_event(p_phone, \u2018workflow_error\u2019, metadata).\nMeta: Column is metadata (not meta). No customer message."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/log_event",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "p_phone",
              "value": "={{$json.phone_number}}"
            },
            {
              "name": "p_type",
              "value": "={{$json.event_type}}"
            },
            {
              "name": "p_meta",
              "value": "={{ JSON.stringify($json.meta) }}"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -1952,
        720
      ],
      "id": "dc1c1c34-7ac7-4d6f-904e-7ea1c75f1e8c",
      "name": "log_event",
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/update_user_summary",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "p_phone",
              "value": "={{$node[\"Resolve child + build\"].json.phone_number}}"
            },
            {
              "name": "p_summary",
              "value": "={{$json.message.content}}"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3376,
        544
      ],
      "id": "e1eb3779-40fb-4b81-aff8-8393448712eb",
      "name": "Update Summary",
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Persist history_summary + last_summary_at.\nUse: { p_phone, p_summary }.\nMeta: SECURITY DEFINER; anon key."
    },
    {
      "parameters": {
        "jsCode": "// Error Trigger payload\nconst exec = $json.execution || {};\nconst run  = exec.data || {};\nconst runData = run?.resultData?.runData || {};\nconst wf   = exec.workflow || {};\nconst err  = exec.error || {};\n\nlet phone = null;\n// Heuristic: scan runData for a phone number from any node output\nouter:\nfor (const nodeName of Object.keys(runData)) {\n  for (const task of (runData[nodeName] || [])) {\n    for (const conn of (task?.data?.main || [])) {\n      for (const item of (conn || [])) {\n        const j = item?.json || {};\n        const candidate = j.phone_number || j.p_phone || j.to || j.from;\n        if (candidate && String(candidate).trim()) { phone = String(candidate).trim(); break outer; }\n      }\n    }\n  }\n}\n\nconst trunc = (s, n) => (s ? String(s).slice(0, n) : null);\n\nreturn {\n  phone_number: phone || 'unknown',\n  event_type: 'workflow_error',\n  meta: {\n    workflow_id: wf.id || null,\n    workflow_name: wf.name || null,\n    run_id: exec.id || null,\n    failed_node: exec.node?.name || null,\n    error_message: trunc(err.message, 500),\n    error_stack: trunc(err.stack, 1000),\n    started_at: run.startedAt || null,\n    stopped_at: run.stoppedAt || null,\n  },\n};"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2128,
        720
      ],
      "id": "c60dde97-fb60-461c-a796-9a5472f81032",
      "name": "Build Log"
    },
    {
      "parameters": {
        "jsCode": "// ---- Formatter (strict) ----\n\nconst phone = $node['Resolve child + build'].json.phone_number;\nif (!phone) throw new Error('Formatter: missing phone_number');\n\nconst pr = $json.pipeline_ready || $node['Guard: Verifier JSON'].json.pipeline_ready;\nif (!pr || !pr.reply) throw new Error('Formatter: pipeline_ready.reply missing');\n\n// 1) Clamp reply to \u22645 lines\nlet text = String(pr.reply).replace(/\\s+\\n/g, '\\n').trim()\n  .split('\\n').slice(0, 5).join('\\n');\nif (!text) throw new Error('Formatter: empty reply after clamp');\n\n// 2) Topic \u2192 category fallback\nfunction mapTopicToCategorySlug(topic) {\n  if (!topic) return null;\n  const t = String(topic).toLowerCase();\n  if (/(sleep|nap|bedtime|regression|wake window|early waking)/.test(t)) return 'sleep';\n  if (/(feed|breast|bottle|formula|latch|pumping|reflux|spit up|colic)/.test(t)) return 'feeding';\n  if (/(solids|wean|weaning|pur\u00e9e|blw|baby-led)/.test(t)) return 'feeding.solids';\n  if (/(potty|toilet|pee|poo|accident)/.test(t)) return 'potty';\n  if (/(fever|cough|rash|diarrhea|vomit|sick|ill)/.test(t)) return 'health';\n  if (/(routine|schedule|morning|bedtime routine)/.test(t)) return 'routine';\n  if (/(tantrum|meltdown|biting|hitting|aggression|defiance|boundary|discipline|scream)/.test(t)) return 'behavior.tantrums';\n  if (/(milestone|rolling|crawling|walking|talking)/.test(t)) return 'development';\n  if (/(screen|tv|tablet|youtube)/.test(t)) return 'screen_time';\n  if (/(travel|flight|jet lag|car seat)/.test(t)) return 'travel';\n  return null;\n}\nconst category_slug = pr.category_slug || mapTopicToCategorySlug(pr.topic_freeform);\n\n// 3) Build output\nconst out = {\n  phone_number: phone,\n  response: text,\n  lang: pr.lang || 'en',\n};\n\n// 4) Children passthrough\nif (Array.isArray(pr.children) && pr.children.length) {\n  out.children = pr.children.map(c => {\n    const name = String(c.name || '').trim();\n    if (!name) return null;\n    const hasDOB = !!c.date_of_birth;\n    return {\n      name,\n      date_of_birth: hasDOB ? c.date_of_birth : null,\n      approx_age_months: c.approx_age_months ?? null,\n      dob_precision: hasDOB ? 'exact' : 'approx',\n    };\n  }).filter(Boolean);\n}\n\n// 5) Interventions (if both category & child.id)\nif (category_slug && pr.child && pr.child.id && pr.intervention) {\n  const { technique_slug = null, solution = null, expected_outcome = null } = pr.intervention;\n  if (technique_slug || solution) {\n    out.interventions = [{\n      category_slug,\n      technique_slug,\n      solution,\n      expected_outcome,\n      child_id: pr.child.id,\n    }];\n  }\n}\n\n// 6) Follow-up passthrough\nif (pr.schedule_followup && pr.schedule_followup.create) {\n  out.followup_hours = pr.schedule_followup.scheduled_for_hours ?? null;\n  out.template_name = pr.schedule_followup.template_name || null;\n  out.followup_params = pr.schedule_followup.parameters || {};\n  if (pr.schedule_followup.scheduled_for_iso) {\n    out.followup_scheduled_for_iso = pr.schedule_followup.scheduled_for_iso;\n  }\n}\n\nreturn [{ json: out }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2272,
        544
      ],
      "id": "b14a80a8-0e73-4305-b053-c6f19419b034",
      "name": "Format (strict)",
      "notes": "Purpose: Convert pipeline_ready \u2192 channel-specific envelope + DB-safe struct.\nUse: Clamps to \u22645 lines; surfaces followup_scheduled_for_iso when present.\nMeta:\n\u2022 WA: plain text copy for \u201csend now\u201d; template fields only for scheduled follow-ups."
    },
    {
      "parameters": {
        "jsCode": "// Normalize Verifier output to { pipeline_ready: {...} } no matter the shape\n\nconst raw = $json;\nlet pr = null;\n\n// 1) Ideal: already at root\nif (raw && raw.pipeline_ready) pr = raw.pipeline_ready;\n\n// 2) n8n \"full\" message shape\nif (!pr && raw && raw.message && raw.message.content && raw.message.content.pipeline_ready) {\n  pr = raw.message.content.pipeline_ready;\n}\n\n// 3) Array of choices/messages\nif (!pr && Array.isArray(raw) && raw[0]?.message?.content?.pipeline_ready) {\n  pr = raw[0].message.content.pipeline_ready;\n}\n\n// 4) Occasionally it's under .content directly\nif (!pr && raw && raw.content && raw.content.pipeline_ready) {\n  pr = raw.content.pipeline_ready;\n}\n\n// 5) Last resort: if the model returned the bare object (no wrapper)\n//    we accept it if it looks like a pipeline_ready (has reply/lang)\nif (!pr && raw && typeof raw === 'object' && raw.reply && raw.lang) {\n  pr = raw;\n}\n\nif (!pr || typeof pr !== 'object') {\n  const keys = raw && typeof raw === 'object' ? Object.keys(raw) : typeof raw;\n  throw new Error(`Verifier: missing pipeline_ready object (got: ${JSON.stringify(keys)})`);\n}\n\n// Minimal shape checks (fail fast)\nfor (const k of ['reply','lang','child','flags']) {\n  if (!(k in pr)) throw new Error(`Verifier: pipeline_ready.${k} missing`);\n}\nif (!pr.reply) throw new Error('Verifier: empty reply');\n\n// Output normalized shape for downstream nodes\nreturn [{ json: { pipeline_ready: pr } }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        384,
        528
      ],
      "id": "bc33b3c3-0c19-4525-9212-9318ded01292",
      "name": "Guard: Verifier JSON",
      "notes": "Purpose: Normalize Verifier output regardless of LLM shape.\nUse: Guarantees downstream input is { pipeline_ready:{ reply, lang, \u2026 } }.\nMeta: Hard-fails if reply/lang missing."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "3a08c22b-3c91-46f6-9922-a37062a905b5",
              "leftValue": "={{$json.is_voice}}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -1904,
        496
      ],
      "id": "5ff365d0-a7ad-4322-9942-58d7667525b2",
      "name": "is_voice?",
      "notes": "Purpose: Ignore delivery/status webhooks and non-message updates.\nUse: If no content and no media, stop."
    },
    {
      "parameters": {
        "url": "={{ 'https://graph.facebook.com/v20.0/' + $node[\"Parse payload\"].json.media_id }}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "whatsAppApi",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -1696,
        352
      ],
      "id": "8cd7c456-5256-4ff5-9029-241efbb782f8",
      "name": "WA Get Media URL",
      "credentials": {
        "whatsAppApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Resolve temporary download URL from media_id.\nUse: Graph API; returns { url, mime_type, file_size, id }."
    },
    {
      "parameters": {
        "resource": "audio",
        "operation": "transcribe",
        "options": {}
      },
      "type": "@n8n/n8n-nodes-langchain.openAi",
      "typeVersion": 1.8,
      "position": [
        -1328,
        352
      ],
      "id": "557366b9-69b1-48fe-a7c6-7356ca3f5598",
      "name": "Transcribe a recording",
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Convert audio to text.\nUse: Input = binary; returns { text, seconds }.\nMeta: transcription_confidence may be null; field retained."
    },
    {
      "parameters": {
        "jsCode": "// earlier fields from your Parse payload node\nconst parse = $node[\"Parse payload\"].json;\n\n// transcription payload from the previous node\nconst tr = $json;\n\n// text (robust)\nconst text =\n  (tr.text ??\n   tr.data?.text ??\n   tr.output?.text ??\n   tr.choices?.[0]?.message?.content ??\n   \"\").toString();\n\n// confidence: not returned by this model \u2192 keep null (or compute later if you switch models)\nlet conf = null;\n\n// duration: map from usage.seconds (fallbacks kept just in case)\nconst duration =\n  tr.usage?.seconds ??\n  tr.duration ??\n  tr.data?.duration ??\n  null;\n\nreturn [\n  {\n    json: {\n      phone_number: parse.phone_number,\n      whatsapp_id: parse.whatsapp_id,\n      type: \"text\",\n      content: text.trim(),\n      is_voice: true,\n      media_id: parse.media_id ?? null,\n      transcription_confidence: conf,   // stays null (expected)\n      audio_seconds: duration,          // now 4 in your example\n      timestamp: parse.timestamp,\n    },\n  },\n];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1152,
        352
      ],
      "id": "f8741c07-c0cd-4f60-8889-546c19b29e74",
      "name": "Build transcribed message",
      "notes": "Purpose: Standardize transcription payload to \u201ctext-like\u201d inbound.\nUse: Output matches the channel\u2019s normalized schema with is_voice=true and audio_seconds set."
    },
    {
      "parameters": {
        "url": "={{$json.url}}",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "whatsAppApi",
        "options": {
          "response": {
            "response": {
              "responseFormat": "file"
            }
          }
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -1520,
        352
      ],
      "id": "29e671ad-0b0f-4351-a8e6-8047ae87c0e6",
      "name": "WA Download Media",
      "credentials": {
        "whatsAppApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Download audio as binary.\nUse: GET url \u2192 binary property data.\nMeta: Use provider credential."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "bd1b5110-5885-45f4-bc69-4be896612165",
              "leftValue": "={{ !!$json.pipeline_ready?.schedule_followup?.create }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        576,
        528
      ],
      "id": "6ddb5b68-4f65-4fa0-b099-954b98e1ff45",
      "name": "Has followup?"
    },
    {
      "parameters": {
        "jsCode": "// 0) Early-drop if timing disallowed (string or boolean)\nconst ta = $json.timing_allowed;\nif (ta === false || ta === 'false') return [];\n\n// 1) Inputs\nconst out = items[0].json;\nconst pr  = out.pipeline_ready || {};\nconst sf  = pr.schedule_followup || {};\nif (!sf.create) return items;\n\n// 2) Normalize LLM template \u2192 snake_case + map synonyms\nconst raw = String(sf.template_name || '')\n  .toLowerCase()\n  .replace(/[^a-z0-9]+/g, '_')\n  .replace(/^_|_$/g, '');\n\nconst MAP = {\n  // generic check-ins\n  'check_in': 'check_in_generic',\n  'checkin': 'check_in_generic',\n  'check_in_on_sleep_schedule': 'check_in_generic',\n  'sleep_check_in': 'check_in_generic',\n  'sleep_progress_check': 'check_in_generic',\n\n  // nudges\n  'open_loop': 'open_loop_nudge',\n  'nudge': 'open_loop_nudge',\n\n  // weekly re-engage\n  'weekly_check_in': 'reengage_weekly',\n  'reengage': 'reengage_weekly',\n\n  // celebration\n  'celebrate_success': 'success_celebration',\n  'win_celebration': 'success_celebration',\n};\n\nlet template = MAP[raw] || raw;\n\n// 3) Allowed templates (as in your Meta list)\nconst ALLOW = new Set([\n  'check_in_generic',\n  'open_loop_nudge',\n  'reengage_weekly',\n  'success_celebration',\n  'bubu_test_basic',\n  'hello_world',\n]);\n\n// Fallback if LLM name not recognized\nif (!ALLOW.has(template)) {\n  const hrs = Number(sf.scheduled_for_hours ?? 24);\n  template = hrs >= 72 ? 'reengage_weekly' : 'check_in_generic';\n}\n\n// 4) Caps / recency guard\nconst sent1d = Number(out.sent_1d || 0) > 0;\nconst sent7d = Number(out.sent_7d || 0) >= 3;\nconst recent = !!out.recent_inbound;\nconst isCritical = template === 'clarify_blocker'; // none of your current templates, kept for future\nif (sent1d || sent7d || (recent && !isCritical)) {\n  pr.schedule_followup = { create: false };\n  out.pipeline_ready = pr;\n  return [{ json: out }];\n}\n\n// 5) Ensure parameters (topic & child_name)\nconst topic =\n  pr.topic_freeform || pr.category_slug || sf.parameters?.topic || 'your goal';\nconst childName =\n  pr.child?.name || pr.children?.[0]?.name || sf.parameters?.child_name || 'your child';\nsf.parameters = { ...(sf.parameters || {}), topic, child_name: childName };\n\n// 6) Timing source: prefer RPC (Adopt node) \u2192 fallback to candidate\nconst whenIso =\n  out.followup_scheduled_for_iso ??\n  sf.scheduled_for_iso ??\n  out.scheduled_for_candidate_iso ?? null;\n\n// 7) Write back\npr.schedule_followup = {\n  ...sf,\n  create: true,\n  template_name: template,\n  language: sf.language || 'en',\n  scheduled_for_iso: whenIso,\n};\nout.pipeline_ready = pr;\n\nreturn [{ json: out }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1936,
        368
      ],
      "id": "c40c6671-7040-4b84-b59a-9e6b6d6a6b8b",
      "name": "Follow-up Policy Clamp",
      "notes": "Purpose: Early-drop follow-ups disallowed by timing clamp/opt-out.\nUse: if ($json.timing_allowed === false) return []; (rest of policy unchanged).\nMeta: Prevents enqueue when cap_24h/cap_7d/opted_out/in_quiet_hours; optionally log timing_reason for observability."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "3c490698-789f-4043-b2d9-1b9fed8d8451",
              "name": "_now_iso",
              "value": "={{ $now.toUTC().toISO() }}",
              "type": "string"
            },
            {
              "id": "25db1f7c-3e30-4ae1-a1e3-ae7d159fe0c6",
              "name": "_cutoff_2h",
              "value": "={{ $now.minus({ hours: 2 }).toUTC().toISO() }}",
              "type": "string"
            },
            {
              "id": "d124e936-1ffc-463d-961f-e841fe6ee4e8",
              "name": "_cutoff_1d",
              "value": "={{ $now.minus({ days: 1 }).toUTC().toISO() }}",
              "type": "string"
            },
            {
              "id": "ce116adc-9335-4c1e-867f-a5cbae1a8e97",
              "name": "_cutoff_7d",
              "value": "={{ $now.minus({ days: 7 }).toUTC().toISO() }}",
              "type": "string"
            },
            {
              "id": "216a8523-a044-489f-bf7c-2721365ad7a1",
              "name": "_candidate_utc",
              "value": "={{ $now.plus({ hours: $json.pipeline_ready?.schedule_followup?.scheduled_for_hours ?? 12 }).toUTC().toISO() }}",
              "type": "string"
            },
            {
              "id": "5426e7ce-24a0-4afa-8807-e39fccab38b2",
              "name": "scheduled_for_candidate_iso",
              "value": "={{ $now.plus({ hours: $json.pipeline_ready?.schedule_followup?.scheduled_for_hours ?? 12 }).toUTC().toISO() }}",
              "type": "string"
            },
            {
              "id": "712db744-bbd0-4253-a819-81b0180c1a88",
              "name": "phone_number",
              "value": "={{ $json.phone_number || $node[\"Parse payload\"].json.phone_number }}",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        832,
        384
      ],
      "id": "16b8d26f-4667-4a01-aa1b-c7baf3dd895a",
      "name": "Time helpers + clamp candidate",
      "notes": "Purpose: Produce a candidate timestamp only (no per-user clamping here).\nUse: Output scheduled_for_candidate_iso (e.g., now+24h, \u201ctoday 18:00\u201d).\nMeta: Remove old quiet-hours logic\u2014final ISO is set downstream via the RPC result."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/followup_clamp_inputs",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"p_phone\": \"{{$json.phone_number}}\",\n  \"p_cutoff_2h\": \"{{$json._cutoff_2h}}\",\n  \"p_cutoff_1d\": \"{{$json._cutoff_1d}}\",\n  \"p_cutoff_7d\": \"{{$json._cutoff_7d}}\"\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1104,
        208
      ],
      "id": "6e2fa884-dbff-4770-bbb7-23a1ffbed48b",
      "name": "Supabase: Clamp inputs",
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Provide sender stop-rules data.\nUse: Input phone + cutoffs \u2192 { recent_inbound, sent_1d, sent_7d }.\nMeta: DB is single source of truth for recency and caps."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/rpc_followup_timing_clamp",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "supabaseApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"p_phone_number\": \"={{ $json.phone_number || $node['Parse payload'].json.phone_number }}\",\n  \"p_candidate\": \"={{ $json.scheduled_for_candidate_iso }}\",\n  \"p_cap_24h\": 1,\n  \"p_cap_7d\": 3,\n  \"p_jitter_minutes\": 7\n}",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1200,
        384
      ],
      "id": "04639200-9f76-47e7-a50e-a73018be004a",
      "name": "Supabase: timing_clamp (RPC)",
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "notes": "Purpose: Convert a candidate send time into a per-user allowed time using timezone + quiet hours, with caps & jitter.\nUse: Calls rpc_followup_timing_clamp with p_phone_number + p_candidate; returns { allowed, next_allowed, reason }.\nMeta: Auth via SERVICE_ROLE; don\u2019t \u201cContinue On Fail\u201d; idempotent (read-only); safe to call multiple times."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "2e79888b-0ad0-4377-8dc5-c2228476b199",
              "name": "timing_allowed",
              "value": "={{ $json.allowed }}",
              "type": "boolean"
            },
            {
              "id": "522c7623-3f9c-4cd8-8959-1c399e4e1bcf",
              "name": "timing_reason",
              "value": "={{ $json.reason }}",
              "type": "string"
            },
            {
              "id": "a5fa35c7-bf17-490e-99fe-8c90e5c52699",
              "name": "followup_scheduled_for_iso",
              "value": "={{ $json.next_allowed }}",
              "type": "string"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1456,
        384
      ],
      "id": "2bf02bbe-083c-4242-ae9c-937e0446d29d",
      "name": "Adopt timing clamp result",
      "notes": "Purpose: Normalize RPC output for downstream nodes.\nUse: Exposes timing_allowed, timing_reason, and sets followup_scheduled_for_iso = next_allowed.\nMeta: Hard-fails if allowed=true but next_allowed missing; keeps a single, final ISO for the queue."
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineByPosition",
        "options": {
          "clashHandling": {
            "values": {
              "resolveClash": "preferInput1"
            }
          },
          "includeUnpaired": false
        }
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        1472,
        192
      ],
      "id": "29a44b46-dff0-4922-9ce6-20547393d23d",
      "name": "Merge A",
      "notes": "Purpose: Carry both (A) pipeline_ready + clamped ISO and (B) RPC caps in one item.\nUse: Deep-merge, left-bias."
    },
    {
      "parameters": {
        "mode": "combine",
        "combineBy": "combineByPosition",
        "options": {}
      },
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3.2,
      "position": [
        1728,
        368
      ],
      "id": "0a6b83b7-3a20-43b2-a58e-44beb0156273",
      "name": "Merge B",
      "notes": "Purpose: Combine upstream payload + caps info + timing-clamp result into one item.\nUse: Input 1 = Merge A (existing), Input 2 = Adopt timing clamp result.\nMeta: Mode = Merge by Index; preserves all fields; no transforms\u2014just unions the three streams."
    }
  ],
  "connections": {
    "Webhook (GET)": {
      "main": [
        [
          {
            "node": "Verify (GET)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify (GET)": {
      "main": [
        [
          {
            "node": "Respond (GET)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook (POST)": {
      "main": [
        [
          {
            "node": "Parse payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse payload": {
      "main": [
        [
          {
            "node": "is_voice?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip?": {
      "main": [
        [],
        [
          {
            "node": "Supabase: record_inbound",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Supabase: record_inbound": {
      "main": [
        [
          {
            "node": "Supabase: get_context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Supabase: get_context": {
      "main": [
        [
          {
            "node": "Resolve child + build",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve child + build": {
      "main": [
        [
          {
            "node": "OpenAI: Orchestrator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify draft": {
      "main": [
        [
          {
            "node": "OpenAI: Verifier",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Supabase: apply_updates": {
      "main": [
        [
          {
            "node": "WhatsApp: send text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build p_updates": {
      "main": [
        [
          {
            "node": "Supabase: apply_updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI: Orchestrator": {
      "main": [
        [
          {
            "node": "Verify draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI: Verifier": {
      "main": [
        [
          {
            "node": "Guard: Verifier JSON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "WhatsApp: send text": {
      "main": [
        [
          {
            "node": "OpenAI: Summarizer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI: Summarizer": {
      "main": [
        [
          {
            "node": "Update Summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Build Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Log": {
      "main": [
        [
          {
            "node": "log_event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format (strict)": {
      "main": [
        [
          {
            "node": "Build p_updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Guard: Verifier JSON": {
      "main": [
        [
          {
            "node": "Has followup?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "is_voice?": {
      "main": [
        [
          {
            "node": "WA Get Media URL",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "WA Get Media URL": {
      "main": [
        [
          {
            "node": "WA Download Media",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Transcribe a recording": {
      "main": [
        [
          {
            "node": "Build transcribed message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build transcribed message": {
      "main": [
        [
          {
            "node": "Supabase: record_inbound",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "WA Download Media": {
      "main": [
        [
          {
            "node": "Transcribe a recording",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has followup?": {
      "main": [
        [
          {
            "node": "Time helpers + clamp candidate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format (strict)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Time helpers + clamp candidate": {
      "main": [
        [
          {
            "node": "Supabase: Clamp inputs",
            "type": "main",
            "index": 0
          },
          {
            "node": "Supabase: timing_clamp (RPC)",
            "type": "main",
            "index": 0
          },
          {
            "node": "Merge A",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Follow-up Policy Clamp": {
      "main": [
        [
          {
            "node": "Format (strict)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Supabase: Clamp inputs": {
      "main": [
        [
          {
            "node": "Merge A",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Supabase: timing_clamp (RPC)": {
      "main": [
        [
          {
            "node": "Adopt timing clamp result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Adopt timing clamp result": {
      "main": [
        [
          {
            "node": "Merge B",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge A": {
      "main": [
        [
          {
            "node": "Merge B",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge B": {
      "main": [
        [
          {
            "node": "Follow-up Policy Clamp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "staticData": null,
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "versionId": "0f16ea66-53d8-4a52-bab2-52d7c59202ab",
  "triggerCount": 2,
  "shared": [
    {
      "createdAt": "2025-08-14T08:17:18.935Z",
      "updatedAt": "2025-08-14T08:17:18.935Z",
      "role": "workflow:owner",
      "workflowId": "tjk89cBgzinji59E",
      "projectId": "O4WbEo8M4wdnIuwK",
      "project": {
        "createdAt": "2025-08-14T08:13:10.107Z",
        "updatedAt": "2025-08-14T08:13:13.212Z",
        "id": "O4WbEo8M4wdnIuwK",
        "name": "Koosha Najmi <koosha@bubuapp.ai>",
        "type": "personal",
        "icon": null,
        "description": null,
        "projectRelations": [
          {
            "createdAt": "2025-08-14T08:13:10.107Z",
            "updatedAt": "2025-08-14T08:13:10.107Z",
            "userId": "00345f41-7293-4394-9edc-d95d972c1462",
            "projectId": "O4WbEo8M4wdnIuwK",
            "user": {
              "createdAt": "2025-08-14T08:13:08.631Z",
              "updatedAt": "2025-10-12T08:12:04.000Z",
              "id": "00345f41-7293-4394-9edc-d95d972c1462",
              "email": "koosha@bubuapp.ai",
              "firstName": "Koosha",
              "lastName": "Najmi",
              "personalizationAnswers": null,
              "settings": {
                "userActivated": true,
                "easyAIWorkflowOnboarded": true,
                "firstSuccessfulWorkflowId": "tjk89cBgzinji59E",
                "userActivatedAt": 1755208424155,
                "npsSurvey": {
                  "responded": true,
                  "lastShownAt": 1755544077552
                }
              },
              "disabled": false,
              "mfaEnabled": false,
              "lastActiveAt": "2025-10-11",
              "isPending": false
            }
          }
        ]
      }
    },
    {
      "createdAt": "2025-08-22T14:52:49.868Z",
      "updatedAt": "2025-08-22T14:52:49.868Z",
      "role": "workflow:editor",
      "workflowId": "tjk89cBgzinji59E",
      "projectId": "Ygvab2wX9MV6x7TL",
      "project": {
        "createdAt": "2025-08-14T08:23:33.918Z",
        "updatedAt": "2025-08-14T09:34:37.569Z",
        "id": "Ygvab2wX9MV6x7TL",
        "name": "Hadi Keivan <hadi@bubuapp.ai>",
        "type": "personal",
        "icon": null,
        "description": null,
        "projectRelations": [
          {
            "createdAt": "2025-08-14T08:23:33.920Z",
            "updatedAt": "2025-08-14T08:23:33.920Z",
            "userId": "b506f4a9-15d7-4a5e-8423-33f27b6797f4",
            "projectId": "Ygvab2wX9MV6x7TL",
            "user": {
              "createdAt": "2025-08-14T08:23:33.916Z",
              "updatedAt": "2025-10-05T17:33:38.000Z",
              "id": "b506f4a9-15d7-4a5e-8423-33f27b6797f4",
              "email": "hadi@bubuapp.ai",
              "firstName": "Hadi",
              "lastName": "Keivan",
              "personalizationAnswers": null,
              "settings": {
                "easyAIWorkflowOnboarded": true,
                "firstSuccessfulWorkflowId": "yvZIdoAQGA2NeEqb",
                "userActivated": true,
                "userActivatedAt": 1758902861723
              },
              "disabled": false,
              "mfaEnabled": false,
              "lastActiveAt": "2025-10-04",
              "isPending": false
            }
          }
        ]
      }
    },
    {
      "createdAt": "2025-08-22T15:35:33.871Z",
      "updatedAt": "2025-08-22T15:35:33.871Z",
      "role": "workflow:editor",
      "workflowId": "tjk89cBgzinji59E",
      "projectId": "rJbk9aEmeIAmF2gQ",
      "project": {
        "createdAt": "2025-08-22T14:51:07.230Z",
        "updatedAt": "2025-08-22T17:19:37.695Z",
        "id": "rJbk9aEmeIAmF2gQ",
        "name": "Ali Issa <ali@bubuapp.ai>",
        "type": "personal",
        "icon": null,
        "description": null,
        "projectRelations": [
          {
            "createdAt": "2025-08-22T14:51:07.232Z",
            "updatedAt": "2025-08-22T14:51:07.232Z",
            "userId": "058df35a-1bcd-443c-be30-46324b389f85",
            "projectId": "rJbk9aEmeIAmF2gQ",
            "user": {
              "createdAt": "2025-08-22T14:51:07.228Z",
              "updatedAt": "2025-10-08T22:05:16.000Z",
              "id": "058df35a-1bcd-443c-be30-46324b389f85",
              "email": "ali@bubuapp.ai",
              "firstName": "Ali",
              "lastName": "Issa",
              "personalizationAnswers": null,
              "settings": {
                "easyAIWorkflowOnboarded": true
              },
              "disabled": false,
              "mfaEnabled": false,
              "lastActiveAt": "2025-10-08",
              "isPending": false
            }
          }
        ]
      }
    }
  ],
  "tags": []
}