feat: initial commit - lealtad demo n8n
This commit is contained in:
+16
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.zip
|
||||||
|
*.rar
|
||||||
|
*.7z
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
exports/
|
||||||
|
backups/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Lealtad Demo n8n
|
||||||
|
|
||||||
|
Este proyecto es una solución automatizada para la gestión de programas de fidelización (lealtad) utilizando WhatsApp como canal de interacción, n8n como motor de flujos y Supabase como backend escalable.
|
||||||
|
|
||||||
|
## Funcionalidades Principales
|
||||||
|
|
||||||
|
* **Registro Automatizado**: Identifica clientes nuevos y solicita su nombre para personalizar la billetera digital.
|
||||||
|
* **Gamificación**: Actualización automática de niveles (`Member`, `Gold`, `Black`) según el saldo acumulado.
|
||||||
|
* **Consulta de Saldo**: Respuesta instantánea con el saldo actual y nivel de tier al escribir por WhatsApp.
|
||||||
|
* **Digital Wallet Integration**: Entrega enlaces personalizados para pases de Apple Wallet y Google Wallet.
|
||||||
|
|
||||||
|
## Arquitectura del Sistema
|
||||||
|
|
||||||
|
1. **WhatsApp (Meta API)**: Recibe y envía mensajes.
|
||||||
|
2. **n8n**: Orquesta la lógica de negocio, procesa los datos y conecta los servicios.
|
||||||
|
3. **Supabase**: Almacena clientes, empresas, transacciones y ejecuta la lógica de gamificación vía Triggers de SQL.
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
* Instancia de **n8n** (v1.0+ recomendada).
|
||||||
|
* Proyecto en **Supabase**.
|
||||||
|
* Cuenta de desarrollador en **Meta** con WhatsApp Business API configurado.
|
||||||
|
* Portal de Wallet configurado para generar los pases.
|
||||||
|
|
||||||
|
## Guía de Configuración
|
||||||
|
|
||||||
|
### 1. Supabase
|
||||||
|
Ejecuta el script situado en `supabase/schema.sql` en tu editor SQL de Supabase. Esto creará la estructura de tablas y las funciones necesarias.
|
||||||
|
|
||||||
|
### 2. Importación en n8n
|
||||||
|
1. Importa el archivo `n8n/plan-de-incentivo.template.json` en un nuevo workflow de n8n.
|
||||||
|
2. Configura las siguientes credenciales:
|
||||||
|
* **WhatsApp Trigger**: Credenciales OAuth2 o API Token de Meta.
|
||||||
|
* **Supabase Auth**: Configura un nodo de Header Auth con tu `apikey` y `Authorization: Bearer [TOKEN]`.
|
||||||
|
|
||||||
|
### 3. Reemplazo de Parámetros
|
||||||
|
Busca y reemplaza los siguientes placeholders en el workflow:
|
||||||
|
* `TU_PROYECTO`: El subdominio de tu instancia de Supabase.
|
||||||
|
* `TU_PHONE_NUMBER_ID`: El ID del número de teléfono en Meta Developer Portal.
|
||||||
|
* `COMPANY_ID_DEMO`: El UUID de la empresa creada en la tabla `companies`.
|
||||||
|
* `TU_PORTAL`: El dominio de tu portal de generación de Wallet.
|
||||||
|
|
||||||
|
## Seguridad y Privacidad
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> Nunca subas credenciales reales, tokens de acceso o datos personales (`pinData`) al repositorio. Utiliza variables de entorno o el gestor de credenciales de n8n.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Desarrollado como parte de la infraestructura de fidelización digital de Digital Compass Agency.*
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
# Configuración — Lealtad Demo n8n
|
||||||
|
|
||||||
|
Este documento resume la configuración del demo de sistema de lealtad usando **Supabase**, **n8n** y **WhatsApp Business API / Meta Graph**.
|
||||||
|
|
||||||
|
> **Nota de seguridad:** esta versión está preparada para subirse a Gitea.
|
||||||
|
> No contiene tokens, teléfonos reales, credenciales, IDs reales de producción ni datos personales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objetivo del proyecto
|
||||||
|
|
||||||
|
El flujo permite que un cliente escriba por WhatsApp y que n8n:
|
||||||
|
|
||||||
|
1. Reciba el mensaje desde WhatsApp.
|
||||||
|
2. Extraiga el teléfono y el texto del mensaje.
|
||||||
|
3. Consulte en Supabase si el cliente existe.
|
||||||
|
4. Cree el cliente si es nuevo.
|
||||||
|
5. Maneje el estado del bot:
|
||||||
|
- `NEW`
|
||||||
|
- `AWAITING_NAME`
|
||||||
|
- `ACTIVE`
|
||||||
|
6. Responda por WhatsApp según el estado del cliente.
|
||||||
|
7. Entregue una URL de Wallet / pase digital.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Arquitectura general
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[WhatsApp / Meta] --> B[n8n WhatsApp Trigger]
|
||||||
|
B --> C[Code Node: parsear mensaje]
|
||||||
|
C --> D[Supabase RPC: process_wa_customer]
|
||||||
|
D --> E{Switch por estado}
|
||||||
|
E -->|NEW| F[Actualizar a AWAITING_NAME]
|
||||||
|
F --> G[Enviar mensaje solicitando nombre]
|
||||||
|
E -->|AWAITING_NAME| H[Guardar nombre y activar cliente]
|
||||||
|
H --> I[Enviar link de Wallet]
|
||||||
|
E -->|ACTIVE| J[Responder saldo, tier y link de Wallet]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Valores que deben reemplazarse
|
||||||
|
|
||||||
|
Antes de usar este proyecto en una demo real o producción, reemplaza los siguientes placeholders:
|
||||||
|
|
||||||
|
| Placeholder | Descripción |
|
||||||
|
|---|---|
|
||||||
|
| `TU_PROYECTO` | Subdominio del proyecto en Supabase |
|
||||||
|
| `TU_SUPABASE_ANON_KEY` | Supabase anon key configurada en credenciales de n8n |
|
||||||
|
| `TU_TOKEN_DE_META` | Token de Meta / WhatsApp Business API |
|
||||||
|
| `TU_PHONE_NUMBER_ID` | ID del número de WhatsApp en Meta |
|
||||||
|
| `COMPANY_ID_DEMO` | UUID de la empresa creada en la tabla `companies` |
|
||||||
|
| `TU_PORTAL` | Dominio del portal donde se consulta/genera el Wallet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parte 1 — Configuración en Supabase
|
||||||
|
|
||||||
|
En Supabase, abre el **SQL Editor**, pega el siguiente script y ejecútalo.
|
||||||
|
|
||||||
|
Este script crea:
|
||||||
|
|
||||||
|
- Tabla `companies`
|
||||||
|
- Tabla `customers`
|
||||||
|
- Tabla `transactions`
|
||||||
|
- Trigger de gamificación automática
|
||||||
|
- RPC `process_wa_customer`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. Habilitar extensión para IDs seguros (UUID)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 2. TABLA: Empresas
|
||||||
|
CREATE TABLE companies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
wa_phone_id TEXT -- ID del número de WhatsApp en Meta
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. TABLA: Clientes finales
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
balance NUMERIC(10,2) DEFAULT 0.00,
|
||||||
|
tier TEXT DEFAULT 'Member',
|
||||||
|
bot_state TEXT DEFAULT 'NEW',
|
||||||
|
pass_id TEXT UNIQUE DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(company_id, phone)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. TABLA: Transacciones
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
amount_spent NUMERIC(10,2) NOT NULL,
|
||||||
|
cashback_earned NUMERIC(10,2) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. TRIGGER: Gamificación automática
|
||||||
|
CREATE OR REPLACE FUNCTION update_customer_stats() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
new_balance NUMERIC;
|
||||||
|
BEGIN
|
||||||
|
-- Sumar cashback al cliente
|
||||||
|
UPDATE customers
|
||||||
|
SET balance = balance + NEW.cashback_earned
|
||||||
|
WHERE id = NEW.customer_id
|
||||||
|
RETURNING balance INTO new_balance;
|
||||||
|
|
||||||
|
-- Lógica de estatus dinámico
|
||||||
|
IF new_balance >= 100 THEN
|
||||||
|
UPDATE customers SET tier = 'Black' WHERE id = NEW.customer_id;
|
||||||
|
ELSIF new_balance >= 50 THEN
|
||||||
|
UPDATE customers SET tier = 'Gold' WHERE id = NEW.customer_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER after_transaction_insert
|
||||||
|
AFTER INSERT ON transactions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_customer_stats();
|
||||||
|
|
||||||
|
-- 6. RPC: función que n8n llama para consultar/crear cliente
|
||||||
|
CREATE OR REPLACE FUNCTION process_wa_customer(
|
||||||
|
p_phone TEXT,
|
||||||
|
p_company_id UUID
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
customer_id UUID,
|
||||||
|
state TEXT,
|
||||||
|
c_name TEXT,
|
||||||
|
pass_id TEXT,
|
||||||
|
c_balance NUMERIC,
|
||||||
|
c_tier TEXT
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id UUID;
|
||||||
|
v_state TEXT;
|
||||||
|
v_name TEXT;
|
||||||
|
v_pass TEXT;
|
||||||
|
v_bal NUMERIC;
|
||||||
|
v_tier TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Buscar cliente existente
|
||||||
|
SELECT id, bot_state, name, customers.pass_id, balance, tier
|
||||||
|
INTO v_id, v_state, v_name, v_pass, v_bal, v_tier
|
||||||
|
FROM customers
|
||||||
|
WHERE phone = p_phone
|
||||||
|
AND company_id = p_company_id;
|
||||||
|
|
||||||
|
-- Si es la primera vez que escribe, crearlo en estado NEW
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
INSERT INTO customers (phone, company_id, bot_state)
|
||||||
|
VALUES (p_phone, p_company_id, 'NEW')
|
||||||
|
RETURNING id, bot_state, name, customers.pass_id, balance, tier
|
||||||
|
INTO v_id, v_state, v_name, v_pass, v_bal, v_tier;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_id, v_state, v_name, v_pass, v_bal, v_tier;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Crear empresa demo
|
||||||
|
|
||||||
|
Después de crear las tablas, inserta una empresa de prueba en `companies`.
|
||||||
|
|
||||||
|
Ejemplo:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO companies (name, wa_phone_id)
|
||||||
|
VALUES ('Empresa Demo', 'TU_PHONE_NUMBER_ID');
|
||||||
|
```
|
||||||
|
|
||||||
|
Luego copia el `id` generado por Supabase. Ese valor será usado en n8n como:
|
||||||
|
|
||||||
|
```text
|
||||||
|
COMPANY_ID_DEMO
|
||||||
|
```
|
||||||
|
|
||||||
|
> No subas a Git/Gitea el UUID real de producción si este repositorio se usará como plantilla.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parte 2 — Flujo en n8n
|
||||||
|
|
||||||
|
El flujo en n8n procesa mensajes de WhatsApp, consulta Supabase y responde según el estado del cliente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nodo 1 — WhatsApp Trigger
|
||||||
|
|
||||||
|
Configura el nodo de entrada con las credenciales de WhatsApp / Meta.
|
||||||
|
|
||||||
|
Responsabilidad:
|
||||||
|
|
||||||
|
- Escuchar mensajes entrantes.
|
||||||
|
- Recibir payloads de WhatsApp.
|
||||||
|
- Disparar el flujo cuando llega un mensaje.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nodo 2 — Code Node: Parsear JSON de Meta
|
||||||
|
|
||||||
|
Este nodo limpia el payload de Meta y devuelve solo los campos necesarios:
|
||||||
|
|
||||||
|
- Teléfono del cliente
|
||||||
|
- Texto recibido
|
||||||
|
- ID del número de WhatsApp
|
||||||
|
- ID de empresa demo
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const body = $input.first().json;
|
||||||
|
|
||||||
|
// Evitar procesar payloads sin mensajes reales
|
||||||
|
if (!body?.messages?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = body.messages[0];
|
||||||
|
const phone_id = body.metadata?.phone_number_id;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
phone: msg.from,
|
||||||
|
text: msg.type === 'text' ? (msg.text?.body || '').trim() : '',
|
||||||
|
phone_id,
|
||||||
|
company_id: 'COMPANY_ID_DEMO'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
> Reemplaza `COMPANY_ID_DEMO` por el UUID real de la empresa creada en Supabase, o configura este valor mediante variables/credenciales según tu estándar interno.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nodo 3 — HTTP Request: Consultar a Supabase RPC
|
||||||
|
|
||||||
|
Este nodo consulta la función `process_wa_customer` en Supabase.
|
||||||
|
|
||||||
|
Configuración sugerida:
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|---|---|
|
||||||
|
| Method | `POST` |
|
||||||
|
| URL | `https://TU_PROYECTO.supabase.co/rest/v1/rpc/process_wa_customer` |
|
||||||
|
| Authentication | Header Auth / Custom Auth |
|
||||||
|
| Header `apikey` | `TU_SUPABASE_ANON_KEY` |
|
||||||
|
| Header `Authorization` | `Bearer TU_SUPABASE_ANON_KEY` |
|
||||||
|
| Send Body | Activado |
|
||||||
|
| Body Type | JSON |
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"p_phone": "{{ $json.phone }}",
|
||||||
|
"p_company_id": "{{ $json.company_id }}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nodo 4 — Switch Node: Enrutador por estado
|
||||||
|
|
||||||
|
Conecta la salida del HTTP Request de Supabase al Switch.
|
||||||
|
|
||||||
|
El Switch debe evaluar el campo:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{{ $json.state }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rutas:
|
||||||
|
|
||||||
|
| Ruta | Condición | Descripción |
|
||||||
|
|---|---|---|
|
||||||
|
| Ruta 0 | `state == NEW` | Cliente nuevo |
|
||||||
|
| Ruta 1 | `state == AWAITING_NAME` | Cliente está dando su nombre |
|
||||||
|
| Ruta 2 | `state == ACTIVE` | Cliente ya está activo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parte 3 — Ramas del Switch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rama 0 — Estado `NEW`
|
||||||
|
|
||||||
|
Objetivo: pedir el nombre del cliente.
|
||||||
|
|
||||||
|
### Paso 1 — Actualizar estado en Supabase
|
||||||
|
|
||||||
|
Nodo: HTTP Request
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|---|---|
|
||||||
|
| Method | `PATCH` |
|
||||||
|
| URL | `https://TU_PROYECTO.supabase.co/rest/v1/customers?id=eq.{{ $json.customer_id }}` |
|
||||||
|
| Auth | Misma configuración de Supabase |
|
||||||
|
| Header recomendado | `Prefer: return=minimal` |
|
||||||
|
| Body | JSON |
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_state": "AWAITING_NAME"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paso 2 — Enviar WhatsApp solicitando nombre
|
||||||
|
|
||||||
|
Nodo: HTTP Request
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|---|---|
|
||||||
|
| Method | `POST` |
|
||||||
|
| URL | `https://graph.facebook.com/v19.0/{{ $('Code').item.json.phone_id }}/messages` |
|
||||||
|
| Header `Authorization` | `Bearer TU_TOKEN_DE_META` |
|
||||||
|
| Body | JSON |
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": "{{ $('Code').item.json.phone }}",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"body": "¡Hola! 👋 Bienvenido a nuestro Club VIP. Para personalizar tu Billetera Digital, ¿cómo te gusta que te llamen?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rama 1 — Estado `AWAITING_NAME`
|
||||||
|
|
||||||
|
Objetivo: guardar el nombre del cliente y activar su perfil.
|
||||||
|
|
||||||
|
### Paso 1 — Guardar nombre y pasar a `ACTIVE`
|
||||||
|
|
||||||
|
Nodo: HTTP Request
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|---|---|
|
||||||
|
| Method | `PATCH` |
|
||||||
|
| URL | `https://TU_PROYECTO.supabase.co/rest/v1/customers?id=eq.{{ $json.customer_id }}` |
|
||||||
|
| Auth | Misma configuración de Supabase |
|
||||||
|
| Body | JSON |
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"bot_state": "ACTIVE",
|
||||||
|
"name": "{{ $('Code').item.json.text }}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paso 2 — Enviar link del Wallet
|
||||||
|
|
||||||
|
Nodo: HTTP Request
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|---|---|
|
||||||
|
| Method | `POST` |
|
||||||
|
| URL | `https://graph.facebook.com/v19.0/{{ $('Code').item.json.phone_id }}/messages` |
|
||||||
|
| Header `Authorization` | `Bearer TU_TOKEN_DE_META` |
|
||||||
|
| Body | JSON |
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": "{{ $('Code').item.json.phone }}",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"body": "¡Listo, {{ $('Code').item.json.text }}! 🎉\n\nTu perfil está activo. Descarga tu pase VIP y agrégalo a tu Apple/Google Wallet tocando este enlace:\n\n👉 https://TU_PORTAL/wallet/{{ $('HTTP Request').item.json.pass_id }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rama 2 — Estado `ACTIVE`
|
||||||
|
|
||||||
|
Objetivo: responder saldo, nivel y link de recuperación del Wallet.
|
||||||
|
|
||||||
|
Nodo: HTTP Request
|
||||||
|
|
||||||
|
| Campo | Valor |
|
||||||
|
|---|---|
|
||||||
|
| Method | `POST` |
|
||||||
|
| URL | `https://graph.facebook.com/v19.0/{{ $('Code').item.json.phone_id }}/messages` |
|
||||||
|
| Header `Authorization` | `Bearer TU_TOKEN_DE_META` |
|
||||||
|
| Body | JSON |
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"messaging_product": "whatsapp",
|
||||||
|
"to": "{{ $('Code').item.json.phone }}",
|
||||||
|
"type": "text",
|
||||||
|
"text": {
|
||||||
|
"body": "¡Hola de nuevo, {{ $json.c_name }}! 💳\n\nEres nivel *{{ $json.c_tier }}* y tu saldo actual para gastar es de *$ {{ $json.c_balance }}*.\n\nSi perdiste tu tarjeta, recupérala aquí:\n👉 https://TU_PORTAL/wallet/{{ $json.pass_id }}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parte 4 — Credenciales necesarias en n8n
|
||||||
|
|
||||||
|
Configura manualmente las credenciales dentro de n8n. No deben versionarse en Gitea.
|
||||||
|
|
||||||
|
## WhatsApp / Meta
|
||||||
|
|
||||||
|
- WhatsApp Trigger OAuth o credencial equivalente.
|
||||||
|
- Token de Meta para llamadas a Graph API.
|
||||||
|
- Phone Number ID de Meta.
|
||||||
|
|
||||||
|
## Supabase
|
||||||
|
|
||||||
|
Configura headers para HTTP Request:
|
||||||
|
|
||||||
|
```text
|
||||||
|
apikey: TU_SUPABASE_ANON_KEY
|
||||||
|
Authorization: Bearer TU_SUPABASE_ANON_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parte 5 — Importación del workflow
|
||||||
|
|
||||||
|
1. Entra a n8n.
|
||||||
|
2. Importa el archivo `n8n/plan-de-incentivo.template.json`.
|
||||||
|
3. Asigna manualmente las credenciales de WhatsApp y Supabase.
|
||||||
|
4. Reemplaza los placeholders:
|
||||||
|
- `TU_PROYECTO`
|
||||||
|
- `TU_PHONE_NUMBER_ID`
|
||||||
|
- `COMPANY_ID_DEMO`
|
||||||
|
- `TU_PORTAL`
|
||||||
|
5. Ejecuta una prueba con un número autorizado de WhatsApp.
|
||||||
|
6. Verifica que el cliente se cree en Supabase.
|
||||||
|
7. Verifica que el bot responda según el estado correcto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parte 6 — Seguridad antes de subir a Gitea
|
||||||
|
|
||||||
|
Antes de hacer commit, verifica que no existan:
|
||||||
|
|
||||||
|
- `pinData`
|
||||||
|
- Teléfonos reales
|
||||||
|
- Nombres reales de clientes/personas
|
||||||
|
- Tokens de Meta
|
||||||
|
- Supabase anon key real
|
||||||
|
- Supabase service role key
|
||||||
|
- Credential IDs reales de n8n
|
||||||
|
- Credential names reales de n8n
|
||||||
|
- `webhookId`
|
||||||
|
- `instanceId`
|
||||||
|
- Workflow ID real
|
||||||
|
- `versionId`
|
||||||
|
- UUID real de `company_id`
|
||||||
|
- URLs reales internas o sensibles
|
||||||
|
|
||||||
|
El workflow que se suba al repositorio debe funcionar como **template reutilizable**, no como exportación exacta de producción.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Parte 7 — Checklist rápido
|
||||||
|
|
||||||
|
- [ ] Supabase schema creado.
|
||||||
|
- [ ] Empresa demo creada en `companies`.
|
||||||
|
- [ ] `COMPANY_ID_DEMO` reemplazado en n8n.
|
||||||
|
- [ ] Credenciales de WhatsApp configuradas manualmente.
|
||||||
|
- [ ] Credenciales de Supabase configuradas manualmente.
|
||||||
|
- [ ] Workflow importado en n8n.
|
||||||
|
- [ ] `pinData` eliminado del JSON antes de subirlo.
|
||||||
|
- [ ] IDs, tokens y teléfonos reales eliminados.
|
||||||
|
- [ ] Prueba end-to-end ejecutada correctamente.
|
||||||
|
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
{
|
||||||
|
"name": "plan de incentivo",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"updates": [
|
||||||
|
"messages"
|
||||||
|
],
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.whatsAppTrigger",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [
|
||||||
|
-368,
|
||||||
|
-16
|
||||||
|
],
|
||||||
|
"name": "WhatsApp Trigger"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"jsCode": "const body = $input.first().json;\n\n// Evitar procesar payloads sin mensajes reales\nif (!body?.messages?.length) {\n return [];\n}\n\nconst msg = body.messages[0];\nconst phone_id = body.metadata?.phone_number_id;\n\nreturn [\n {\n json: {\n phone: msg.from,\n text: msg.type === 'text' ? (msg.text?.body || '').trim() : '',\n phone_id,\n company_id: 'COMPANY_ID_DEMO'\n }\n }\n];"
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
-176,
|
||||||
|
-16
|
||||||
|
],
|
||||||
|
"name": "Code in JavaScript"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://TU_PROYECTO.supabase.co/rest/v1/rpc/process_wa_customer",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpCustomAuth",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"p_phone\": \"{{ $('Code in JavaScript').item.json.phone }}\",\n \"p_company_id\": \"{{ $('Code in JavaScript').item.json.company_id }}\"\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.4,
|
||||||
|
"position": [
|
||||||
|
32,
|
||||||
|
-16
|
||||||
|
],
|
||||||
|
"name": "HTTP Request"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"rules": {
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"leftValue": "={{ $json.state }}",
|
||||||
|
"rightValue": "NEW",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
},
|
||||||
|
"id": "261c308d-79b6-4229-958f-99ba6b2a14f9"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"renameOutput": true,
|
||||||
|
"outputKey": "NEW"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "5db18bfd-716c-4961-bb65-c3791f8ad202",
|
||||||
|
"leftValue": "={{ $json.state }}",
|
||||||
|
"rightValue": "AWAITING_NAME",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals",
|
||||||
|
"name": "filter.operator.equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"renameOutput": true,
|
||||||
|
"outputKey": "AWAITING_NAME"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict",
|
||||||
|
"version": 3
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "dee9b7b0-f7ca-4ef0-87ea-f43a15a2e52e",
|
||||||
|
"leftValue": "={{ $json.state }}",
|
||||||
|
"rightValue": "ACTIVE",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals",
|
||||||
|
"name": "filter.operator.equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"renameOutput": true,
|
||||||
|
"outputKey": "ACTIVE"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.switch",
|
||||||
|
"typeVersion": 3.4,
|
||||||
|
"position": [
|
||||||
|
176,
|
||||||
|
-32
|
||||||
|
],
|
||||||
|
"name": "Switch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://graph.facebook.com/v22.0/TU_PHONE_NUMBER_ID/messages",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"messaging_product\": \"whatsapp\",\n \"to\": \"={{ $('Code in JavaScript').item.json.phone }}\",\n \"type\": \"text\",\n \"text\": { \"body\": \"¡Hola! 👋 Bienvenido a nuestro Club VIP. Para personalizar tu Billetera Digital, ¿cómo te gusta que te llamen?\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.4,
|
||||||
|
"position": [
|
||||||
|
624,
|
||||||
|
-240
|
||||||
|
],
|
||||||
|
"name": "HTTP Request2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "=https://TU_PROYECTO.supabase.co/rest/v1/customers?id=eq.{{ $json.customer_id }}",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpCustomAuth",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={ \"bot_state\": \"AWAITING_NAME\" }",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.4,
|
||||||
|
"position": [
|
||||||
|
432,
|
||||||
|
-240
|
||||||
|
],
|
||||||
|
"name": "HTTP Request3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "PATCH",
|
||||||
|
"url": "=https://TU_PROYECTO.supabase.co/rest/v1/customers?id=eq.{{ $json.customer_id }}",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpCustomAuth",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={ \n \"bot_state\": \"ACTIVE\",\n \"name\": \"{{ $('Code in JavaScript').item.json.text }}\"\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.4,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
-16
|
||||||
|
],
|
||||||
|
"name": "HTTP Request4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://graph.facebook.com/v22.0/TU_PHONE_NUMBER_ID/messages",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"messaging_product\": \"whatsapp\",\n \"to\": \"{{ $('Code in JavaScript').item.json.phone }}\",\n \"type\": \"text\",\n \"text\": { \"body\": \"¡Listo, {{ $('Code in JavaScript').item.json.text }}! 🎉\\n\\nTu perfil está activo. Descarga tu pase VIP y agrégalo a tu Apple/Google Wallet tocando este enlace:\\n\\n👉 https://TU_PORTAL.com/wallet/{{ $('HTTP Request').item.json.pass_id }}\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.4,
|
||||||
|
"position": [
|
||||||
|
656,
|
||||||
|
-16
|
||||||
|
],
|
||||||
|
"name": "HTTP Request5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://graph.facebook.com/v22.0/TU_PHONE_NUMBER_ID/messages",
|
||||||
|
"authentication": "genericCredentialType",
|
||||||
|
"genericAuthType": "httpHeaderAuth",
|
||||||
|
"sendBody": true,
|
||||||
|
"specifyBody": "json",
|
||||||
|
"jsonBody": "={\n \"messaging_product\": \"whatsapp\",\n \"to\": \"={{ $('Code in JavaScript').item.json.phone }}\",\n \"type\": \"text\",\n \"text\": { \"body\": \"¡Hola de nuevo, {{ $('Switch').item.json.c_name }}! 💳\\n\\nEres nivel *{{ $('Switch').item.json.c_tier }}* y tu saldo actual para gastar es de *$ {{ $('Switch').item.json.c_balance }}*.\\n\\nSi perdiste tu tarjeta, recupérala aquí:\\n👉 https://TU_PORTAL.com/wallet/{{ $('Switch').item.json.pass_id }}\" }\n}",
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"type": "n8n-nodes-base.httpRequest",
|
||||||
|
"typeVersion": 4.4,
|
||||||
|
"position": [
|
||||||
|
448,
|
||||||
|
160
|
||||||
|
],
|
||||||
|
"name": "HTTP Request6"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pinData": {},
|
||||||
|
"connections": {
|
||||||
|
"WhatsApp Trigger": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Code in JavaScript",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Code in JavaScript": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HTTP Request": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Switch",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Switch": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request3",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request4",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request6",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HTTP Request3": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request2",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"HTTP Request4": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "HTTP Request5",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"active": false,
|
||||||
|
"settings": {
|
||||||
|
"executionOrder": "v1",
|
||||||
|
"binaryMode": "separate",
|
||||||
|
"availableInMCP": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
-- 1. Habilitar extensión para IDs seguros (UUID)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- 2. TABLA: Empresas
|
||||||
|
CREATE TABLE companies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
wa_phone_id TEXT -- El ID del número de WhatsApp en Meta
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. TABLA: Clientes Finales
|
||||||
|
CREATE TABLE customers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
balance NUMERIC(10,2) DEFAULT 0.00,
|
||||||
|
tier TEXT DEFAULT 'Member', -- Niveles: Member, Gold, Black
|
||||||
|
bot_state TEXT DEFAULT 'NEW', -- Máquina de estados: NEW, AWAITING_NAME, ACTIVE
|
||||||
|
pass_id TEXT UNIQUE DEFAULT uuid_generate_v4(),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(company_id, phone)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. TABLA: Transacciones
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID REFERENCES customers(id) ON DELETE CASCADE,
|
||||||
|
amount_spent NUMERIC(10,2) NOT NULL,
|
||||||
|
cashback_earned NUMERIC(10,2) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. TRIGGER: Gamificación Automática
|
||||||
|
CREATE OR REPLACE FUNCTION update_customer_stats() RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
new_balance NUMERIC;
|
||||||
|
BEGIN
|
||||||
|
-- Sumar cashback al cliente
|
||||||
|
UPDATE customers SET balance = balance + NEW.cashback_earned
|
||||||
|
WHERE id = NEW.customer_id RETURNING balance INTO new_balance;
|
||||||
|
|
||||||
|
-- Lógica de Estatus Dinámico
|
||||||
|
IF new_balance >= 100 THEN
|
||||||
|
UPDATE customers SET tier = 'Black' WHERE id = NEW.customer_id;
|
||||||
|
ELSIF new_balance >= 50 THEN
|
||||||
|
UPDATE customers SET tier = 'Gold' WHERE id = NEW.customer_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER after_transaction_insert
|
||||||
|
AFTER INSERT ON transactions
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_customer_stats();
|
||||||
|
|
||||||
|
-- 6. RPC: Función de procesamiento para n8n
|
||||||
|
CREATE OR REPLACE FUNCTION process_wa_customer(p_phone TEXT, p_company_id UUID)
|
||||||
|
RETURNS TABLE (customer_id UUID, state TEXT, c_name TEXT, pass_id TEXT, c_balance NUMERIC, c_tier TEXT) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id UUID; v_state TEXT; v_name TEXT; v_pass TEXT; v_bal NUMERIC; v_tier TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Buscar cliente existente
|
||||||
|
SELECT id, bot_state, name, customers.pass_id, balance, tier
|
||||||
|
INTO v_id, v_state, v_name, v_pass, v_bal, v_tier
|
||||||
|
FROM customers WHERE phone = p_phone AND company_id = p_company_id;
|
||||||
|
|
||||||
|
-- Si es la primera vez que escribe, lo creamos en estado 'NEW'
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
INSERT INTO customers (phone, company_id, bot_state)
|
||||||
|
VALUES (p_phone, p_company_id, 'NEW')
|
||||||
|
RETURNING id, bot_state, name, customers.pass_id, balance, tier
|
||||||
|
INTO v_id, v_state, v_name, v_pass, v_bal, v_tier;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_id, v_state, v_name, v_pass, v_bal, v_tier;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Ejemplo de inserción de empresa (Descomentar y reemplazar)
|
||||||
|
-- INSERT INTO companies (name, wa_phone_id)
|
||||||
|
-- VALUES ('TU_EMPRESA', 'TU_PHONE_NUMBER_ID');
|
||||||
Reference in New Issue
Block a user