This workflow follows the Agent → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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-4
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
githubApiopenRouterApipostgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Customer Service v4 - Raja Cutting Laser Human Fixed CRM Context. Uses postgres, httpRequest, agent, lmChatOpenRouter. Webhook trigger; 43 nodes.
Source: https://github.com/heriscaleup/crm-n8n-dashboard/blob/980acca9b2cfd096f7ac7b53d92e67cfd97f3933/n8n-workflows/customer-service-raja-cutting-laser-human-fixed.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
🧪 LABR - nuevo asistente (REPARADO). Uses httpRequest, postgres, postgresTool, toolCalculator. Webhook trigger; 63 nodes.
🧪 LABR - nuevo asistente (REPARADO). Uses httpRequest, postgres, postgresTool, toolCalculator. Webhook trigger; 63 nodes.
🧪 LABR - nuevo asistente (REPARADO). Uses httpRequest, postgres, postgresTool, toolCode. Webhook trigger; 62 nodes.
🧪 LABR - nuevo asistente (REPARADO). Uses httpRequest, postgres, postgresTool, toolCode. Webhook trigger; 62 nodes.
🧪 LABR - nuevo asistente (REPARADO). Uses httpRequest, postgres, postgresTool, toolCode. Webhook trigger; 62 nodes.