- 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>
712 lines
25 KiB
PL/PgSQL
712 lines
25 KiB
PL/PgSQL
-- =============================================================
|
|
-- ARCHIVO: 09-notifications.sql
|
|
-- DESCRIPCION: Sistema de notificaciones, templates, preferencias
|
|
-- VERSION: 1.0.0
|
|
-- PROYECTO: ERP-Core V2
|
|
-- FECHA: 2026-01-10
|
|
-- EPIC: SAAS-NOTIFICATIONS (EPIC-SAAS-003)
|
|
-- HISTORIAS: US-040, US-041, US-042
|
|
-- =============================================================
|
|
|
|
-- =====================
|
|
-- SCHEMA: notifications
|
|
-- =====================
|
|
CREATE SCHEMA IF NOT EXISTS notifications;
|
|
|
|
-- =====================
|
|
-- TABLA: notifications.channels
|
|
-- Canales de notificacion disponibles
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS notifications.channels (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Identificacion
|
|
code VARCHAR(30) NOT NULL UNIQUE,
|
|
name VARCHAR(100) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Tipo
|
|
channel_type VARCHAR(30) NOT NULL, -- email, sms, push, whatsapp, in_app, webhook
|
|
|
|
-- Configuracion del proveedor
|
|
provider VARCHAR(50), -- sendgrid, twilio, firebase, meta, custom
|
|
provider_config JSONB DEFAULT '{}',
|
|
|
|
-- Limites
|
|
rate_limit_per_minute INTEGER DEFAULT 60,
|
|
rate_limit_per_hour INTEGER DEFAULT 1000,
|
|
rate_limit_per_day INTEGER DEFAULT 10000,
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
is_default BOOLEAN DEFAULT FALSE,
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: notifications.templates
|
|
-- Templates de notificaciones
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS notifications.templates (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID REFERENCES auth.tenants(id) ON DELETE CASCADE, -- NULL = global template
|
|
|
|
-- Identificacion
|
|
code VARCHAR(100) NOT NULL,
|
|
name VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
category VARCHAR(50), -- system, marketing, transactional, alert
|
|
|
|
-- Canal objetivo
|
|
channel_type VARCHAR(30) NOT NULL, -- email, sms, push, whatsapp, in_app
|
|
|
|
-- Contenido
|
|
subject VARCHAR(500), -- Para email
|
|
body_template TEXT NOT NULL,
|
|
body_html TEXT, -- Para email HTML
|
|
|
|
-- Variables disponibles
|
|
available_variables JSONB DEFAULT '[]',
|
|
-- Ejemplo: ["user_name", "company_name", "action_url"]
|
|
|
|
-- Configuracion
|
|
default_locale VARCHAR(10) DEFAULT 'es-MX',
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
is_system BOOLEAN DEFAULT FALSE, -- Templates del sistema no editables
|
|
|
|
-- Versionamiento
|
|
version INTEGER DEFAULT 1,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id),
|
|
updated_by UUID REFERENCES auth.users(id),
|
|
|
|
UNIQUE(tenant_id, code, channel_type)
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: notifications.template_translations
|
|
-- Traducciones de templates
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS notifications.template_translations (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
template_id UUID NOT NULL REFERENCES notifications.templates(id) ON DELETE CASCADE,
|
|
|
|
-- Idioma
|
|
locale VARCHAR(10) NOT NULL, -- es-MX, en-US, etc.
|
|
|
|
-- Contenido traducido
|
|
subject VARCHAR(500),
|
|
body_template TEXT NOT NULL,
|
|
body_html TEXT,
|
|
|
|
-- Estado
|
|
is_active BOOLEAN DEFAULT TRUE,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(template_id, locale)
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: notifications.preferences
|
|
-- Preferencias de notificacion por usuario
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS notifications.preferences (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Preferencias globales
|
|
global_enabled BOOLEAN DEFAULT TRUE,
|
|
quiet_hours_start TIME,
|
|
quiet_hours_end TIME,
|
|
timezone VARCHAR(50) DEFAULT 'America/Mexico_City',
|
|
|
|
-- Preferencias por canal
|
|
email_enabled BOOLEAN DEFAULT TRUE,
|
|
sms_enabled BOOLEAN DEFAULT TRUE,
|
|
push_enabled BOOLEAN DEFAULT TRUE,
|
|
whatsapp_enabled BOOLEAN DEFAULT FALSE,
|
|
in_app_enabled BOOLEAN DEFAULT TRUE,
|
|
|
|
-- Preferencias por categoria
|
|
category_preferences JSONB DEFAULT '{}',
|
|
-- Ejemplo: {"marketing": false, "alerts": true, "reports": {"email": true, "push": false}}
|
|
|
|
-- Frecuencia de digest
|
|
digest_frequency VARCHAR(20) DEFAULT 'instant', -- instant, hourly, daily, weekly
|
|
digest_day INTEGER, -- 0-6 para weekly
|
|
digest_hour INTEGER DEFAULT 9, -- Hora del dia para daily/weekly
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
UNIQUE(user_id, tenant_id)
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: notifications.notifications
|
|
-- Notificaciones enviadas/pendientes
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS notifications.notifications (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Destinatario
|
|
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
recipient_email VARCHAR(255),
|
|
recipient_phone VARCHAR(20),
|
|
recipient_device_id UUID REFERENCES auth.devices(id),
|
|
|
|
-- Template usado
|
|
template_id UUID REFERENCES notifications.templates(id),
|
|
template_code VARCHAR(100),
|
|
|
|
-- Canal
|
|
channel_type VARCHAR(30) NOT NULL,
|
|
channel_id UUID REFERENCES notifications.channels(id),
|
|
|
|
-- Contenido renderizado
|
|
subject VARCHAR(500),
|
|
body TEXT NOT NULL,
|
|
body_html TEXT,
|
|
|
|
-- Variables usadas
|
|
variables JSONB DEFAULT '{}',
|
|
|
|
-- Contexto
|
|
context_type VARCHAR(50), -- sale, attendance, inventory, system
|
|
context_id UUID,
|
|
|
|
-- Prioridad
|
|
priority VARCHAR(20) DEFAULT 'normal', -- low, normal, high, urgent
|
|
|
|
-- Estado
|
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
|
-- pending, queued, sending, sent, delivered, read, failed, cancelled
|
|
|
|
-- Tracking
|
|
queued_at TIMESTAMPTZ,
|
|
sent_at TIMESTAMPTZ,
|
|
delivered_at TIMESTAMPTZ,
|
|
read_at TIMESTAMPTZ,
|
|
failed_at TIMESTAMPTZ,
|
|
|
|
-- Errores
|
|
error_message TEXT,
|
|
retry_count INTEGER DEFAULT 0,
|
|
max_retries INTEGER DEFAULT 3,
|
|
next_retry_at TIMESTAMPTZ,
|
|
|
|
-- Proveedor
|
|
provider_message_id VARCHAR(255),
|
|
provider_response JSONB DEFAULT '{}',
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}',
|
|
|
|
-- Expiracion
|
|
expires_at TIMESTAMPTZ,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: notifications.notification_batches
|
|
-- Lotes de notificaciones masivas
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS notifications.notification_batches (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
|
|
-- Identificacion
|
|
name VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
|
|
-- Template
|
|
template_id UUID REFERENCES notifications.templates(id),
|
|
channel_type VARCHAR(30) NOT NULL,
|
|
|
|
-- Audiencia
|
|
audience_type VARCHAR(30) NOT NULL, -- all_users, segment, custom
|
|
audience_filter JSONB DEFAULT '{}',
|
|
|
|
-- Contenido
|
|
variables JSONB DEFAULT '{}',
|
|
|
|
-- Programacion
|
|
scheduled_at TIMESTAMPTZ,
|
|
|
|
-- Estado
|
|
status VARCHAR(20) NOT NULL DEFAULT 'draft',
|
|
-- draft, scheduled, processing, completed, failed, cancelled
|
|
|
|
-- Estadisticas
|
|
total_recipients INTEGER DEFAULT 0,
|
|
sent_count INTEGER DEFAULT 0,
|
|
delivered_count INTEGER DEFAULT 0,
|
|
failed_count INTEGER DEFAULT 0,
|
|
read_count INTEGER DEFAULT 0,
|
|
|
|
-- Tiempos
|
|
started_at TIMESTAMPTZ,
|
|
completed_at TIMESTAMPTZ,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
created_by UUID REFERENCES auth.users(id)
|
|
);
|
|
|
|
-- =====================
|
|
-- TABLA: notifications.in_app_notifications
|
|
-- Notificaciones in-app (centro de notificaciones)
|
|
-- =====================
|
|
CREATE TABLE IF NOT EXISTS notifications.in_app_notifications (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
|
|
-- Contenido
|
|
title VARCHAR(200) NOT NULL,
|
|
message TEXT NOT NULL,
|
|
icon VARCHAR(50),
|
|
color VARCHAR(20),
|
|
|
|
-- Accion
|
|
action_type VARCHAR(30), -- link, modal, function
|
|
action_url TEXT,
|
|
action_data JSONB DEFAULT '{}',
|
|
|
|
-- Categoria
|
|
category VARCHAR(50), -- info, success, warning, error, task
|
|
|
|
-- Contexto
|
|
context_type VARCHAR(50),
|
|
context_id UUID,
|
|
|
|
-- Estado
|
|
is_read BOOLEAN DEFAULT FALSE,
|
|
read_at TIMESTAMPTZ,
|
|
is_archived BOOLEAN DEFAULT FALSE,
|
|
archived_at TIMESTAMPTZ,
|
|
|
|
-- Prioridad y expiracion
|
|
priority VARCHAR(20) DEFAULT 'normal',
|
|
expires_at TIMESTAMPTZ,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- =====================
|
|
-- INDICES
|
|
-- =====================
|
|
|
|
-- Indices para channels
|
|
CREATE INDEX IF NOT EXISTS idx_channels_type ON notifications.channels(channel_type);
|
|
CREATE INDEX IF NOT EXISTS idx_channels_active ON notifications.channels(is_active) WHERE is_active = TRUE;
|
|
|
|
-- Indices para templates
|
|
CREATE INDEX IF NOT EXISTS idx_templates_tenant ON notifications.templates(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_templates_code ON notifications.templates(code);
|
|
CREATE INDEX IF NOT EXISTS idx_templates_channel ON notifications.templates(channel_type);
|
|
CREATE INDEX IF NOT EXISTS idx_templates_active ON notifications.templates(is_active) WHERE is_active = TRUE;
|
|
|
|
-- Indices para template_translations
|
|
CREATE INDEX IF NOT EXISTS idx_template_trans_template ON notifications.template_translations(template_id);
|
|
CREATE INDEX IF NOT EXISTS idx_template_trans_locale ON notifications.template_translations(locale);
|
|
|
|
-- Indices para preferences
|
|
CREATE INDEX IF NOT EXISTS idx_preferences_user ON notifications.preferences(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_preferences_tenant ON notifications.preferences(tenant_id);
|
|
|
|
-- Indices para notifications
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_tenant ON notifications.notifications(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications.notifications(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_status ON notifications.notifications(status);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_channel ON notifications.notifications(channel_type);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications.notifications(context_type, context_id);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_pending ON notifications.notifications(status, next_retry_at)
|
|
WHERE status IN ('pending', 'queued');
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications.notifications(created_at DESC);
|
|
|
|
-- Indices para notification_batches
|
|
CREATE INDEX IF NOT EXISTS idx_batches_tenant ON notifications.notification_batches(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_batches_status ON notifications.notification_batches(status);
|
|
CREATE INDEX IF NOT EXISTS idx_batches_scheduled ON notifications.notification_batches(scheduled_at)
|
|
WHERE status = 'scheduled';
|
|
|
|
-- Indices para in_app_notifications
|
|
CREATE INDEX IF NOT EXISTS idx_in_app_user ON notifications.in_app_notifications(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_in_app_tenant ON notifications.in_app_notifications(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_in_app_unread ON notifications.in_app_notifications(user_id, is_read)
|
|
WHERE is_read = FALSE;
|
|
CREATE INDEX IF NOT EXISTS idx_in_app_created ON notifications.in_app_notifications(created_at DESC);
|
|
|
|
-- =====================
|
|
-- RLS POLICIES
|
|
-- =====================
|
|
|
|
-- Channels son globales (lectura publica, escritura admin)
|
|
ALTER TABLE notifications.channels ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY public_read_channels ON notifications.channels
|
|
FOR SELECT USING (true);
|
|
|
|
-- Templates: globales (tenant_id NULL) o por tenant
|
|
ALTER TABLE notifications.templates ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_or_global_templates ON notifications.templates
|
|
FOR SELECT USING (
|
|
tenant_id IS NULL
|
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
|
);
|
|
|
|
ALTER TABLE notifications.template_translations ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY template_trans_access ON notifications.template_translations
|
|
FOR SELECT USING (
|
|
template_id IN (
|
|
SELECT id FROM notifications.templates
|
|
WHERE tenant_id IS NULL
|
|
OR tenant_id = current_setting('app.current_tenant_id', true)::uuid
|
|
)
|
|
);
|
|
|
|
-- Preferences por tenant
|
|
ALTER TABLE notifications.preferences ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_preferences ON notifications.preferences
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Notifications por tenant
|
|
ALTER TABLE notifications.notifications ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_notifications ON notifications.notifications
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- Batches por tenant
|
|
ALTER TABLE notifications.notification_batches ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_batches ON notifications.notification_batches
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- In-app notifications por tenant
|
|
ALTER TABLE notifications.in_app_notifications ENABLE ROW LEVEL SECURITY;
|
|
CREATE POLICY tenant_isolation_in_app ON notifications.in_app_notifications
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
|
|
|
|
-- =====================
|
|
-- FUNCIONES
|
|
-- =====================
|
|
|
|
-- Funcion para obtener template con fallback a global
|
|
CREATE OR REPLACE FUNCTION notifications.get_template(
|
|
p_tenant_id UUID,
|
|
p_code VARCHAR(100),
|
|
p_channel_type VARCHAR(30),
|
|
p_locale VARCHAR(10) DEFAULT 'es-MX'
|
|
)
|
|
RETURNS TABLE (
|
|
template_id UUID,
|
|
subject VARCHAR(500),
|
|
body_template TEXT,
|
|
body_html TEXT,
|
|
available_variables JSONB
|
|
) AS $$
|
|
BEGIN
|
|
RETURN QUERY
|
|
SELECT
|
|
t.id as template_id,
|
|
COALESCE(tt.subject, t.subject) as subject,
|
|
COALESCE(tt.body_template, t.body_template) as body_template,
|
|
COALESCE(tt.body_html, t.body_html) as body_html,
|
|
t.available_variables
|
|
FROM notifications.templates t
|
|
LEFT JOIN notifications.template_translations tt
|
|
ON tt.template_id = t.id AND tt.locale = p_locale AND tt.is_active = TRUE
|
|
WHERE t.code = p_code
|
|
AND t.channel_type = p_channel_type
|
|
AND t.is_active = TRUE
|
|
AND (t.tenant_id = p_tenant_id OR t.tenant_id IS NULL)
|
|
ORDER BY t.tenant_id NULLS LAST -- Priorizar template del tenant
|
|
LIMIT 1;
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
-- Funcion para verificar preferencias de usuario
|
|
CREATE OR REPLACE FUNCTION notifications.should_send(
|
|
p_user_id UUID,
|
|
p_tenant_id UUID,
|
|
p_channel_type VARCHAR(30),
|
|
p_category VARCHAR(50) DEFAULT NULL
|
|
)
|
|
RETURNS BOOLEAN AS $$
|
|
DECLARE
|
|
v_prefs RECORD;
|
|
v_channel_enabled BOOLEAN;
|
|
v_category_enabled BOOLEAN;
|
|
v_in_quiet_hours BOOLEAN;
|
|
BEGIN
|
|
-- Obtener preferencias
|
|
SELECT * INTO v_prefs
|
|
FROM notifications.preferences
|
|
WHERE user_id = p_user_id AND tenant_id = p_tenant_id;
|
|
|
|
-- Si no hay preferencias, permitir por defecto
|
|
IF NOT FOUND THEN
|
|
RETURN TRUE;
|
|
END IF;
|
|
|
|
-- Verificar si las notificaciones estan habilitadas globalmente
|
|
IF NOT v_prefs.global_enabled THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
|
|
-- Verificar canal especifico
|
|
v_channel_enabled := CASE p_channel_type
|
|
WHEN 'email' THEN v_prefs.email_enabled
|
|
WHEN 'sms' THEN v_prefs.sms_enabled
|
|
WHEN 'push' THEN v_prefs.push_enabled
|
|
WHEN 'whatsapp' THEN v_prefs.whatsapp_enabled
|
|
WHEN 'in_app' THEN v_prefs.in_app_enabled
|
|
ELSE TRUE
|
|
END;
|
|
|
|
IF NOT v_channel_enabled THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
|
|
-- Verificar categoria si se proporciona
|
|
IF p_category IS NOT NULL AND v_prefs.category_preferences ? p_category THEN
|
|
v_category_enabled := (v_prefs.category_preferences->>p_category)::boolean;
|
|
IF NOT v_category_enabled THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
END IF;
|
|
|
|
-- Verificar horas de silencio
|
|
IF v_prefs.quiet_hours_start IS NOT NULL AND v_prefs.quiet_hours_end IS NOT NULL THEN
|
|
v_in_quiet_hours := CURRENT_TIME BETWEEN v_prefs.quiet_hours_start AND v_prefs.quiet_hours_end;
|
|
IF v_in_quiet_hours AND p_channel_type IN ('push', 'sms') THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
END IF;
|
|
|
|
RETURN TRUE;
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
-- Funcion para encolar notificacion
|
|
CREATE OR REPLACE FUNCTION notifications.enqueue_notification(
|
|
p_tenant_id UUID,
|
|
p_user_id UUID,
|
|
p_template_code VARCHAR(100),
|
|
p_channel_type VARCHAR(30),
|
|
p_variables JSONB DEFAULT '{}',
|
|
p_context_type VARCHAR(50) DEFAULT NULL,
|
|
p_context_id UUID DEFAULT NULL,
|
|
p_priority VARCHAR(20) DEFAULT 'normal'
|
|
)
|
|
RETURNS UUID AS $$
|
|
DECLARE
|
|
v_template RECORD;
|
|
v_notification_id UUID;
|
|
v_subject VARCHAR(500);
|
|
v_body TEXT;
|
|
v_body_html TEXT;
|
|
BEGIN
|
|
-- Verificar preferencias
|
|
IF NOT notifications.should_send(p_user_id, p_tenant_id, p_channel_type) THEN
|
|
RETURN NULL;
|
|
END IF;
|
|
|
|
-- Obtener template
|
|
SELECT * INTO v_template
|
|
FROM notifications.get_template(p_tenant_id, p_template_code, p_channel_type);
|
|
|
|
IF v_template.template_id IS NULL THEN
|
|
RAISE EXCEPTION 'Template not found: %', p_template_code;
|
|
END IF;
|
|
|
|
-- TODO: Renderizar template con variables (se hara en el backend)
|
|
v_subject := v_template.subject;
|
|
v_body := v_template.body_template;
|
|
v_body_html := v_template.body_html;
|
|
|
|
-- Crear notificacion
|
|
INSERT INTO notifications.notifications (
|
|
tenant_id, user_id, template_id, template_code,
|
|
channel_type, subject, body, body_html,
|
|
variables, context_type, context_id, priority,
|
|
status, queued_at
|
|
) VALUES (
|
|
p_tenant_id, p_user_id, v_template.template_id, p_template_code,
|
|
p_channel_type, v_subject, v_body, v_body_html,
|
|
p_variables, p_context_type, p_context_id, p_priority,
|
|
'queued', CURRENT_TIMESTAMP
|
|
) RETURNING id INTO v_notification_id;
|
|
|
|
RETURN v_notification_id;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Funcion para marcar notificacion como leida
|
|
CREATE OR REPLACE FUNCTION notifications.mark_as_read(p_notification_id UUID)
|
|
RETURNS BOOLEAN AS $$
|
|
BEGIN
|
|
UPDATE notifications.in_app_notifications
|
|
SET is_read = TRUE, read_at = CURRENT_TIMESTAMP
|
|
WHERE id = p_notification_id AND is_read = FALSE;
|
|
|
|
RETURN FOUND;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- Funcion para obtener conteo de no leidas
|
|
CREATE OR REPLACE FUNCTION notifications.get_unread_count(p_user_id UUID, p_tenant_id UUID)
|
|
RETURNS INTEGER AS $$
|
|
BEGIN
|
|
RETURN (
|
|
SELECT COUNT(*)::INTEGER
|
|
FROM notifications.in_app_notifications
|
|
WHERE user_id = p_user_id
|
|
AND tenant_id = p_tenant_id
|
|
AND is_read = FALSE
|
|
AND is_archived = FALSE
|
|
AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
|
|
);
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
-- Funcion para limpiar notificaciones antiguas
|
|
CREATE OR REPLACE FUNCTION notifications.cleanup_old_notifications(p_days INTEGER DEFAULT 90)
|
|
RETURNS INTEGER AS $$
|
|
DECLARE
|
|
deleted_count INTEGER;
|
|
BEGIN
|
|
-- Eliminar notificaciones enviadas antiguas
|
|
DELETE FROM notifications.notifications
|
|
WHERE created_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL
|
|
AND status IN ('sent', 'delivered', 'read', 'failed', 'cancelled');
|
|
|
|
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
|
|
-- Eliminar in-app archivadas antiguas
|
|
DELETE FROM notifications.in_app_notifications
|
|
WHERE archived_at IS NOT NULL
|
|
AND archived_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL;
|
|
|
|
RETURN deleted_count;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
-- =====================
|
|
-- TRIGGERS
|
|
-- =====================
|
|
|
|
-- Trigger para updated_at
|
|
CREATE OR REPLACE FUNCTION notifications.update_timestamp()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER trg_channels_updated_at
|
|
BEFORE UPDATE ON notifications.channels
|
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_templates_updated_at
|
|
BEFORE UPDATE ON notifications.templates
|
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_preferences_updated_at
|
|
BEFORE UPDATE ON notifications.preferences
|
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_notifications_updated_at
|
|
BEFORE UPDATE ON notifications.notifications
|
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
|
|
|
CREATE TRIGGER trg_batches_updated_at
|
|
BEFORE UPDATE ON notifications.notification_batches
|
|
FOR EACH ROW EXECUTE FUNCTION notifications.update_timestamp();
|
|
|
|
-- =====================
|
|
-- SEED DATA: Canales
|
|
-- =====================
|
|
INSERT INTO notifications.channels (code, name, channel_type, provider, is_active, is_default) VALUES
|
|
('email_sendgrid', 'Email (SendGrid)', 'email', 'sendgrid', TRUE, TRUE),
|
|
('email_smtp', 'Email (SMTP)', 'email', 'smtp', TRUE, FALSE),
|
|
('sms_twilio', 'SMS (Twilio)', 'sms', 'twilio', TRUE, TRUE),
|
|
('push_firebase', 'Push (Firebase)', 'push', 'firebase', TRUE, TRUE),
|
|
('whatsapp_meta', 'WhatsApp (Meta)', 'whatsapp', 'meta', FALSE, FALSE),
|
|
('in_app', 'In-App', 'in_app', 'internal', TRUE, TRUE)
|
|
ON CONFLICT (code) DO NOTHING;
|
|
|
|
-- =====================
|
|
-- SEED DATA: Templates del Sistema
|
|
-- =====================
|
|
INSERT INTO notifications.templates (code, name, channel_type, subject, body_template, category, is_system, available_variables) VALUES
|
|
-- Email templates
|
|
('welcome', 'Bienvenida', 'email', 'Bienvenido a {{company_name}}',
|
|
'Hola {{user_name}},\n\nBienvenido a {{company_name}}. Tu cuenta ha sido creada exitosamente.\n\nPuedes acceder desde: {{login_url}}\n\nSaludos,\nEl equipo de {{company_name}}',
|
|
'system', TRUE, '["user_name", "company_name", "login_url"]'),
|
|
|
|
('password_reset', 'Recuperar Contraseña', 'email', 'Recupera tu contraseña - {{company_name}}',
|
|
'Hola {{user_name}},\n\nHemos recibido una solicitud para recuperar tu contraseña.\n\nHaz clic aquí para restablecerla: {{reset_url}}\n\nEste enlace expira en {{expiry_hours}} horas.\n\nSi no solicitaste esto, ignora este correo.',
|
|
'system', TRUE, '["user_name", "reset_url", "expiry_hours", "company_name"]'),
|
|
|
|
('invitation', 'Invitación', 'email', 'Has sido invitado a {{company_name}}',
|
|
'Hola,\n\n{{inviter_name}} te ha invitado a unirte a {{company_name}} con el rol de {{role_name}}.\n\nAcepta la invitación aquí: {{invitation_url}}\n\nEsta invitación expira el {{expiry_date}}.',
|
|
'system', TRUE, '["inviter_name", "company_name", "role_name", "invitation_url", "expiry_date"]'),
|
|
|
|
('mfa_code', 'Código de Verificación', 'email', 'Tu código de verificación: {{code}}',
|
|
'Tu código de verificación es: {{code}}\n\nEste código expira en {{expiry_minutes}} minutos.\n\nSi no solicitaste esto, cambia tu contraseña inmediatamente.',
|
|
'system', TRUE, '["code", "expiry_minutes"]'),
|
|
|
|
-- Push templates
|
|
('attendance_reminder', 'Recordatorio de Asistencia', 'push', NULL,
|
|
'{{user_name}}, no olvides registrar tu {{attendance_type}} de hoy.',
|
|
'transactional', TRUE, '["user_name", "attendance_type"]'),
|
|
|
|
('low_stock_alert', 'Alerta de Stock Bajo', 'push', NULL,
|
|
'Stock bajo: {{product_name}} tiene solo {{quantity}} unidades en {{branch_name}}.',
|
|
'alert', TRUE, '["product_name", "quantity", "branch_name"]'),
|
|
|
|
-- In-app templates
|
|
('task_assigned', 'Tarea Asignada', 'in_app', NULL,
|
|
'{{assigner_name}} te ha asignado una nueva tarea: {{task_title}}',
|
|
'transactional', TRUE, '["assigner_name", "task_title"]'),
|
|
|
|
('payment_received', 'Pago Recibido', 'in_app', NULL,
|
|
'Se ha recibido un pago de ${{amount}} {{currency}} de {{customer_name}}.',
|
|
'transactional', TRUE, '["amount", "currency", "customer_name"]')
|
|
|
|
ON CONFLICT (tenant_id, code, channel_type) DO NOTHING;
|
|
|
|
-- =====================
|
|
-- COMENTARIOS
|
|
-- =====================
|
|
COMMENT ON TABLE notifications.channels IS 'Canales de notificacion disponibles (email, sms, push, etc.)';
|
|
COMMENT ON TABLE notifications.templates IS 'Templates de notificaciones con soporte multi-idioma';
|
|
COMMENT ON TABLE notifications.template_translations IS 'Traducciones de templates de notificaciones';
|
|
COMMENT ON TABLE notifications.preferences IS 'Preferencias de notificacion por usuario';
|
|
COMMENT ON TABLE notifications.notifications IS 'Cola y log de notificaciones';
|
|
COMMENT ON TABLE notifications.notification_batches IS 'Lotes de notificaciones masivas';
|
|
COMMENT ON TABLE notifications.in_app_notifications IS 'Notificaciones in-app para centro de notificaciones';
|
|
|
|
COMMENT ON FUNCTION notifications.get_template IS 'Obtiene template con fallback a template global';
|
|
COMMENT ON FUNCTION notifications.should_send IS 'Verifica si se debe enviar notificacion segun preferencias';
|
|
COMMENT ON FUNCTION notifications.enqueue_notification IS 'Encola una notificacion para envio';
|
|
COMMENT ON FUNCTION notifications.get_unread_count IS 'Obtiene conteo de notificaciones no leidas';
|