erp-core-database/ddl/15-whatsapp.sql
rckrdmrd 5043a640e4 refactor: Restructure DDL with numbered schema files
- Replace old DDL structure with new numbered files (01-24)
- Update migrations and seeds for new schema
- Clean up deprecated files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 00:40:32 -06:00

1019 lines
34 KiB
PL/PgSQL

-- =============================================================
-- ARCHIVO: 15-whatsapp.sql
-- DESCRIPCION: Integracion WhatsApp Business API, mensajes, templates
-- VERSION: 1.0.0
-- PROYECTO: ERP-Core V2
-- FECHA: 2026-01-10
-- EPIC: SAAS-WHATSAPP (EPIC-SAAS-008)
-- HISTORIAS: US-090, US-091, US-092
-- =============================================================
-- =====================
-- SCHEMA: whatsapp
-- =====================
CREATE SCHEMA IF NOT EXISTS whatsapp;
-- =====================
-- TABLA: whatsapp.accounts
-- Cuentas de WhatsApp Business configuradas
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.accounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificación de la cuenta
name VARCHAR(200) NOT NULL,
phone_number VARCHAR(20) NOT NULL,
phone_number_id VARCHAR(50) NOT NULL, -- ID en Meta
business_account_id VARCHAR(50) NOT NULL, -- WABA ID
-- Configuración de API
access_token TEXT, -- Encriptado
webhook_verify_token VARCHAR(255),
webhook_secret VARCHAR(255),
-- Perfil de negocio
business_name VARCHAR(200),
business_description TEXT,
business_category VARCHAR(100),
business_website TEXT,
profile_picture_url TEXT,
-- Configuración
default_language VARCHAR(10) DEFAULT 'es_MX',
auto_reply_enabled BOOLEAN DEFAULT FALSE,
auto_reply_message TEXT,
business_hours JSONB DEFAULT '{}',
-- {monday: {start: "09:00", end: "18:00"}, ...}
-- Estado
status VARCHAR(20) DEFAULT 'pending', -- pending, active, suspended, disconnected
verified_at TIMESTAMPTZ,
-- Límites
daily_message_limit INTEGER DEFAULT 1000,
messages_sent_today INTEGER DEFAULT 0,
last_limit_reset TIMESTAMPTZ,
-- Estadísticas
total_messages_sent BIGINT DEFAULT 0,
total_messages_received BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, phone_number)
);
-- =====================
-- TABLA: whatsapp.templates
-- Templates aprobados por Meta
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
-- Identificación
name VARCHAR(512) NOT NULL, -- Nombre en Meta (snake_case)
display_name VARCHAR(200) NOT NULL, -- Nombre legible
description TEXT,
-- Categoría (requerida por Meta)
category VARCHAR(30) NOT NULL, -- MARKETING, UTILITY, AUTHENTICATION
-- Idioma
language VARCHAR(10) NOT NULL DEFAULT 'es_MX',
-- Componentes del template
header_type VARCHAR(20), -- TEXT, IMAGE, VIDEO, DOCUMENT
header_text TEXT,
header_media_url TEXT,
body_text TEXT NOT NULL,
body_variables TEXT[] DEFAULT '{}', -- {{1}}, {{2}}, etc.
footer_text VARCHAR(60),
-- Botones
buttons JSONB DEFAULT '[]',
-- [{type: "QUICK_REPLY", text: "Sí"}, {type: "URL", text: "Ver más", url: "..."}]
-- Estado en Meta
meta_template_id VARCHAR(50),
meta_status VARCHAR(20) DEFAULT 'PENDING',
-- PENDING, APPROVED, REJECTED, PAUSED, DISABLED
rejection_reason TEXT,
-- Uso
is_active BOOLEAN DEFAULT TRUE,
usage_count INTEGER DEFAULT 0,
last_used_at TIMESTAMPTZ,
-- Versionamiento
version INTEGER DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
submitted_at TIMESTAMPTZ,
approved_at TIMESTAMPTZ,
UNIQUE(account_id, name, language)
);
-- =====================
-- TABLA: whatsapp.contacts
-- Contactos de WhatsApp
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE,
-- Número de teléfono
phone_number VARCHAR(20) NOT NULL,
wa_id VARCHAR(50), -- WhatsApp ID
-- Perfil (obtenido de WhatsApp)
profile_name VARCHAR(200),
profile_picture_url TEXT,
-- Asociación con entidades
customer_id UUID,
user_id UUID REFERENCES auth.users(id),
-- Estado de conversación
conversation_status VARCHAR(20) DEFAULT 'active',
-- active, waiting, resolved, blocked
last_message_at TIMESTAMPTZ,
last_message_direction VARCHAR(10), -- inbound, outbound
-- Ventana de 24 horas
conversation_window_expires_at TIMESTAMPTZ,
can_send_template_only BOOLEAN DEFAULT TRUE,
-- Opt-in/Opt-out
opted_in BOOLEAN DEFAULT FALSE,
opted_in_at TIMESTAMPTZ,
opted_out BOOLEAN DEFAULT FALSE,
opted_out_at TIMESTAMPTZ,
-- Tags y categorización
tags TEXT[] DEFAULT '{}',
notes TEXT,
-- Estadísticas
total_messages_sent INTEGER DEFAULT 0,
total_messages_received INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(account_id, phone_number)
);
-- =====================
-- TABLA: whatsapp.conversations
-- Conversaciones (hilos de chat)
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES whatsapp.contacts(id) ON DELETE CASCADE,
-- Estado
status VARCHAR(20) DEFAULT 'open', -- open, pending, resolved, closed
priority VARCHAR(20) DEFAULT 'normal', -- low, normal, high, urgent
-- Asignación
assigned_to UUID REFERENCES auth.users(id),
assigned_at TIMESTAMPTZ,
team_id UUID,
-- Categorización
category VARCHAR(50),
tags TEXT[] DEFAULT '{}',
-- Contexto
context_type VARCHAR(50), -- support, sales, order, general
context_id UUID,
-- Tiempos
first_response_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
-- Estadísticas
message_count INTEGER DEFAULT 0,
unread_count INTEGER DEFAULT 0,
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: whatsapp.messages
-- Mensajes enviados y recibidos
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES whatsapp.contacts(id) ON DELETE CASCADE,
conversation_id UUID REFERENCES whatsapp.conversations(id),
-- Identificadores de Meta
wa_message_id VARCHAR(100), -- ID del mensaje en WhatsApp
wa_conversation_id VARCHAR(100), -- ID de conversación en Meta
-- Dirección
direction VARCHAR(10) NOT NULL, -- inbound, outbound
-- Tipo de mensaje
message_type VARCHAR(20) NOT NULL,
-- text, image, video, audio, document, sticker, location, contacts, interactive, template, reaction
-- Contenido
content TEXT,
caption TEXT,
-- Media
media_id VARCHAR(100),
media_url TEXT,
media_mime_type VARCHAR(100),
media_sha256 VARCHAR(64),
media_size_bytes INTEGER,
-- Template (si aplica)
template_id UUID REFERENCES whatsapp.templates(id),
template_name VARCHAR(512),
template_variables JSONB DEFAULT '[]',
-- Interactivo (si aplica)
interactive_type VARCHAR(30), -- button, list, product, product_list
interactive_data JSONB DEFAULT '{}',
-- Contexto (respuesta a otro mensaje)
context_message_id VARCHAR(100),
quoted_message_id UUID REFERENCES whatsapp.messages(id),
-- Estado del mensaje
status VARCHAR(20) DEFAULT 'pending',
-- pending, sent, delivered, read, failed
status_updated_at TIMESTAMPTZ,
-- Error (si falló)
error_code VARCHAR(20),
error_message TEXT,
-- Costos (para mensajes de template)
is_billable BOOLEAN DEFAULT FALSE,
cost_category VARCHAR(30), -- utility, authentication, marketing
-- Metadata
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ
);
-- =====================
-- TABLA: whatsapp.message_status_updates
-- Actualizaciones de estado de mensajes
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.message_status_updates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID NOT NULL REFERENCES whatsapp.messages(id) ON DELETE CASCADE,
-- Estado
status VARCHAR(20) NOT NULL,
previous_status VARCHAR(20),
-- Error (si aplica)
error_code VARCHAR(20),
error_title VARCHAR(200),
error_message TEXT,
-- Timestamp de Meta
meta_timestamp TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- =====================
-- TABLA: whatsapp.quick_replies
-- Respuestas rápidas predefinidas
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.quick_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
account_id UUID REFERENCES whatsapp.accounts(id) ON DELETE CASCADE,
-- Identificación
shortcut VARCHAR(50) NOT NULL, -- /gracias, /horario
title VARCHAR(200) NOT NULL,
category VARCHAR(50),
-- Contenido
message_type VARCHAR(20) DEFAULT 'text',
content TEXT NOT NULL,
media_url TEXT,
-- Uso
usage_count INTEGER DEFAULT 0,
last_used_at TIMESTAMPTZ,
-- Estado
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id),
UNIQUE(tenant_id, shortcut)
);
-- =====================
-- TABLA: whatsapp.automations
-- Automatizaciones de WhatsApp
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.automations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE,
-- Identificación
name VARCHAR(200) NOT NULL,
description TEXT,
-- Trigger
trigger_type VARCHAR(30) NOT NULL,
-- keyword, first_message, after_hours, no_response, webhook
trigger_config JSONB NOT NULL DEFAULT '{}',
-- keyword: {keywords: ["hola", "info"]}
-- after_hours: {message: "..."}
-- no_response: {delay_minutes: 30}
-- Acción
action_type VARCHAR(30) NOT NULL,
-- send_message, send_template, assign_agent, add_tag, create_ticket
action_config JSONB NOT NULL DEFAULT '{}',
-- Condiciones adicionales
conditions JSONB DEFAULT '[]',
-- Estado
is_active BOOLEAN DEFAULT TRUE,
priority INTEGER DEFAULT 0,
-- Estadísticas
trigger_count INTEGER DEFAULT 0,
last_triggered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id)
);
-- =====================
-- TABLA: whatsapp.broadcasts
-- Envíos masivos de mensajes
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.broadcasts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES whatsapp.accounts(id) ON DELETE CASCADE,
-- Identificación
name VARCHAR(200) NOT NULL,
description TEXT,
-- Template a usar
template_id UUID NOT NULL REFERENCES whatsapp.templates(id),
-- Audiencia
audience_type VARCHAR(30) NOT NULL, -- all, segment, custom, file
audience_filter JSONB DEFAULT '{}',
recipient_count INTEGER DEFAULT 0,
-- Programación
scheduled_at TIMESTAMPTZ,
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
-- Estado
status VARCHAR(20) DEFAULT 'draft',
-- draft, scheduled, sending, completed, cancelled, failed
-- Progreso
sent_count INTEGER DEFAULT 0,
delivered_count INTEGER DEFAULT 0,
read_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
reply_count INTEGER DEFAULT 0,
-- Tiempos
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Costos estimados
estimated_cost DECIMAL(10,2),
actual_cost DECIMAL(10,2),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
created_by UUID REFERENCES auth.users(id)
);
-- =====================
-- TABLA: whatsapp.broadcast_recipients
-- Destinatarios de broadcasts
-- =====================
CREATE TABLE IF NOT EXISTS whatsapp.broadcast_recipients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
broadcast_id UUID NOT NULL REFERENCES whatsapp.broadcasts(id) ON DELETE CASCADE,
contact_id UUID NOT NULL REFERENCES whatsapp.contacts(id) ON DELETE CASCADE,
-- Variables del template
template_variables JSONB DEFAULT '[]',
-- Estado
status VARCHAR(20) DEFAULT 'pending',
-- pending, sent, delivered, read, failed
-- Mensaje enviado
message_id UUID REFERENCES whatsapp.messages(id),
-- Error
error_code VARCHAR(20),
error_message TEXT,
-- Tiempos
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE(broadcast_id, contact_id)
);
-- =====================
-- INDICES
-- =====================
-- Indices para accounts
CREATE INDEX IF NOT EXISTS idx_wa_accounts_tenant ON whatsapp.accounts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wa_accounts_phone ON whatsapp.accounts(phone_number);
CREATE INDEX IF NOT EXISTS idx_wa_accounts_status ON whatsapp.accounts(status);
-- Indices para templates
CREATE INDEX IF NOT EXISTS idx_wa_templates_account ON whatsapp.templates(account_id);
CREATE INDEX IF NOT EXISTS idx_wa_templates_tenant ON whatsapp.templates(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wa_templates_status ON whatsapp.templates(meta_status);
CREATE INDEX IF NOT EXISTS idx_wa_templates_category ON whatsapp.templates(category);
-- Indices para contacts
CREATE INDEX IF NOT EXISTS idx_wa_contacts_tenant ON whatsapp.contacts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wa_contacts_account ON whatsapp.contacts(account_id);
CREATE INDEX IF NOT EXISTS idx_wa_contacts_phone ON whatsapp.contacts(phone_number);
CREATE INDEX IF NOT EXISTS idx_wa_contacts_tags ON whatsapp.contacts USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_wa_contacts_opted_in ON whatsapp.contacts(opted_in) WHERE opted_in = TRUE;
-- Indices para conversations
CREATE INDEX IF NOT EXISTS idx_wa_convos_tenant ON whatsapp.conversations(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wa_convos_contact ON whatsapp.conversations(contact_id);
CREATE INDEX IF NOT EXISTS idx_wa_convos_status ON whatsapp.conversations(status);
CREATE INDEX IF NOT EXISTS idx_wa_convos_assigned ON whatsapp.conversations(assigned_to);
CREATE INDEX IF NOT EXISTS idx_wa_convos_created ON whatsapp.conversations(created_at DESC);
-- Indices para messages
CREATE INDEX IF NOT EXISTS idx_wa_messages_tenant ON whatsapp.messages(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wa_messages_account ON whatsapp.messages(account_id);
CREATE INDEX IF NOT EXISTS idx_wa_messages_contact ON whatsapp.messages(contact_id);
CREATE INDEX IF NOT EXISTS idx_wa_messages_conversation ON whatsapp.messages(conversation_id);
CREATE INDEX IF NOT EXISTS idx_wa_messages_wa_id ON whatsapp.messages(wa_message_id);
CREATE INDEX IF NOT EXISTS idx_wa_messages_status ON whatsapp.messages(status);
CREATE INDEX IF NOT EXISTS idx_wa_messages_created ON whatsapp.messages(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wa_messages_direction ON whatsapp.messages(direction);
-- Indices para status updates
CREATE INDEX IF NOT EXISTS idx_wa_status_message ON whatsapp.message_status_updates(message_id);
-- Indices para quick_replies
CREATE INDEX IF NOT EXISTS idx_wa_quick_replies_tenant ON whatsapp.quick_replies(tenant_id);
CREATE INDEX IF NOT EXISTS idx_wa_quick_replies_shortcut ON whatsapp.quick_replies(shortcut);
-- Indices para automations
CREATE INDEX IF NOT EXISTS idx_wa_automations_account ON whatsapp.automations(account_id);
CREATE INDEX IF NOT EXISTS idx_wa_automations_active ON whatsapp.automations(is_active) WHERE is_active = TRUE;
-- Indices para broadcasts
CREATE INDEX IF NOT EXISTS idx_wa_broadcasts_account ON whatsapp.broadcasts(account_id);
CREATE INDEX IF NOT EXISTS idx_wa_broadcasts_status ON whatsapp.broadcasts(status);
CREATE INDEX IF NOT EXISTS idx_wa_broadcasts_scheduled ON whatsapp.broadcasts(scheduled_at)
WHERE status = 'scheduled';
-- Indices para broadcast_recipients
CREATE INDEX IF NOT EXISTS idx_wa_bcast_recip_broadcast ON whatsapp.broadcast_recipients(broadcast_id);
CREATE INDEX IF NOT EXISTS idx_wa_bcast_recip_status ON whatsapp.broadcast_recipients(status);
-- =====================
-- RLS POLICIES
-- =====================
ALTER TABLE whatsapp.accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_accounts ON whatsapp.accounts
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE whatsapp.templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_templates ON whatsapp.templates
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE whatsapp.contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_contacts ON whatsapp.contacts
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE whatsapp.conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_convos ON whatsapp.conversations
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE whatsapp.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_messages ON whatsapp.messages
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE whatsapp.quick_replies ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_quick ON whatsapp.quick_replies
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE whatsapp.automations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_auto ON whatsapp.automations
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
ALTER TABLE whatsapp.broadcasts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_wa_broadcasts ON whatsapp.broadcasts
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- =====================
-- FUNCIONES
-- =====================
-- Función para obtener o crear contacto
CREATE OR REPLACE FUNCTION whatsapp.get_or_create_contact(
p_account_id UUID,
p_phone_number VARCHAR(20),
p_profile_name VARCHAR(200) DEFAULT NULL,
p_wa_id VARCHAR(50) DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_contact_id UUID;
v_tenant_id UUID;
BEGIN
-- Obtener tenant de la cuenta
SELECT tenant_id INTO v_tenant_id
FROM whatsapp.accounts WHERE id = p_account_id;
-- Buscar contacto existente
SELECT id INTO v_contact_id
FROM whatsapp.contacts
WHERE account_id = p_account_id AND phone_number = p_phone_number;
IF FOUND THEN
-- Actualizar perfil si hay nuevos datos
IF p_profile_name IS NOT NULL OR p_wa_id IS NOT NULL THEN
UPDATE whatsapp.contacts
SET
profile_name = COALESCE(p_profile_name, profile_name),
wa_id = COALESCE(p_wa_id, wa_id),
updated_at = CURRENT_TIMESTAMP
WHERE id = v_contact_id;
END IF;
RETURN v_contact_id;
END IF;
-- Crear nuevo contacto
INSERT INTO whatsapp.contacts (
tenant_id, account_id, phone_number, profile_name, wa_id
) VALUES (
v_tenant_id, p_account_id, p_phone_number, p_profile_name, p_wa_id
) RETURNING id INTO v_contact_id;
RETURN v_contact_id;
END;
$$ LANGUAGE plpgsql;
-- Función para crear o obtener conversación activa
CREATE OR REPLACE FUNCTION whatsapp.get_active_conversation(
p_contact_id UUID
)
RETURNS UUID AS $$
DECLARE
v_conversation_id UUID;
v_contact RECORD;
BEGIN
-- Obtener datos del contacto
SELECT * INTO v_contact FROM whatsapp.contacts WHERE id = p_contact_id;
-- Buscar conversación abierta
SELECT id INTO v_conversation_id
FROM whatsapp.conversations
WHERE contact_id = p_contact_id
AND status IN ('open', 'pending')
ORDER BY created_at DESC
LIMIT 1;
IF FOUND THEN
RETURN v_conversation_id;
END IF;
-- Crear nueva conversación
INSERT INTO whatsapp.conversations (
tenant_id, account_id, contact_id
) VALUES (
v_contact.tenant_id, v_contact.account_id, p_contact_id
) RETURNING id INTO v_conversation_id;
RETURN v_conversation_id;
END;
$$ LANGUAGE plpgsql;
-- Función para registrar mensaje entrante
CREATE OR REPLACE FUNCTION whatsapp.receive_message(
p_account_id UUID,
p_phone_number VARCHAR(20),
p_wa_message_id VARCHAR(100),
p_message_type VARCHAR(20),
p_content TEXT,
p_media_id VARCHAR(100) DEFAULT NULL,
p_media_url TEXT DEFAULT NULL,
p_context_message_id VARCHAR(100) DEFAULT NULL,
p_profile_name VARCHAR(200) DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_message_id UUID;
v_contact_id UUID;
v_conversation_id UUID;
v_tenant_id UUID;
BEGIN
-- Obtener tenant
SELECT tenant_id INTO v_tenant_id
FROM whatsapp.accounts WHERE id = p_account_id;
-- Obtener o crear contacto
v_contact_id := whatsapp.get_or_create_contact(
p_account_id, p_phone_number, p_profile_name
);
-- Actualizar ventana de conversación
UPDATE whatsapp.contacts
SET
conversation_window_expires_at = CURRENT_TIMESTAMP + INTERVAL '24 hours',
can_send_template_only = FALSE,
last_message_at = CURRENT_TIMESTAMP,
last_message_direction = 'inbound',
total_messages_received = total_messages_received + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_contact_id;
-- Obtener conversación activa
v_conversation_id := whatsapp.get_active_conversation(v_contact_id);
-- Crear mensaje
INSERT INTO whatsapp.messages (
tenant_id, account_id, contact_id, conversation_id,
wa_message_id, direction, message_type, content,
media_id, media_url, context_message_id, status
) VALUES (
v_tenant_id, p_account_id, v_contact_id, v_conversation_id,
p_wa_message_id, 'inbound', p_message_type, p_content,
p_media_id, p_media_url, p_context_message_id, 'received'
) RETURNING id INTO v_message_id;
-- Actualizar conversación
UPDATE whatsapp.conversations
SET
message_count = message_count + 1,
unread_count = unread_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_conversation_id;
-- Actualizar estadísticas de la cuenta
UPDATE whatsapp.accounts
SET
total_messages_received = total_messages_received + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_account_id;
RETURN v_message_id;
END;
$$ LANGUAGE plpgsql;
-- Función para enviar mensaje de texto
CREATE OR REPLACE FUNCTION whatsapp.send_text_message(
p_account_id UUID,
p_contact_id UUID,
p_content TEXT,
p_sent_by UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_message_id UUID;
v_conversation_id UUID;
v_contact RECORD;
v_tenant_id UUID;
BEGIN
-- Obtener contacto
SELECT * INTO v_contact FROM whatsapp.contacts WHERE id = p_contact_id;
v_tenant_id := v_contact.tenant_id;
-- Verificar ventana de conversación
IF v_contact.can_send_template_only AND
(v_contact.conversation_window_expires_at IS NULL OR
v_contact.conversation_window_expires_at < CURRENT_TIMESTAMP) THEN
RAISE EXCEPTION 'Cannot send text message outside conversation window. Use template.';
END IF;
-- Obtener conversación
v_conversation_id := whatsapp.get_active_conversation(p_contact_id);
-- Crear mensaje
INSERT INTO whatsapp.messages (
tenant_id, account_id, contact_id, conversation_id,
direction, message_type, content, status
) VALUES (
v_tenant_id, p_account_id, p_contact_id, v_conversation_id,
'outbound', 'text', p_content, 'pending'
) RETURNING id INTO v_message_id;
-- Actualizar contacto
UPDATE whatsapp.contacts
SET
last_message_at = CURRENT_TIMESTAMP,
last_message_direction = 'outbound',
total_messages_sent = total_messages_sent + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_contact_id;
-- Actualizar conversación
UPDATE whatsapp.conversations
SET
message_count = message_count + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = v_conversation_id;
-- Actualizar cuenta
UPDATE whatsapp.accounts
SET
total_messages_sent = total_messages_sent + 1,
messages_sent_today = messages_sent_today + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_account_id;
RETURN v_message_id;
END;
$$ LANGUAGE plpgsql;
-- Función para enviar template
CREATE OR REPLACE FUNCTION whatsapp.send_template_message(
p_account_id UUID,
p_contact_id UUID,
p_template_id UUID,
p_variables JSONB DEFAULT '[]'
)
RETURNS UUID AS $$
DECLARE
v_message_id UUID;
v_conversation_id UUID;
v_template RECORD;
v_contact RECORD;
v_tenant_id UUID;
BEGIN
-- Obtener template
SELECT * INTO v_template FROM whatsapp.templates WHERE id = p_template_id;
IF NOT FOUND OR v_template.meta_status != 'APPROVED' THEN
RAISE EXCEPTION 'Template not found or not approved';
END IF;
-- Obtener contacto
SELECT * INTO v_contact FROM whatsapp.contacts WHERE id = p_contact_id;
v_tenant_id := v_contact.tenant_id;
-- Obtener conversación
v_conversation_id := whatsapp.get_active_conversation(p_contact_id);
-- Crear mensaje
INSERT INTO whatsapp.messages (
tenant_id, account_id, contact_id, conversation_id,
direction, message_type, content,
template_id, template_name, template_variables,
is_billable, cost_category, status
) VALUES (
v_tenant_id, p_account_id, p_contact_id, v_conversation_id,
'outbound', 'template', v_template.body_text,
p_template_id, v_template.name, p_variables,
TRUE, v_template.category, 'pending'
) RETURNING id INTO v_message_id;
-- Actualizar uso del template
UPDATE whatsapp.templates
SET usage_count = usage_count + 1, last_used_at = CURRENT_TIMESTAMP
WHERE id = p_template_id;
-- Actualizar contacto
UPDATE whatsapp.contacts
SET
last_message_at = CURRENT_TIMESTAMP,
last_message_direction = 'outbound',
total_messages_sent = total_messages_sent + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_contact_id;
-- Actualizar cuenta
UPDATE whatsapp.accounts
SET
total_messages_sent = total_messages_sent + 1,
messages_sent_today = messages_sent_today + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = p_account_id;
RETURN v_message_id;
END;
$$ LANGUAGE plpgsql;
-- Función para actualizar estado de mensaje
CREATE OR REPLACE FUNCTION whatsapp.update_message_status(
p_wa_message_id VARCHAR(100),
p_status VARCHAR(20),
p_meta_timestamp TIMESTAMPTZ DEFAULT NULL,
p_error_code VARCHAR(20) DEFAULT NULL,
p_error_message TEXT DEFAULT NULL
)
RETURNS BOOLEAN AS $$
DECLARE
v_message RECORD;
BEGIN
SELECT * INTO v_message
FROM whatsapp.messages
WHERE wa_message_id = p_wa_message_id;
IF NOT FOUND THEN
RETURN FALSE;
END IF;
-- Registrar actualización de estado
INSERT INTO whatsapp.message_status_updates (
message_id, status, previous_status, error_code, error_message, meta_timestamp
) VALUES (
v_message.id, p_status, v_message.status, p_error_code, p_error_message, p_meta_timestamp
);
-- Actualizar mensaje
UPDATE whatsapp.messages
SET
status = p_status,
status_updated_at = CURRENT_TIMESTAMP,
sent_at = CASE WHEN p_status = 'sent' AND sent_at IS NULL THEN COALESCE(p_meta_timestamp, CURRENT_TIMESTAMP) ELSE sent_at END,
delivered_at = CASE WHEN p_status = 'delivered' THEN COALESCE(p_meta_timestamp, CURRENT_TIMESTAMP) ELSE delivered_at END,
read_at = CASE WHEN p_status = 'read' THEN COALESCE(p_meta_timestamp, CURRENT_TIMESTAMP) ELSE read_at END,
error_code = COALESCE(p_error_code, error_code),
error_message = COALESCE(p_error_message, error_message)
WHERE id = v_message.id;
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;
-- Función para obtener estadísticas de cuenta
CREATE OR REPLACE FUNCTION whatsapp.get_account_stats(
p_account_id UUID,
p_days INTEGER DEFAULT 30
)
RETURNS TABLE (
total_sent BIGINT,
total_received BIGINT,
total_delivered BIGINT,
total_read BIGINT,
total_failed BIGINT,
delivery_rate DECIMAL,
read_rate DECIMAL,
active_contacts BIGINT,
messages_by_day JSONB
) AS $$
BEGIN
RETURN QUERY
SELECT
COUNT(*) FILTER (WHERE m.direction = 'outbound')::BIGINT as total_sent,
COUNT(*) FILTER (WHERE m.direction = 'inbound')::BIGINT as total_received,
COUNT(*) FILTER (WHERE m.status = 'delivered')::BIGINT as total_delivered,
COUNT(*) FILTER (WHERE m.status = 'read')::BIGINT as total_read,
COUNT(*) FILTER (WHERE m.status = 'failed')::BIGINT as total_failed,
ROUND(
COUNT(*) FILTER (WHERE m.status IN ('delivered', 'read'))::DECIMAL /
NULLIF(COUNT(*) FILTER (WHERE m.direction = 'outbound'), 0) * 100, 2
) as delivery_rate,
ROUND(
COUNT(*) FILTER (WHERE m.status = 'read')::DECIMAL /
NULLIF(COUNT(*) FILTER (WHERE m.status IN ('delivered', 'read')), 0) * 100, 2
) as read_rate,
COUNT(DISTINCT m.contact_id)::BIGINT as active_contacts,
jsonb_object_agg(
DATE(m.created_at)::TEXT,
day_count
) as messages_by_day
FROM whatsapp.messages m
LEFT JOIN (
SELECT DATE(created_at) as day, COUNT(*) as day_count
FROM whatsapp.messages
WHERE account_id = p_account_id
AND created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
GROUP BY DATE(created_at)
) days ON TRUE
WHERE m.account_id = p_account_id
AND m.created_at > CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
END;
$$ LANGUAGE plpgsql STABLE;
-- Función para resetear límite diario
CREATE OR REPLACE FUNCTION whatsapp.reset_daily_limits()
RETURNS INTEGER AS $$
DECLARE
updated_count INTEGER;
BEGIN
UPDATE whatsapp.accounts
SET
messages_sent_today = 0,
last_limit_reset = CURRENT_TIMESTAMP
WHERE DATE(last_limit_reset) < CURRENT_DATE
OR last_limit_reset IS NULL;
GET DIAGNOSTICS updated_count = ROW_COUNT;
RETURN updated_count;
END;
$$ LANGUAGE plpgsql;
-- =====================
-- TRIGGERS
-- =====================
CREATE OR REPLACE FUNCTION whatsapp.update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_wa_accounts_updated_at
BEFORE UPDATE ON whatsapp.accounts
FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp();
CREATE TRIGGER trg_wa_templates_updated_at
BEFORE UPDATE ON whatsapp.templates
FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp();
CREATE TRIGGER trg_wa_contacts_updated_at
BEFORE UPDATE ON whatsapp.contacts
FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp();
CREATE TRIGGER trg_wa_convos_updated_at
BEFORE UPDATE ON whatsapp.conversations
FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp();
CREATE TRIGGER trg_wa_quick_updated_at
BEFORE UPDATE ON whatsapp.quick_replies
FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp();
CREATE TRIGGER trg_wa_auto_updated_at
BEFORE UPDATE ON whatsapp.automations
FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp();
CREATE TRIGGER trg_wa_bcast_updated_at
BEFORE UPDATE ON whatsapp.broadcasts
FOR EACH ROW EXECUTE FUNCTION whatsapp.update_timestamp();
-- =====================
-- COMENTARIOS
-- =====================
COMMENT ON TABLE whatsapp.accounts IS 'Cuentas de WhatsApp Business configuradas por tenant';
COMMENT ON TABLE whatsapp.templates IS 'Templates de mensaje aprobados por Meta';
COMMENT ON TABLE whatsapp.contacts IS 'Contactos de WhatsApp con historial de interacción';
COMMENT ON TABLE whatsapp.conversations IS 'Conversaciones (hilos de chat) con contactos';
COMMENT ON TABLE whatsapp.messages IS 'Mensajes enviados y recibidos';
COMMENT ON TABLE whatsapp.message_status_updates IS 'Historial de cambios de estado de mensajes';
COMMENT ON TABLE whatsapp.quick_replies IS 'Respuestas rápidas predefinidas';
COMMENT ON TABLE whatsapp.automations IS 'Reglas de automatización de respuestas';
COMMENT ON TABLE whatsapp.broadcasts IS 'Envíos masivos de mensajes';
COMMENT ON TABLE whatsapp.broadcast_recipients IS 'Destinatarios de broadcasts';
COMMENT ON FUNCTION whatsapp.receive_message IS 'Registra un mensaje entrante de WhatsApp';
COMMENT ON FUNCTION whatsapp.send_text_message IS 'Envía un mensaje de texto';
COMMENT ON FUNCTION whatsapp.send_template_message IS 'Envía un mensaje de template';
COMMENT ON FUNCTION whatsapp.update_message_status IS 'Actualiza el estado de un mensaje desde webhook';