{
  "name": "Customer Service v4 - Raja Cutting Laser Human Fixed CRM Context",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "0f764a1b-6bcb-46ac-8e26-0efb8f391ce4",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "id": "833298f6-4c9e-481e-aad0-ad27cbafca6a",
      "name": "WA_WEBHOOK",
      "type": "n8n-nodes-base.webhook",
      "position": [
        -11136,
        5008
      ],
      "typeVersion": 2.1
    },
    {
      "parameters": {
        "jsCode": "const root = $input.first().json || {};\nconst raw = root.body || {};\nconst msg = raw.message || {};\n\nconst event = String(raw.event || 'message').toLowerCase();\nconst isFromMe = Boolean(raw.isFromMe ?? msg.isFromMe ?? false);\nconst isGroup = Boolean(raw.isGroup ?? msg.isGroup ?? false);\n\nconst incomingPhone = String(raw.fromPhone || raw.chatPhone || '');\nconst outgoingPhone = String(raw.chatPhone || raw.toPhone || incomingPhone || '');\nconst counterpartyPhone = isFromMe ? outgoingPhone : incomingPhone;\n\nconst incomingJid = String(raw.fromCanonical || raw.chatJid || raw.from || msg.from || '');\nconst outgoingJid = String(raw.chatJid || raw.toCanonical || raw.to || msg.to || '');\nconst sessionId = isFromMe ? (outgoingJid || outgoingPhone) : (incomingJid || incomingPhone);\n\nconst messageText = String(raw.messageText || msg.body || msg.caption || raw.caption || '');\nconst messageId = String(raw.messageId || msg.id || '');\nconst messageTimestamp = Number(raw.messageTimestamp || msg.timestamp || raw.unixTimestamp || Math.floor(Date.now() / 1000));\nconst messageType = String(raw.messageType || msg.type || raw.type || 'text').toLowerCase();\nconst mediaUrl = String(raw.mediaUrl || raw.imageUrl || raw.documentUrl || raw.videoUrl || raw.audioUrl || msg.mediaUrl || '');\nconst mediaMimeType = String(raw.mediaMimeType || raw.mimetype || msg.mediaMimeType || '');\nconst senderName = String(raw.senderName || msg.pushName || raw.chatName || '');\nconst hasAttachment = Boolean(\n  mediaUrl || raw.fileName || raw.mediaKey || mediaMimeType ||\n  ['image','document','video','audio','sticker'].includes(messageType)\n);\n\nconst fallbackId = `${sessionId}:${messageTimestamp}:${messageType}:${messageText.slice(0,80)}`;\nconst finalMessageId = messageId || fallbackId;\nconst shouldIgnore = event !== 'message' || isGroup || !sessionId || (!messageText && !hasAttachment);\n\nreturn [{ json: { wa: {\n  event,\n  session_id: sessionId,\n  counterparty_jid: sessionId,\n  counterparty_phone: counterpartyPhone,\n  from: incomingJid,\n  from_phone: counterpartyPhone,\n  sender_name: senderName,\n  message_id: finalMessageId,\n  message_timestamp: messageTimestamp,\n  message_type: messageType,\n  message_text: messageText,\n  media_url: mediaUrl,\n  media_mime_type: mediaMimeType,\n  has_attachment: hasAttachment,\n  is_from_me: isFromMe,\n  is_group: isGroup,\n  should_ignore: shouldIgnore,\n  tenant_id: String(raw.tenantId || ''),\n  tenant_session_id: String(raw.sessionId || ''),\n  chat_id: String(raw.chatId || raw.chat_id || ''),\n  chat_jid: String(raw.chatJid || raw.chat_jid || ''),\n  chat_name: String(raw.chatName || raw.chat_name || ''),\n  received_at: String(raw.receivedAt || new Date().toISOString())\n}} }];"
      },
      "id": "76c1a739-f1d9-477c-94a1-42404701b936",
      "name": "Normalize Incoming WA Payload",
      "type": "n8n-nodes-base.code",
      "position": [
        -10912,
        5008
      ],
      "typeVersion": 2
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "f74610cb-39b3-42f1-9622-c1433bb066ef",
              "leftValue": "={{ $json.wa.should_ignore }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "7944177f-379e-4bdd-94b2-e18d56606f68",
      "name": "Ignore Unsupported Event?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -10688,
        5008
      ]
    },
    {
      "parameters": {},
      "id": "aabd1aea-177a-44c0-9731-e68130faac77",
      "name": "Ignore Stop",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        -10464,
        4912
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "1c8c2ab8-98b4-4d24-955e-fc90acdf22aa",
              "leftValue": "={{ $json.wa.is_from_me }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "db10a8f5-85bc-4d72-89c1-434826807a34",
      "name": "Is From Me?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -10464,
        5104
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH ctx AS (\n  SELECT\n    '{{ ($json.wa.counterparty_phone || '').replace(/'/g, \"''\") }}' AS customer_phone,\n    '{{ ($json.wa.counterparty_jid || '').replace(/'/g, \"''\") }}' AS session_id,\n    '{{ ($json.wa.message_id || '').replace(/'/g, \"''\") }}' AS message_id,\n    '{{ ($json.wa.message_text || '').replace(/'/g, \"''\") }}' AS message_text\n),\nupsert_customer AS (\n  INSERT INTO customers (\n    phone,\n    last_message_at,\n    updated_at,\n    status\n  )\n  SELECT\n    ctx.customer_phone,\n    NOW(),\n    NOW(),\n    'active'\n  FROM ctx\n  WHERE ctx.customer_phone <> ''\n  ON CONFLICT (phone) DO UPDATE SET\n    last_message_at = NOW(),\n    updated_at = NOW()\n  RETURNING id, phone\n),\ncustomer_lookup AS (\n  SELECT id, phone FROM upsert_customer\n  UNION ALL\n  SELECT c.id, c.phone\n  FROM customers c\n  JOIN ctx ON c.phone = ctx.customer_phone\n  LIMIT 1\n),\nrecent_ai_echo AS (\n  SELECT 1 AS matched\n  FROM chat_history ch\n  JOIN customer_lookup cl ON ch.customer_id = cl.id\n  JOIN ctx ON true\n  WHERE ch.message_type = 'ai'\n    AND ch.content = ctx.message_text\n    AND ch.created_at > NOW() - INTERVAL '5 minutes'\n  ORDER BY ch.created_at DESC\n  LIMIT 1\n),\nexisting_open AS (\n  SELECT e.id\n  FROM escalations e\n  JOIN customer_lookup cl ON e.customer_id = cl.id\n  WHERE e.status = 'open'\n    AND e.expired_at > NOW()\n  ORDER BY e.created_at DESC\n  LIMIT 1\n),\nnew_escalation AS (\n  INSERT INTO escalations (\n    customer_id,\n    escalation_type,\n    escalation_reason,\n    chat_summary,\n    priority_level,\n    status,\n    expired_at,\n    snapshot_data,\n    created_at\n  )\n  SELECT\n    cl.id,\n    'manual_takeover',\n    'Admin/manual outgoing message takeover',\n    ctx.message_text,\n    'normal',\n    'open',\n    NOW() + INTERVAL '12 hours',\n    jsonb_build_object(\n      'takeover_source', 'outgoing_admin_message',\n      'session_id', ctx.session_id,\n      'from_phone', ctx.customer_phone\n    ),\n    NOW() - INTERVAL '1 second'\n  FROM customer_lookup cl\n  CROSS JOIN ctx\n  WHERE NOT EXISTS (SELECT 1 FROM recent_ai_echo)\n    AND NOT EXISTS (SELECT 1 FROM existing_open)\n  RETURNING id\n),\nresolved_escalation AS (\n  SELECT id FROM existing_open\n  UNION ALL\n  SELECT id FROM new_escalation\n  LIMIT 1\n),\nadmin_log AS (\n  INSERT INTO chat_history (\n    customer_id,\n    message_type,\n    content,\n    escalated,\n    message_id,\n    created_at\n  )\n  SELECT\n    cl.id,\n    'admin',\n    ctx.message_text,\n    true,\n    ctx.message_id,\n    NOW()\n  FROM customer_lookup cl\n  CROSS JOIN ctx\n  WHERE ctx.message_id <> ''\n    AND NOT EXISTS (SELECT 1 FROM recent_ai_echo)\n  ON CONFLICT (message_id) DO NOTHING\n  RETURNING customer_id\n)\nSELECT\n  COALESCE((SELECT matched FROM recent_ai_echo LIMIT 1), 0) AS ignored_ai_echo,\n  (SELECT id FROM customer_lookup LIMIT 1) AS customer_id,\n  (SELECT id FROM resolved_escalation LIMIT 1) AS escalation_id,\n  '{{ ($json.wa.counterparty_phone || '').replace(/'/g, \"''\") }}' AS from_phone,\n  '{{ ($json.wa.message_text || '').replace(/'/g, \"''\") }}' AS latest_message;",
        "options": {}
      },
      "id": "9f23ab23-7710-47f9-9fdd-713333623e8c",
      "name": "Process Outgoing Manual Takeover",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        -10240,
        5008
      ],
      "alwaysOutputData": true,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {},
      "id": "97b1a4df-7b04-4328-9d32-396d058f90c5",
      "name": "No Operation, outgoing stop",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        -10016,
        5008
      ]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "CREATE TABLE IF NOT EXISTS customers (\n  id BIGSERIAL PRIMARY KEY,\n  phone TEXT UNIQUE NOT NULL,\n  name TEXT,\n  location TEXT,\n  company TEXT,\n  product_type TEXT,\n  material TEXT,\n  thickness TEXT,\n  size TEXT,\n  has_image BOOLEAN DEFAULT FALSE,\n  is_owner BOOLEAN DEFAULT FALSE,\n  lead_status TEXT DEFAULT 'cold',\n  gclid TEXT,\n  gclid_captured_at TIMESTAMPTZ,\n  keyword_text TEXT,\n  utm_source TEXT,\n  utm_campaign TEXT,\n  status TEXT DEFAULT 'active',\n  last_message_at TIMESTAMPTZ,\n  created_at TIMESTAMPTZ DEFAULT NOW(),\n  updated_at TIMESTAMPTZ DEFAULT NOW()\n);\nALTER TABLE customers ADD COLUMN IF NOT EXISTS location TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS company TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS product_type TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS material TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS thickness TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS size TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS has_image BOOLEAN DEFAULT FALSE;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS is_owner BOOLEAN DEFAULT FALSE;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS lead_status TEXT DEFAULT 'cold';\nALTER TABLE customers ADD COLUMN IF NOT EXISTS gclid TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS gclid_captured_at TIMESTAMPTZ;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS keyword_text TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS utm_source TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS utm_campaign TEXT;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'active';\nALTER TABLE customers ADD COLUMN IF NOT EXISTS last_message_at TIMESTAMPTZ;\nALTER TABLE customers ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();\nALTER TABLE customers ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();\nCREATE UNIQUE INDEX IF NOT EXISTS idx_customers_phone_unique ON customers(phone);\n\nCREATE TABLE IF NOT EXISTS chat_history (\n  id BIGSERIAL PRIMARY KEY,\n  customer_id BIGINT REFERENCES customers(id) ON DELETE CASCADE,\n  message_type TEXT NOT NULL DEFAULT 'customer',\n  content TEXT NOT NULL DEFAULT '',\n  escalated BOOLEAN DEFAULT FALSE,\n  message_id TEXT UNIQUE,\n  created_at TIMESTAMPTZ DEFAULT NOW()\n);\nCREATE UNIQUE INDEX IF NOT EXISTS idx_chat_history_message_id_unique ON chat_history(message_id) WHERE message_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_chat_history_customer_created ON chat_history(customer_id, created_at DESC);\n\nCREATE TABLE IF NOT EXISTS escalations (\n  id BIGSERIAL PRIMARY KEY,\n  customer_id BIGINT REFERENCES customers(id) ON DELETE CASCADE,\n  escalation_type TEXT DEFAULT 'customer_service',\n  escalation_reason TEXT,\n  chat_summary TEXT,\n  priority_level TEXT DEFAULT 'normal',\n  status TEXT DEFAULT 'open',\n  expired_at TIMESTAMPTZ,\n  snapshot_data JSONB DEFAULT '{}'::jsonb,\n  created_at TIMESTAMPTZ DEFAULT NOW()\n);\nCREATE INDEX IF NOT EXISTS idx_escalations_customer_open ON escalations(customer_id, status, expired_at DESC);\n\nCREATE TABLE IF NOT EXISTS wa_debounce_sessions (\n  session_id TEXT PRIMARY KEY,\n  from_phone TEXT NOT NULL DEFAULT '',\n  sender_name TEXT NOT NULL DEFAULT '',\n  latest_message_id TEXT NOT NULL,\n  latest_message_timestamp BIGINT NOT NULL DEFAULT 0,\n  messages JSONB NOT NULL DEFAULT '[]'::jsonb,\n  version BIGINT NOT NULL DEFAULT 1,\n  due_at TIMESTAMPTZ NOT NULL,\n  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),\n  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()\n);\nCREATE INDEX IF NOT EXISTS idx_wa_debounce_due_at ON wa_debounce_sessions (due_at);\n\nCREATE TABLE IF NOT EXISTS wa_attribution_events (\n  id BIGSERIAL PRIMARY KEY,\n  event_type VARCHAR(20) DEFAULT 'click',\n  consultation_code VARCHAR(30),\n  gclid TEXT,\n  keyword_text VARCHAR(255),\n  utm_source VARCHAR(100),\n  utm_medium VARCHAR(100),\n  utm_campaign VARCHAR(255),\n  utm_term VARCHAR(255),\n  landing_page TEXT,\n  wbraid TEXT,\n  gbraid TEXT,\n  created_at TIMESTAMPTZ DEFAULT NOW()\n);\nSELECT 1 AS ready;",
        "options": {}
      },
      "id": "1cc67c48-ea0c-4f96-b7cd-640629f5b0ff",
      "name": "Ensure Runtime Tables",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        -10240,
        5200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH v AS (\n  SELECT\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.session_id || \"\").replace(/'/g, \"''\") }}' AS session_id,\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.from_phone || \"\").replace(/'/g, \"''\") }}' AS from_phone,\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.sender_name || \"\").replace(/'/g, \"''\") }}' AS sender_name,\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.message_id || \"\").replace(/'/g, \"''\") }}' AS message_id,\n    {{ Number($node[\"Normalize Incoming WA Payload\"].json.wa.message_timestamp || 0) }}::bigint AS message_timestamp,\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.message_type || \"text\").replace(/'/g, \"''\") }}' AS message_type,\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.message_text || \"\").replace(/'/g, \"''\") }}' AS message_text,\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.media_url || \"\").replace(/'/g, \"''\") }}' AS media_url,\n    '{{ ($node[\"Normalize Incoming WA Payload\"].json.wa.media_mime_type || \"\").replace(/'/g, \"''\") }}' AS media_mime_type,\n    {{ $node[\"Normalize Incoming WA Payload\"].json.wa.has_attachment === true ? \"true\" : \"false\" }}::boolean AS has_attachment\n),\nupserted AS (\n  INSERT INTO wa_debounce_sessions (\n    session_id,from_phone,sender_name,latest_message_id,latest_message_timestamp,\n    messages,version,due_at,created_at,updated_at\n  )\n  SELECT\n    session_id,from_phone,sender_name,message_id,message_timestamp,\n    jsonb_build_array(jsonb_build_object(\n      'message_id',message_id,\n      'message_timestamp',message_timestamp,\n      'message_type',message_type,\n      'message_text',message_text,\n      'media_url',media_url,\n      'media_mime_type',media_mime_type,\n      'has_attachment',has_attachment\n    )),\n    1,NOW() + INTERVAL '10 seconds',NOW(),NOW()\n  FROM v\n  WHERE session_id <> '' AND message_id <> ''\n  ON CONFLICT (session_id) DO UPDATE SET\n    from_phone = EXCLUDED.from_phone,\n    sender_name = COALESCE(NULLIF(EXCLUDED.sender_name,''),wa_debounce_sessions.sender_name),\n    latest_message_id = EXCLUDED.latest_message_id,\n    latest_message_timestamp = EXCLUDED.latest_message_timestamp,\n    messages = (\n      SELECT COALESCE(jsonb_agg(x.value ORDER BY x.ord),'[]'::jsonb)\n      FROM (\n        SELECT e.value,e.ord\n        FROM jsonb_array_elements(wa_debounce_sessions.messages || EXCLUDED.messages)\n             WITH ORDINALITY AS e(value,ord)\n        ORDER BY e.ord DESC\n        LIMIT 10\n      ) x\n    ),\n    version = wa_debounce_sessions.version + 1,\n    due_at = NOW() + INTERVAL '10 seconds',\n    updated_at = NOW()\n  WHERE wa_debounce_sessions.latest_message_id IS DISTINCT FROM EXCLUDED.latest_message_id\n  RETURNING session_id,latest_message_id AS message_id,version\n)\nSELECT session_id,message_id,version FROM upserted;",
        "options": {}
      },
      "id": "822c41b1-bd99-4580-bf37-bd619923162e",
      "name": "Upsert Debounce Session",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        -10016,
        5200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "amount": 11
      },
      "id": "d938f31e-f6e0-4d21-87a8-f67c8c3f9f0a",
      "name": "Wait 11 Seconds",
      "type": "n8n-nodes-base.wait",
      "position": [
        -9792,
        5200
      ],
      "typeVersion": 1.1
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH v AS (\n  SELECT\n    '{{ ($json.session_id || \"\").replace(/'/g, \"''\") }}' AS session_id,\n    '{{ ($json.message_id || \"\").replace(/'/g, \"''\") }}' AS message_id,\n    {{ Number($json.version || 0) }}::bigint AS version\n),\nclaimed AS (\n  DELETE FROM wa_debounce_sessions d\n  USING v\n  WHERE d.session_id = v.session_id\n    AND d.latest_message_id = v.message_id\n    AND d.version = v.version\n    AND d.due_at <= NOW()\n  RETURNING d.*\n)\nSELECT\n  session_id,from_phone,sender_name,latest_message_id AS batch_message_id,\n  latest_message_timestamp,messages\nFROM claimed;",
        "options": {}
      },
      "id": "ce7eb56a-9214-4e0e-b77c-c6116d1a0578",
      "name": "Claim Latest Debounce Batch",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        -9568,
        5200
      ],
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const j = $input.first().json || {};\nlet rows = j.messages || [];\nif (typeof rows === 'string') {\n  try { rows = JSON.parse(rows); } catch { rows = []; }\n}\nif (!Array.isArray(rows)) rows = [];\n\nrows.sort((a,b) => Number(a.message_timestamp || 0) - Number(b.message_timestamp || 0));\n\nconst formatted = rows.map(r => {\n  const type = String(r.message_type || 'text');\n  const text = String(r.message_text || '').trim();\n  const url = String(r.media_url || '').trim();\n  const hasFile = r.has_attachment === true || Boolean(url) || ['image','document','video','audio','sticker'].includes(type);\n  if (!hasFile) return text;\n  return `[FILE:${type}]${text ? ' ' + text : ''}${url ? ' URL=' + url : ''}`;\n}).filter(Boolean);\n\nconst fileMessages = formatted.filter(x => x.startsWith('[FILE:'));\nconst latest = formatted.at(-1) || '';\n\nreturn [{ json: {\n  session_id: String(j.session_id || ''),\n  from_phone: String(j.from_phone || ''),\n  sender_name: String(j.sender_name || ''),\n  batch_message_id: String(j.batch_message_id || ''),\n  latest_message: latest,\n  conversation_text: formatted.join('\\n---\\n'),\n  messages: formatted,\n  message_count: formatted.length,\n  has_file_context: fileMessages.length > 0,\n  file_context_summary: fileMessages.join('\\n---\\n')\n}}];"
      },
      "id": "a235c923-091c-46f0-9e02-d295f585dc59",
      "name": "Build Conversation Batch",
      "type": "n8n-nodes-base.code",
      "position": [
        -9344,
        5200
      ],
      "typeVersion": 2
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH incoming AS (\n  SELECT\n    '{{ ($json.session_id || '').replace(/'/g, \"''\") }}' AS session_id,\n    '{{ ($json.from_phone || '').replace(/'/g, \"''\") }}' AS from_phone,\n    '{{ ($json.sender_name || '').replace(/'/g, \"''\") }}' AS sender_name,\n    '{{ ($json.batch_message_id || '').replace(/'/g, \"''\") }}' AS batch_message_id,\n    '{{ ($json.latest_message || '').replace(/'/g, \"''\") }}' AS latest_message,\n    '{{ ($json.conversation_text || '').replace(/'/g, \"''\") }}' AS conversation_text,\n    {{ $json.has_file_context === true ? \"true\" : \"false\" }} AS has_file_context,\n    '{{ ($json.file_context_summary || '').replace(/'/g, \"''\") }}' AS file_context_summary,\n    '{{ ($json.media_type || '').replace(/'/g, \"''\") }}' AS media_type,\n    '{{ ($json.media_url || '').replace(/'/g, \"''\") }}' AS media_url\n)\nSELECT\n  e.id,\n  e.customer_id,\n  e.status,\n  e.expired_at,\n  e.escalation_type,\n  e.snapshot_data,\n  c.phone,\n  c.name,\n  i.session_id,\n  i.from_phone,\n  i.sender_name,\n  i.batch_message_id,\n  i.latest_message,\n  i.conversation_text,\n  i.has_file_context,\n  i.file_context_summary,\n  i.media_type,\n  i.media_url\nFROM incoming i\nLEFT JOIN customers c\n  ON c.phone = i.from_phone\nLEFT JOIN LATERAL (\n  SELECT e.*\n  FROM escalations e\n  WHERE e.customer_id = c.id\n    AND e.status = 'open'\n    AND e.expired_at > NOW()\n  ORDER BY e.created_at DESC\n  LIMIT 1\n) e ON true;",
        "options": {}
      },
      "id": "50d75153-86ce-4ce9-9646-fe3cbf12d78e",
      "name": "cek eskalasi",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -9120,
        5200
      ],
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "38b2c10d-cdd5-4493-a17e-eb35979412ac",
              "leftValue": "={{ Boolean($json.id && $json.expired_at && (Date.now() <= new Date($json.expired_at).getTime())) }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "97a320e9-3969-4c6e-bddd-82351919352c",
      "name": "Has Active Escalation?",
      "type": "n8n-nodes-base.if",
      "position": [
        -8896,
        5200
      ],
      "typeVersion": 2.2
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT \n  CASE \n    WHEN COUNT(ch.id) > 0 THEN 1\n    ELSE 0\n  END AS admin_has_replied,\n  {{ $json.id || 'NULL' }} AS escalation_id,\n  {{ $json.customer_id || 'NULL' }} AS customer_id,\n  '{{ ($json.name || '').replace(/'/g, \"''\") }}' AS customer_name,\n  '{{ ($json.session_id || '').replace(/'/g, \"''\") }}' AS session_id,\n  '{{ ($json.from_phone || '').replace(/'/g, \"''\") }}' AS from_phone,\n  '{{ ($json.latest_message || '').replace(/'/g, \"''\") }}' AS latest_message,\n  '{{ ($json.conversation_text || '').replace(/'/g, \"''\") }}' AS conversation_text\nFROM chat_history ch\nJOIN escalations e ON e.customer_id = ch.customer_id\nWHERE e.id = {{ $json.id }}\n  AND ch.message_type = 'admin'\n  AND ch.created_at >= e.created_at;",
        "options": {}
      },
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.6,
      "position": [
        -8672,
        5056
      ],
      "id": "5c785dbd-7eb6-45e4-aa79-b41c1d0b5ac9",
      "name": "Check Admin Reply",
      "alwaysOutputData": true,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "267b39d2-d3f3-4f60-8860-624b1f3d4471",
              "leftValue": "={{ $json.admin_has_replied }}",
              "rightValue": 0,
              "operator": {
                "type": "number",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -8448,
        5056
      ],
      "id": "f82cfbc1-3f19-40cd-9f6b-6660d48c9117",
      "name": "Admin Has Not Replied?"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://postgres-customerservicecrm.qk6yxt.easypanel.host/api/v1/n8n/send-message",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Tenant-Key",
              "value": "={{ $env.CS_TENANT_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phone_number",
              "value": "120363421578507033@g.us"
            },
            {
              "name": "message_text",
              "value": "=\u23f0 *REMINDER ESKALASI*\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\n\ud83d\udc64 *CUSTOMER INFO*\nNama: {{ $json.customer_name || 'Belum terdaftar' }}\nWA: wa.me/{{ ($json.from_phone || '').replace(/\\D/g, '') }}\n\n\ud83d\udcac *PESAN TERAKHIR*\n{{ $json.latest_message || '-' }}\n\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n\u26a0\ufe0f Customer chat lagi tapi admin belum reply!\n\ud83d\udd34 ACTION: Segera hubungi customer!\n\u23f0 {{ $now.toFormat('dd MMM yyyy HH:mm') }} WIB\n"
            }
          ]
        },
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        -7664,
        4960
      ],
      "id": "2c3bc14e-a93e-48ca-b3ee-c9017857de09",
      "name": "reminder grup1"
    },
    {
      "parameters": {},
      "id": "cb7ef341-494e-4364-a024-5f389af4c46b",
      "name": "No Operation, do nothing",
      "type": "n8n-nodes-base.noOp",
      "position": [
        -7664,
        5152
      ],
      "typeVersion": 1
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH incoming AS (\n  SELECT\n    '{{ ($json.session_id || '').replace(/'/g, \"''\") }}' AS session_id,\n    '{{ ($json.from_phone || '').replace(/'/g, \"''\") }}' AS from_phone,\n    '{{ ($json.sender_name || '').replace(/'/g, \"''\") }}' AS sender_name,\n    '{{ ($json.batch_message_id || '').replace(/'/g, \"''\") }}' AS batch_message_id,\n    '{{ ($json.latest_message || '').replace(/'/g, \"''\") }}' AS latest_message,\n    '{{ ($json.conversation_text || '').replace(/'/g, \"''\") }}' AS conversation_text,\n    {{ $json.has_file_context === true ? \"true\" : \"false\" }} AS has_file_context,\n    '{{ ($json.file_context_summary || '').replace(/'/g, \"''\") }}' AS file_context_summary\n)\nSELECT\n  c.id AS customer_id,\n  c.name,\n  c.location,\n  c.company,\n  c.product_type,\n  c.material,\n  c.thickness,\n  c.size,\n  c.has_image,\n  c.is_owner,\n  c.lead_status,\n  c.gclid,\n  i.session_id,i.from_phone,i.sender_name,i.batch_message_id,\n  i.latest_message,i.conversation_text,i.has_file_context,i.file_context_summary,\n  COALESCE((\n    SELECT json_agg(json_build_object(\n      'type',h.message_type,\n      'content',LEFT(h.content,1500),\n      'time',h.created_at\n    ) ORDER BY h.created_at ASC)\n    FROM (\n      SELECT message_type,content,created_at\n      FROM chat_history\n      WHERE customer_id = c.id\n      ORDER BY created_at DESC\n      LIMIT 8\n    ) h\n  ),'[]'::json) AS recent_history\nFROM incoming i\nLEFT JOIN customers c ON c.phone = i.from_phone\nLIMIT 1;",
        "options": {}
      },
      "id": "0e13e5fc-dcf2-4dbe-9c0f-aba6ff8bbf6b",
      "name": "Load Customer Profile1",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -8672,
        5456
      ],
      "typeVersion": 2.6,
      "alwaysOutputData": true,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const p = $input.first().json || {};\nlet history = p.recent_history || [];\nif (typeof history === 'string') {\n  try { history = JSON.parse(history); } catch { history = []; }\n}\nif (!Array.isArray(history)) history = [];\n\nconst latestMessage = String(p.latest_message || '');\nconst conversationText = String(p.conversation_text || latestMessage);\nconst historyText = history.slice(-8)\n  .map(h => `[${String(h.type || 'unknown').toUpperCase()}] ${String(h.content || '')}`)\n  .join('\\n');\nconst profileExists = Boolean(p.name);\n\nlet customerContext = profileExists\n  ? `RETURNING CUSTOMER\\nNama: ${p.name}\\nLokasi: ${p.location || 'belum diketahui'}\\nPerusahaan: ${p.company || 'belum diketahui'}\\nProduk: ${p.product_type || '-'}\\nMaterial: ${p.material || '-'}\\nKetebalan: ${p.thickness || '-'}\\nUkuran: ${p.size || '-'}`\n  : `NEW CUSTOMER\\nNama, lokasi, perusahaan, dan kebutuhan belum tervalidasi.`;\n\nif (p.has_file_context && p.file_context_summary) {\n  customerContext += `\\n\\nKONTEKS FILE:\\n${p.file_context_summary}`;\n}\n\nreturn [{ json: {\n  profile_exists: profileExists,\n  customer_context: customerContext,\n  recent_history_text: historyText || '(belum ada riwayat)',\n  customer_id: p.customer_id || null,\n  customer_name: p.name || '',\n  customer_location: p.location || '',\n  customer_company: p.company || '',\n  customer_product: p.product_type || '',\n  customer_material: p.material || '',\n  customer_thickness: p.thickness || '',\n  customer_size: p.size || '',\n  customer_is_owner: p.is_owner === true,\n  customer_lead_status: p.lead_status || 'cold',\n  customer_gclid: p.gclid || null,\n  session_id: p.session_id || '',\n  from_phone: p.from_phone || '',\n  sender_name: p.sender_name || '',\n  batch_message_id: p.batch_message_id || '',\n  latest_message: latestMessage,\n  conversation_text: conversationText,\n  has_file_context: p.has_file_context === true,\n  file_context_summary: p.file_context_summary || ''\n}}];"
      },
      "id": "aa3ab350-6db7-428e-99c8-6443f90d18e6",
      "name": "Normalize Profile1",
      "type": "n8n-nodes-base.code",
      "position": [
        -8448,
        5456
      ],
      "typeVersion": 2
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "={{ $json.conversation_text || $json.latest_message || '' }}",
        "options": {
          "systemMessage": "=Kamu adalah Customer Service AI Raja Cutting Laser / Raja Metal Cutting.\nBisnis: jasa laser cutting metal, CNC router/non-metal, engraving, signage, huruf timbul, panel/fasad, pagar/railing/partisi, cutting plat, finishing, dan pekerjaan custom.\n\nKONTEKS CUSTOMER:\n{{ $json.customer_context }}\n\nRIWAYAT TERAKHIR / CUSTOMER SERVICE CRM:\n{{ $json.recent_history_text }}\n\nGAYA BALASAN HUMAN-LIKE:\n- Jika riwayat CRM menunjukkan admin sudah menjawab/menentukan konteks, ikuti konteks admin itu. Jangan bertanya ulang hal yang sudah ada di riwayat.\n- Jika customer melanjutkan chat lama dan konteks sudah jelas dari riwayat CRM, jawab sebagai resepsionis yang nyambung, bukan seperti customer baru.\n- Jawab seperti admin WhatsApp manusia: ramah, singkat, natural, tidak kaku, tidak terlalu formal.\n- Panggil customer dengan Kak/Pak/Bu bila cocok dari konteks. Jangan berlebihan emoji; maksimal 1 emoji jika membantu.\n- Jangan mengulang data yang customer sudah berikan.\n- Maksimal 1 pertanyaan utama per balasan, kecuali customer memang minta detail teknis.\n- Kalau data order belum lengkap, tanyakan data paling penting berikutnya saja.\n- Kalau customer kirim gambar/file, akui sudah diterima dan minta admin/estimasi cek file bila perlu.\n- Jangan mengarang harga final, stok bahan, lead time pasti, ongkir, atau janji produksi.\n- Untuk harga final/analisa file/komplain/urgent/nego/ketidakjelasan teknis, eskalasi ke admin.\n- Jika customer hanya salam/bertanya umum, jawab ringan lalu arahkan ke kebutuhan: bahan, tebal, ukuran, jumlah, dan file/sketsa.\n\nROUTING KNOWLEDGE TOOL:\n- Gunakan knowledge tool hanya bila perlu. Maksimal 2 tool per respons.\n- Bila knowledge menyebut brand lain seperti Tepat Laser, gunakan isinya sebagai referensi operasional saja; jawaban ke customer tetap atas nama Raja Cutting Laser.\n- File Requirements untuk format file, gambar, DXF/DWG/AI/CDR/PDF.\n- Material Pricing untuk pertanyaan harga/perhitungan awal, tapi tetap jangan memberi harga final tanpa data lengkap.\n- Lead Scoring & Escalation untuk cold/warm/hot/not_lead dan eskalasi.\n\nATURAN DATA:\n- Pertahankan data profil yang sudah ada.\n- Data inti: nama, lokasi, perusahaan bila relevan, produk, material, ketebalan, ukuran, jumlah, dan file.\n- Karena skema tidak punya field quantity/finishing/deadline, masukkan info itu secara natural pada text atau reason bila penting.\n- has_file=true bila customer sudah mengirim atau menyatakan punya file/gambar.\n- lead_status hanya cold, warm, hot, atau not_lead.\n- trigger_conversion=true hanya bila lead_status=hot dan tersedia gclid.\n- reason wajib singkat, konkret, dan tidak boleh kosong.\n\nOUTPUT WAJIB:\nKeluarkan SATU objek JSON valid tanpa markdown, tanpa backtick, dan tanpa teks tambahan.\nObjek harus tepat memiliki 15 field berikut:\n{\n  \"text\": \"balasan WhatsApp yang natural dan siap dikirim ke customer\",\n  \"escalate\": false,\n  \"customer_name\": \"\",\n  \"location\": \"\",\n  \"company\": \"\",\n  \"product\": \"\",\n  \"material\": \"\",\n  \"thickness\": \"\",\n  \"size\": \"\",\n  \"has_file\": false,\n  \"is_owner\": false,\n  \"lead_status\": \"cold\",\n  \"gclid\": null,\n  \"trigger_conversion\": false,\n  \"reason\": \"alasan singkat lead scoring atau eskalasi\"\n}",
          "maxIterations": 4
        }
      },
      "id": "d76ca94c-da1c-48c1-9495-786b8acf9d73",
      "name": "AI customer service",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        -7728,
        5456
      ],
      "typeVersion": 3
    },
    {
      "parameters": {
        "model": "z-ai/glm-4.7-flash",
        "options": {
          "frequencyPenalty": 0,
          "presencePenalty": 0.15,
          "temperature": 0.35,
          "topP": 1
        }
      },
      "id": "a24a0637-e12a-4172-be4c-d473488b82d9",
      "name": "OpenRouter Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
      "position": [
        -8224,
        5680
      ],
      "typeVersion": 1,
      "credentials": {
        "openRouterApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const raw = String($json.output || '').trim();\nconst ctx = $('Normalize Profile1').first().json || {};\nfunction parseJson(text) {\n  const cleaned = text.replace(/^```json\\s*/i,'').replace(/^```\\s*/i,'').replace(/\\s*```$/i,'').trim();\n  try { return JSON.parse(cleaned); } catch {}\n  const s = cleaned.indexOf('{'); const e = cleaned.lastIndexOf('}');\n  if (s >= 0 && e > s) { try { return JSON.parse(cleaned.slice(s,e+1)); } catch {} }\n  return null;\n}\nconst p = parseJson(raw);\nconst parseError = !p || typeof p !== 'object';\nconst x = parseError ? {} : p;\nconst validLead = new Set(['cold','warm','hot','not_lead']);\nconst gclidMatch = String(ctx.conversation_text || '').match(/(?:\\[gclid:|kode konsultasi\\s*:\\s*)([^\\]\\s]+)/i);\nconst gclid = x.gclid || ctx.customer_gclid || (gclidMatch ? gclidMatch[1] : null);\nreturn [{ json: {\n  session_id: ctx.session_id || '',\n  from_phone: ctx.from_phone || '',\n  sender_name: ctx.sender_name || '',\n  batch_message_id: ctx.batch_message_id || '',\n  latest_message: ctx.latest_message || '',\n  conversation_text: ctx.conversation_text || ctx.latest_message || '',\n  text: String(x.text || (parseError ? 'Maaf Kak, ada gangguan teknis. Pesan Kakak saya teruskan ke admin.' : 'Baik Kak, pesan sudah kami terima.')).slice(0,3500),\n  escalate: parseError ? true : x.escalate === true,\n  customer_name: String(x.customer_name || ctx.customer_name || ctx.sender_name || ''),\n  location: String(x.location || ctx.customer_location || ''),\n  company: String(x.company || ctx.customer_company || ''),\n  product: String(x.product || ctx.customer_product || ''),\n  material: String(x.material || ctx.customer_material || ''),\n  thickness: String(x.thickness || ctx.customer_thickness || ''),\n  size: String(x.size || ctx.customer_size || ''),\n  has_file: x.has_file === true || ctx.has_file_context === true,\n  is_owner: x.is_owner === true || ctx.customer_is_owner === true,\n  lead_status: validLead.has(String(x.lead_status || '')) ? String(x.lead_status) : String(ctx.customer_lead_status || 'cold'),\n  gclid,\n  trigger_conversion: x.trigger_conversion === true && Boolean(gclid),\n  reason: String(x.reason || (parseError ? 'AI output parse error' : '')),\n  parse_error: parseError\n}}];"
      },
      "id": "bd0dfc78-27c0-4dc2-a6e3-271eff9c9f94",
      "name": "Parse AI Output",
      "type": "n8n-nodes-base.code",
      "position": [
        -6864,
        5456
      ],
      "typeVersion": 2
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH v AS (\n  SELECT\n    NULLIF('{{ ($json.from_phone || \"\").replace(/'/g, \"''\") }}','') AS phone,\n    NULLIF('{{ ($json.customer_name || \"\").replace(/'/g, \"''\") }}','') AS name,\n    NULLIF('{{ ($json.location || \"\").replace(/'/g, \"''\") }}','') AS location,\n    NULLIF('{{ ($json.company || \"\").replace(/'/g, \"''\") }}','') AS company,\n    NULLIF('{{ ($json.product || \"\").replace(/'/g, \"''\") }}','') AS product_type,\n    NULLIF('{{ ($json.material || \"\").replace(/'/g, \"''\") }}','') AS material,\n    NULLIF('{{ ($json.thickness || \"\").replace(/'/g, \"''\") }}','') AS thickness,\n    NULLIF('{{ ($json.size || \"\").replace(/'/g, \"''\") }}','') AS size,\n    {{ $json.has_file === true ? \"true\" : \"false\" }}::boolean AS has_image,\n    {{ $json.is_owner === true ? \"true\" : \"false\" }}::boolean AS is_owner,\n    CASE WHEN '{{ ($json.lead_status || \"cold\").replace(/'/g, \"''\") }}' IN ('hot','warm','cold','not_lead') THEN '{{ ($json.lead_status || \"cold\").replace(/'/g, \"''\") }}' ELSE 'cold' END AS lead_status,\n    NULLIF('{{ ($json.gclid || \"\").replace(/'/g, \"''\") }}','') AS gclid,\n    NULLIF('{{ ($json.batch_message_id || \"\").replace(/'/g, \"''\") }}','') AS batch_message_id,\n    '{{ ($json.conversation_text || \"\").replace(/'/g, \"''\") }}' AS conversation_text,\n    '{{ ($json.session_id || \"\").replace(/'/g, \"''\") }}' AS session_id,\n    '{{ ($json.sender_name || \"\").replace(/'/g, \"''\") }}' AS sender_name,\n    '{{ ($json.latest_message || \"\").replace(/'/g, \"''\") }}' AS latest_message,\n    '{{ ($json.text || \"\").replace(/'/g, \"''\") }}' AS text,\n    {{ $json.escalate === true ? \"true\" : \"false\" }}::boolean AS escalate,\n    '{{ ($json.reason || \"\").replace(/'/g, \"''\") }}' AS reason,\n    {{ $json.trigger_conversion === true ? \"true\" : \"false\" }}::boolean AS trigger_conversion\n),\nattr AS (\n  SELECT ae.gclid,ae.keyword_text,ae.utm_source,ae.utm_campaign\n  FROM wa_attribution_events ae,v\n  WHERE ae.consultation_code = (\n    SELECT (regexp_match(v.conversation_text,'kode konsultasi\\s*:\\s*([A-Z0-9-]+)','i'))[1]\n  )\n  ORDER BY ae.created_at DESC LIMIT 1\n),\nupserted AS (\n  INSERT INTO customers (\n    phone,name,location,company,product_type,material,thickness,size,has_image,is_owner,\n    lead_status,gclid,gclid_captured_at,keyword_text,utm_source,utm_campaign,last_message_at,updated_at,status\n  )\n  SELECT\n    v.phone,v.name,v.location,v.company,v.product_type,v.material,v.thickness,v.size,v.has_image,v.is_owner,\n    v.lead_status,COALESCE(v.gclid,a.gclid),\n    CASE WHEN COALESCE(v.gclid,a.gclid) IS NOT NULL THEN NOW() ELSE NULL END,\n    a.keyword_text,a.utm_source,a.utm_campaign,NOW(),NOW(),'active'\n  FROM v LEFT JOIN attr a ON TRUE WHERE v.phone IS NOT NULL\n  ON CONFLICT (phone) DO UPDATE SET\n    name=COALESCE(EXCLUDED.name,customers.name),location=COALESCE(EXCLUDED.location,customers.location),\n    company=COALESCE(EXCLUDED.company,customers.company),product_type=COALESCE(EXCLUDED.product_type,customers.product_type),\n    material=COALESCE(EXCLUDED.material,customers.material),thickness=COALESCE(EXCLUDED.thickness,customers.thickness),\n    size=COALESCE(EXCLUDED.size,customers.size),has_image=COALESCE(customers.has_image,FALSE) OR EXCLUDED.has_image,\n    is_owner=COALESCE(customers.is_owner,FALSE) OR EXCLUDED.is_owner,lead_status=EXCLUDED.lead_status,\n    gclid=COALESCE(customers.gclid,EXCLUDED.gclid),\n    gclid_captured_at=CASE WHEN customers.gclid IS NULL AND EXCLUDED.gclid IS NOT NULL THEN NOW() ELSE customers.gclid_captured_at END,\n    keyword_text=COALESCE(EXCLUDED.keyword_text,customers.keyword_text),utm_source=COALESCE(EXCLUDED.utm_source,customers.utm_source),\n    utm_campaign=COALESCE(EXCLUDED.utm_campaign,customers.utm_campaign),last_message_at=NOW(),updated_at=NOW(),status='active'\n  RETURNING id\n),\nlog_customer AS (\n  INSERT INTO chat_history (customer_id,message_type,content,escalated,message_id,created_at)\n  SELECT u.id,'customer',v.conversation_text,FALSE,'customer_' || md5(COALESCE(v.phone,'') || COALESCE(v.batch_message_id,'')),NOW()\n  FROM upserted u CROSS JOIN v WHERE v.conversation_text <> ''\n  ON CONFLICT (message_id) DO NOTHING\n)\nSELECT u.id AS customer_id,v.session_id,v.phone AS from_phone,\n       v.sender_name,v.batch_message_id,v.latest_message,v.conversation_text,\n       v.text,v.escalate,v.name AS customer_name,v.location,v.company,\n       v.product_type AS product,v.material,v.thickness,v.size,\n       v.has_image AS has_file,v.is_owner,v.lead_status,COALESCE(u2.gclid,v.gclid) AS gclid,\n       v.trigger_conversion,v.reason\nFROM upserted u CROSS JOIN v LEFT JOIN customers u2 ON u2.id=u.id;",
        "options": {}
      },
      "id": "3a637b77-07a9-4740-b36e-ce5da5b2aad1",
      "name": "save customer data",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -6640,
        5456
      ],
      "typeVersion": 2.6,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH v AS (\n  SELECT\n    NULLIF('{{ ($json.customer_id || \"\").toString().replace(/'/g, \"''\") }}','')::bigint AS customer_id,\n    '{{ ($json.session_id || \"\").replace(/'/g, \"''\") }}' AS session_id,\n    '{{ ($json.from_phone || \"\").replace(/'/g, \"''\") }}' AS from_phone,\n    '{{ ($json.sender_name || \"\").replace(/'/g, \"''\") }}' AS sender_name,\n    '{{ ($json.batch_message_id || \"\").replace(/'/g, \"''\") }}' AS batch_message_id,\n    '{{ ($json.latest_message || \"\").replace(/'/g, \"''\") }}' AS latest_message,\n    '{{ ($json.conversation_text || \"\").replace(/'/g, \"''\") }}' AS conversation_text,\n    '{{ ($json.text || \"\").replace(/'/g, \"''\") }}' AS text,\n    {{ $json.escalate === true ? \"true\" : \"false\" }}::boolean AS escalate,\n    '{{ ($json.customer_name || \"\").replace(/'/g, \"''\") }}' AS customer_name,\n    '{{ ($json.location || \"\").replace(/'/g, \"''\") }}' AS location,\n    '{{ ($json.company || \"\").replace(/'/g, \"''\") }}' AS company,\n    '{{ ($json.product || \"\").replace(/'/g, \"''\") }}' AS product,\n    '{{ ($json.material || \"\").replace(/'/g, \"''\") }}' AS material,\n    '{{ ($json.thickness || \"\").replace(/'/g, \"''\") }}' AS thickness,\n    '{{ ($json.size || \"\").replace(/'/g, \"''\") }}' AS size,\n    {{ $json.has_file === true ? \"true\" : \"false\" }}::boolean AS has_file,\n    {{ $json.is_owner === true ? \"true\" : \"false\" }}::boolean AS is_owner,\n    '{{ ($json.lead_status || \"cold\").replace(/'/g, \"''\") }}' AS lead_status,\n    NULLIF('{{ ($json.gclid || \"\").replace(/'/g, \"''\") }}','') AS gclid,\n    {{ $json.trigger_conversion === true ? \"true\" : \"false\" }}::boolean AS trigger_conversion,\n    '{{ ($json.reason || \"\").replace(/'/g, \"''\") }}' AS reason\n),\nins AS (\n  INSERT INTO chat_history (customer_id,message_type,content,escalated,message_id,created_at)\n  SELECT customer_id,'ai',COALESCE(text,''),COALESCE(escalate,FALSE),\n         'ai_' || md5(COALESCE(from_phone,'') || COALESCE(batch_message_id,'')),NOW()\n  FROM v WHERE customer_id IS NOT NULL AND COALESCE(text,'') <> ''\n  ON CONFLICT (message_id) DO NOTHING\n)\nSELECT * FROM v;",
        "options": {}
      },
      "id": "ea9d835a-b58d-45c4-b41f-95eddeb79af2",
      "name": "log ai response",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -6416,
        5456
      ],
      "typeVersion": 2.6,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "cond2",
              "leftValue": "={{ $json.escalate }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "908e7577-474d-46dd-b181-a5a016c2c444",
      "name": "apakah tereskalasi atau tidak",
      "type": "n8n-nodes-base.if",
      "position": [
        -6192,
        5456
      ],
      "typeVersion": 2.2
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "WITH customer_lookup AS (\n  SELECT id,name,location,company,product_type,material,thickness,size,has_image\n  FROM customers WHERE phone = '{{ ($json.from_phone || '').replace(/'/g, \"''\") }}' LIMIT 1\n)\nINSERT INTO escalations (\n  customer_id,escalation_type,escalation_reason,chat_summary,priority_level,status,expired_at,snapshot_data,created_at\n)\nSELECT\n  cl.id,'customer_service',COALESCE(NULLIF('{{ ($json.reason || '').replace(/'/g, \"''\") }}',''),'Customer needs human support'),\n  '{{ ($json.conversation_text || '').replace(/'/g, \"''\") }}','normal','open',NOW()+INTERVAL '30 minutes',\n  jsonb_build_object('product',COALESCE(NULLIF('{{ ($json.product || '').replace(/'/g, \"''\") }}',''),cl.product_type),\n  'material',COALESCE(NULLIF('{{ ($json.material || '').replace(/'/g, \"''\") }}',''),cl.material),\n  'thickness',COALESCE(NULLIF('{{ ($json.thickness || '').replace(/'/g, \"''\") }}',''),cl.thickness),\n  'size',COALESCE(NULLIF('{{ ($json.size || '').replace(/'/g, \"''\") }}',''),cl.size),\n  'has_file',{{ $json.has_file === true ? 'true':'false' }},'company',cl.company,'name',cl.name,'location',cl.location),NOW()\nFROM customer_lookup cl\nWHERE NOT EXISTS (\n  SELECT 1 FROM escalations e WHERE e.customer_id=cl.id AND e.status='open' AND e.expired_at>NOW()\n)\nRETURNING id,expired_at;",
        "options": {}
      },
      "id": "16aabcb9-3e38-4a80-bd65-3aa3b4db59f9",
      "name": "catat eskalasi",
      "type": "n8n-nodes-base.postgres",
      "position": [
        -5968,
        5360
      ],
      "typeVersion": 2.6,
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://postgres-customerservicecrm.qk6yxt.easypanel.host/api/v1/n8n/send-message",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Tenant-Key",
              "value": "={{ $env.CS_TENANT_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phone_number",
              "value": "120363421578507033@g.us"
            },
            {
              "name": "message_text",
              "value": "=\ud83d\udea8 *CUSTOMER BUTUH BANTUAN ADMIN*\n\n\ud83d\udcdd *Detail Customer:*\n\u2022 Nama: {{ $('Parse AI Output').item.json.customer_name || 'Belum disebutkan' }}\n\u2022 Lokasi: {{ $('Parse AI Output').item.json.location || 'Belum disebutkan' }}\n\u2022 Perusahaan: {{ $('Parse AI Output').item.json.company || 'Perorangan' }}\n\n\ud83d\udd27 *Kebutuhan:*\n\u2022 Produk: {{ $('Parse AI Output').item.json.product || 'Belum disebutkan' }}\n\u2022 Bahan: {{ $('Parse AI Output').item.json.material || 'Belum disebutkan' }}\n\u2022 Ketebalan: {{ $('Parse AI Output').item.json.thickness || 'Belum tahu' }}\n\u2022 Ukuran: {{ $('Parse AI Output').item.json.size || 'Belum tahu' }}\n\u2022 File: {{ $('Parse AI Output').item.json.has_file ? 'Sudah ada \u2705' : 'Belum ada \u274c' }}\n\n\ud83d\udc64 *Kontak:*\n\u2022 WA: wa.me/{{ ($('Parse AI Output').item.json.from_phone || '').replace(/\\D/g, '') }}\n\n\ud83d\udcac *Chat History:*\n{{ $('Parse AI Output').item.json.conversation_text || $('Parse AI Output').item.json.latest_message || '-' }}\n\n\u23f0 *Waktu:* {{ $now.toFormat('dd MMM yyyy HH:mm') }} WIB\n\nSilakan hubungi customer untuk konfirmasi."
            }
          ]
        },
        "options": {}
      },
      "id": "ce60d566-7f5c-4f13-8b9e-5133ba7f75e2",
      "name": "notif grup1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -5744,
        5264
      ],
      "typeVersion": 4.3
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://postgres-customerservicecrm.qk6yxt.easypanel.host/api/v1/n8n/send-message",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Tenant-Key",
              "value": "={{ $env.CS_TENANT_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phone_number",
              "value": "={{ $('Parse AI Output').item.json.from_phone }}"
            },
            {
              "name": "message_text",
              "value": "={{ $('Parse AI Output').item.json.text }}"
            }
          ]
        },
        "options": {}
      },
      "id": "7fc5b4db-71d7-44c4-84fc-d9f62a9dc529",
      "name": "kirim-wa-eskalasi",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -5744,
        5456
      ],
      "typeVersion": 4.3
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://postgres-customerservicecrm.qk6yxt.easypanel.host/api/v1/n8n/send-message",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Tenant-Key",
              "value": "={{ $env.CS_TENANT_KEY }}"
            }
          ]
        },
        "sendBody": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "phone_number",
              "value": "={{ $('Parse AI Output').item.json.from_phone }}"
            },
            {
              "name": "message_text",
              "value": "={{ $('Parse AI Output').item.json.text }}"
            }
          ]
        },
        "options": {}
      },
      "id": "4f6057f8-43b4-41f1-9d16-ecba1e7f46c4",
      "name": "kirim-wa-pesan-biasa1",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -5744,
        5648
      ],
      "typeVersion": 4.3
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "89f16dd6-dff2-4ea7-a5f6-bb9db488ce2f",
              "leftValue": "={{ $('Parse AI Output').item.json.trigger_conversion }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "8c34d128-a97d-46f9-8249-bf0f8fb60115",
              "leftValue": "={{ $('Parse AI Output').item.json.gclid }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "8aa87b9b-fc41-4784-a44b-e44e02008fd1",
      "name": "Check: Lead Hot + Ada GCLID?",
      "type": "n8n-nodes-base.if",
      "position": [
        -5520,
        5552
      ],
      "typeVersion": 2.3
    },
    {
      "parameters": {
        "method": "POST",
        "url": "=https://projek-n8n-n8n.qk6yxt.easypanel.host/webhook/73c30570-b916-4004-b6b5-5b9dc40acb16",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"gclid\": \"{{ $('Parse AI Output').item.json.gclid }}\",\n  \"lead_id\": \"{{ $('Parse AI Output').item.json.from_phone }}\",\n  \"lead_status\": \"{{ $('Parse AI Output').item.json.lead_status }}\",\n  \"conversion_value\": 0\n}",
        "options": {}
      },
      "id": "a3733b31-99cd-4d1f-b456-cc6c763ba4fd",
      "name": "Trigger: GCLID Offline Conversion",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        -5296,
        5552
      ],
      "typeVersion": 4.4
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan hanya saat ragu tentang scope bisnis Raja Cutting Laser, aturan output JSON 15 field, gaya komunikasi, atau batas perilaku AI.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/01-skill-core-contract.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "0303e5d1-5c22-438f-8c75-98b44eb3c373",
      "name": "Skill - Core Contract",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -8096,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan untuk menentukan alur percakapan adaptif, data order yang belum lengkap, dan pertanyaan berikutnya tanpa mengulang data customer.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/02-skill-flow-escalation.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "898dda8a-0900-4c97-99f7-958ac3888820",
      "name": "Skill - Conversation Flow",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7968,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan untuk menentukan cold, warm, hot, not_lead, trigger_conversion, dan kapan percakapan harus dieskalasi ke admin.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/04-skill-lead-scoring-escalation.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "6b04364d-0602-4892-8d98-1bfc341f3ca8",
      "name": "Skill - Lead Scoring & Escalation",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7840,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan untuk pertanyaan tentang mesin, material yang dapat diproses, ukuran kerja, presisi, layanan tambahan, dan batas kemampuan teknis.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/05-skill-business-capabilities.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "b339af18-a5f9-4722-b503-2c060220bd4f",
      "name": "Skill - Business Capabilities",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7712,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan untuk harga, biaya, material, ketebalan, kemampuan cutting, referensi bahan, dan dasar perhitungan quotation awal.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/03-skill-material-pricing.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "4354683a-865a-4cfd-9ed3-8cc7d5a41d90",
      "name": "Skill - Material Pricing",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7584,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan untuk signage, huruf timbul, facade, partisi, pagar, railing, booth, panel dekoratif, engraving, dan komponen custom.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/06-skill-product-knowledge.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "8640ace0-2f24-4607-bcbf-7022e5ea756f",
      "name": "Skill - Product Knowledge",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7456,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan untuk durasi produksi, antrean, finishing, tahapan order, pembayaran, revisi, pengiriman, dan instalasi.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/07-skill-production-lead-time.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "f7c1b2bf-45e8-4aa9-8b10-3c6ad4814bd8",
      "name": "Skill - Production & Lead Time",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7328,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan untuk FAQ, diskon, order satuan, bahan customer, ditunggu, hasil potong, desain, pengiriman, instalasi, dan keberatan harga.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/08-skill-faq-objection-handling.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "e5202fb2-e0f5-46ec-8aac-c3b8dec2f3f8",
      "name": "Skill - FAQ & Objection Handling",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7200,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Gunakan saat customer mengirim attachment atau bertanya format file, vector, DXF, DWG, AI, CDR, PDF, gambar, sketsa, atau revisi file.",
        "resource": "file",
        "operation": "get",
        "owner": {
          "__rl": true,
          "value": "Ddos-spec",
          "mode": "list"
        },
        "repository": {
          "__rl": true,
          "value": "skillcs",
          "mode": "list"
        },
        "filePath": "tepatlaser/09-skill-file-requirements.md",
        "asBinaryProperty": false,
        "additionalParameters": {}
      },
      "id": "7fcfea94-6a56-4a5f-9a43-3f097c152116",
      "name": "Skill - File Requirements",
      "type": "n8n-nodes-base.githubTool",
      "position": [
        -7072,
        5680
      ],
      "typeVersion": 1.1,
      "credentials": {
        "githubApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://postgres-customerservicecrm.qk6yxt.easypanel.host/api/v1/external/dashboard/chats/' + encodeURIComponent($('Normalize Incoming WA Payload').item.json.wa.chat_id || '') + '/messages?limit=20' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Tenant-Key",
              "value": "={{ $env.CS_TENANT_KEY }}"
            }
          ]
        },
        "options": {
          "timeout": 10000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        384,
        4624
      ],
      "id": "3bca8281-9ad1-4e16-9b46-47ab8d3b18c1",
      "name": "Load CRM Recent Messages",
      "continueOnFail": true
    },
    {
      "parameters": {
        "jsCode": "const profile = $('Normalize Profile1').first().json || {};\nconst response = $input.first().json || {};\nconst rawRows = Array.isArray(response.data)\n  ? response.data\n  : Array.isArray(response.messages)\n    ? response.messages\n    : [];\n\nconst rows = rawRows\n  .filter(Boolean)\n  .sort((a, b) => new Date(a.created_at || a.createdAt || 0).getTime() - new Date(b.created_at || b.createdAt || 0).getTime())\n  .slice(-20);\n\nconst asRole = (m) => {\n  const senderType = String(m.sender_type || m.senderType || '').toLowerCase();\n  if (m.is_from_me === true || senderType === 'agent' || senderType === 'admin' || senderType === 'owner') return 'ADMIN';\n  if (senderType === 'ai' || senderType === 'bot') return 'AI';\n  return 'CUSTOMER';\n};\n\nconst lines = rows.map((m) => {\n  const role = asRole(m);\n  const name = String(m.sender_name || m.senderName || '').trim();\n  const type = String(m.message_type || m.messageType || 'text').toLowerCase();\n  const body = String(m.body || m.content || m.message_text || m.text || '').trim();\n  const media = String(m.media_url || m.mediaUrl || '').trim();\n  const time = String(m.created_at || m.createdAt || '').replace('T', ' ').replace(/\\.\\d+Z$/, 'Z');\n  const content = body || (media ? `[FILE:${type}] ${media}` : `[${type || 'message'}]`);\n  return `[${role}]${name ? ' ' + name : ''}${time ? ' @ ' + time : ''}: ${content}`;\n}).filter(Boolean);\n\nconst crmHistoryText = lines.length\n  ? lines.join('\\n')\n  : '(belum ada riwayat dari CustomerServiceCRM / gagal mengambil history)';\n\nconst currentMessage = String(profile.latest_message || profile.conversation_text || '').trim();\nconst adminContextExists = rows.some((m) => asRole(m) === 'ADMIN');\n\nreturn [{ json: {\n  ...profile,\n  crm_recent_messages_count: rows.length,\n  crm_admin_context_exists: adminContextExists,\n  crm_recent_history_text: crmHistoryText,\n  recent_history_text: [\n    'RIWAYAT CUSTOMER SERVICE CRM TERBARU (sumber utama, termasuk admin/manual):',\n    crmHistoryText,\n    '',\n    'RIWAYAT MEMORY WORKFLOW N8N:',\n    profile.recent_history_text || '(kosong)'\n  ].join('\\n'),\n  customer_context: [\n    profile.customer_context || '',\n    adminContextExists ? '\\nCATATAN: Ada konteks admin/manual di riwayat CRM. Ikuti konteks tersebut dan jangan ulang/bertentangan dengan jawaban admin.' : ''\n  ].join(''),\n  conversation_text: currentMessage || profile.conversation_text || profile.latest_message || ''\n}}];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        608,
        4624
      ],
      "id": "a421634b-38a8-43d2-b648-515463e7b40d",
      "name": "Merge CRM History Context"
    }
  ],
  "connections": {
    "WA_WEBHOOK": {
      "main": [
        [
          {
            "node": "Normalize Incoming WA Payload",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Incoming WA Payload": {
      "main": [
        [
          {
            "node": "Ignore Unsupported Event?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ignore Unsupported Event?": {
      "main": [
        [
          {
            "node": "Ignore Stop",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Is From Me?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is From Me?": {
      "main": [
        [
          {
            "node": "Process Outgoing Manual Takeover",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Ensure Runtime Tables",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ensure Runtime Tables": {
      "main": [
        [
          {
            "node": "Upsert Debounce Session",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Outgoing Manual Takeover": {
      "main": [
        [
          {
            "node": "No Operation, outgoing stop",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Debounce Session": {
      "main": [
        [
          {
            "node": "Wait 11 Seconds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 11 Seconds": {
      "main": [
        [
          {
            "node": "Claim Latest Debounce Batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claim Latest Debounce Batch": {
      "main": [
        [
          {
            "node": "Build Conversation Batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Conversation Batch": {
      "main": [
        [
          {
            "node": "cek eskalasi",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "cek eskalasi": {
      "main": [
        [
          {
            "node": "Has Active Escalation?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Active Escalation?": {
      "main": [
        [
          {
            "node": "Check Admin Reply",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Load Customer Profile1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Admin Reply": {
      "main": [
        [
          {
            "node": "Admin Has Not Replied?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Admin Has Not Replied?": {
      "main": [
        [
          {
            "node": "reminder grup1",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No Operation, do nothing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Customer Profile1": {
      "main": [
        [
          {
            "node": "Normalize Profile1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Profile1": {
      "main": [
        [
          {
            "node": "Load CRM Recent Messages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI customer service": {
      "main": [
        [
          {
            "node": "Parse AI Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI Output": {
      "main": [
        [
          {
            "node": "save customer data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "save customer data": {
      "main": [
        [
          {
            "node": "log ai response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "log ai response": {
      "main": [
        [
          {
            "node": "apakah tereskalasi atau tidak",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "apakah tereskalasi atau tidak": {
      "main": [
        [
          {
            "node": "catat eskalasi",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "kirim-wa-pesan-biasa1",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "catat eskalasi": {
      "main": [
        [
          {
            "node": "notif grup1",
            "type": "main",
            "index": 0
          },
          {
            "node": "kirim-wa-eskalasi",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "kirim-wa-eskalasi": {
      "main": [
        [
          {
            "node": "Check: Lead Hot + Ada GCLID?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "kirim-wa-pesan-biasa1": {
      "main": [
        [
          {
            "node": "Check: Lead Hot + Ada GCLID?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check: Lead Hot + Ada GCLID?": {
      "main": [
        [
          {
            "node": "Trigger: GCLID Offline Conversion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenRouter Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI customer service",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Skill - Core Contract": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - Conversation Flow": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - Lead Scoring & Escalation": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - Business Capabilities": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - Material Pricing": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - Product Knowledge": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - Production & Lead Time": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - FAQ & Objection Handling": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Skill - File Requirements": {
      "ai_tool": [
        [
          {
            "node": "AI customer service",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Load CRM Recent Messages": {
      "main": [
        [
          {
            "node": "Merge CRM History Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge CRM History Context": {
      "main": [
        [
          {
            "node": "AI customer service",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": true,
  "settings": {
    "executionOrder": "v1",
    "binaryMode": "separate",
    "timezone": "Asia/Jakarta",
    "saveDataSuccessExecution": "all",
    "saveExecutionProgress": true,
    "saveManualExecutions": true,
    "callerPolicy": "workflowsFromSameOwner",
    "availableInMCP": false
  },
  "versionId": "45b7789d-5888-4070-a2af-586bbaaca316",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "id": "m5KfFD8rlM484EVW",
  "tags": [
    {
      "updatedAt": "2026-04-24T03:53:32.677Z",
      "createdAt": "2026-04-24T03:53:32.677Z",
      "id": "5sSsrQHFWUotagxD",
      "name": "tepat laser"
    }
  ]
}