template-saas-database-v2/ddl/schemas/notifications/tables/02-extended-notifications.sql
rckrdmrd 3ce06fbce4 Initial commit - Database de template-saas migrado desde monorepo
Migración desde workspace-v2/projects/template-saas/apps/database
Este repositorio es parte del estándar multi-repo v2

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

507 lines
17 KiB
PL/PgSQL

-- ============================================
-- TEMPLATE-SAAS: Notifications Extended Tables
-- Schema: notifications
-- Version: 2.0.0
-- Fecha: 2026-01-07
-- Basado en: ET-SAAS-007-notifications-v2
-- Nota: Enums definidos en 02-enums.sql
-- ============================================
-- ============================================
-- TABLA: user_devices
-- Dispositivos registrados para push notifications
-- ============================================
CREATE TABLE IF NOT EXISTS notifications.user_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Device info
device_type notifications.device_type NOT NULL DEFAULT 'web',
device_token TEXT NOT NULL, -- PushSubscription JSON para Web Push
device_name VARCHAR(100), -- "Chrome en Windows", "Safari en macOS"
-- Browser/OS info
browser VARCHAR(50),
browser_version VARCHAR(20),
os VARCHAR(50),
os_version VARCHAR(20),
-- Status
is_active BOOLEAN DEFAULT TRUE,
-- Timestamps
last_used_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
-- Constraints
CONSTRAINT unique_user_device_token UNIQUE (user_id, device_token)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_user_devices_user ON notifications.user_devices(user_id) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_user_devices_tenant ON notifications.user_devices(tenant_id);
CREATE INDEX IF NOT EXISTS idx_user_devices_active ON notifications.user_devices(is_active, last_used_at DESC);
-- RLS
ALTER TABLE notifications.user_devices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS user_devices_tenant_isolation ON notifications.user_devices;
CREATE POLICY user_devices_tenant_isolation ON notifications.user_devices
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
COMMENT ON TABLE notifications.user_devices IS 'Dispositivos registrados para push notifications (Web Push API)';
COMMENT ON COLUMN notifications.user_devices.device_token IS 'JSON serializado de PushSubscription del navegador';
COMMENT ON COLUMN notifications.user_devices.device_name IS 'Nombre descriptivo del dispositivo para UI';
-- ============================================
-- TABLA: notification_queue
-- Cola de procesamiento asincrono de notificaciones
-- ============================================
CREATE TABLE IF NOT EXISTS notifications.notification_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE,
-- Channel
channel notifications.channel NOT NULL,
-- Scheduling
scheduled_for TIMESTAMPTZ DEFAULT NOW(),
-- Priority (numerico para ordenamiento)
priority_value INT DEFAULT 0, -- urgent=10, high=5, normal=0, low=-5
-- Processing
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 3,
status notifications.queue_status DEFAULT 'queued',
-- Timestamps
last_attempt_at TIMESTAMPTZ,
next_retry_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- Error tracking
error_message TEXT,
error_count INT DEFAULT 0,
-- Metadata
metadata JSONB DEFAULT '{}'::jsonb,
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Indexes para procesamiento eficiente
CREATE INDEX IF NOT EXISTS idx_queue_pending ON notifications.notification_queue(scheduled_for, priority_value DESC)
WHERE status IN ('queued', 'retrying');
CREATE INDEX IF NOT EXISTS idx_queue_status ON notifications.notification_queue(status);
CREATE INDEX IF NOT EXISTS idx_queue_notification ON notifications.notification_queue(notification_id);
CREATE INDEX IF NOT EXISTS idx_queue_retry ON notifications.notification_queue(next_retry_at)
WHERE status = 'retrying' AND next_retry_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_queue_created ON notifications.notification_queue(created_at DESC);
COMMENT ON TABLE notifications.notification_queue IS 'Cola de procesamiento asincrono de notificaciones con BullMQ';
COMMENT ON COLUMN notifications.notification_queue.priority_value IS 'Valor numerico: urgent=10, high=5, normal=0, low=-5';
COMMENT ON COLUMN notifications.notification_queue.max_attempts IS 'Maximo de reintentos antes de marcar como failed';
-- ============================================
-- TABLA: notification_logs
-- Historial de entregas y eventos de notificaciones
-- ============================================
CREATE TABLE IF NOT EXISTS notifications.notification_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
notification_id UUID NOT NULL REFERENCES notifications.notifications(id) ON DELETE CASCADE,
queue_id UUID REFERENCES notifications.notification_queue(id),
-- Channel info
channel notifications.channel NOT NULL,
-- Delivery status
status VARCHAR(30) NOT NULL, -- 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'complained', 'failed'
-- Provider info
provider VARCHAR(50), -- 'sendgrid', 'ses', 'web-push', 'smtp'
provider_message_id VARCHAR(200),
provider_response JSONB,
-- Timestamps
delivered_at TIMESTAMPTZ,
opened_at TIMESTAMPTZ,
clicked_at TIMESTAMPTZ,
-- Error details
error_code VARCHAR(50),
error_message TEXT,
-- Device info (for push)
device_id UUID REFERENCES notifications.user_devices(id),
-- Audit
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_logs_notification ON notifications.notification_logs(notification_id);
CREATE INDEX IF NOT EXISTS idx_logs_status ON notifications.notification_logs(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_logs_channel ON notifications.notification_logs(channel, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_logs_provider ON notifications.notification_logs(provider, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_logs_queue ON notifications.notification_logs(queue_id) WHERE queue_id IS NOT NULL;
COMMENT ON TABLE notifications.notification_logs IS 'Historial de entregas con tracking detallado por proveedor';
COMMENT ON COLUMN notifications.notification_logs.status IS 'sent, delivered, opened, clicked, bounced, complained, failed';
COMMENT ON COLUMN notifications.notification_logs.provider_response IS 'Respuesta cruda del proveedor para debugging';
-- ============================================
-- ACTUALIZAR user_preferences
-- Agregar campo para preferencias granulares
-- ============================================
-- Agregar columna para preferencias por tipo de notificacion
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'notifications'
AND table_name = 'user_preferences'
AND column_name = 'notification_type_preferences'
) THEN
ALTER TABLE notifications.user_preferences
ADD COLUMN notification_type_preferences JSONB DEFAULT '{}'::jsonb;
COMMENT ON COLUMN notifications.user_preferences.notification_type_preferences
IS 'Preferencias por tipo: {"billing": {"email": true, "push": false}, "system": {"email": true}}';
END IF;
END$$;
-- Agregar columna para frequencia de email digest
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'notifications'
AND table_name = 'user_preferences'
AND column_name = 'email_frequency'
) THEN
ALTER TABLE notifications.user_preferences
ADD COLUMN email_frequency VARCHAR(20) DEFAULT 'immediate';
COMMENT ON COLUMN notifications.user_preferences.email_frequency
IS 'Frecuencia: immediate, daily, weekly';
END IF;
END$$;
-- ============================================
-- FUNCIONES SQL
-- ============================================
-- Funcion: Calcular priority value desde string
CREATE OR REPLACE FUNCTION notifications.get_priority_value(p_priority VARCHAR)
RETURNS INT AS $$
BEGIN
RETURN CASE p_priority
WHEN 'urgent' THEN 10
WHEN 'high' THEN 5
WHEN 'normal' THEN 0
WHEN 'low' THEN -5
ELSE 0
END;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
COMMENT ON FUNCTION notifications.get_priority_value IS 'Convierte priority string a valor numerico para ordenamiento';
-- Funcion: Encolar notificacion
CREATE OR REPLACE FUNCTION notifications.enqueue_notification(
p_notification_id UUID,
p_channel notifications.channel,
p_priority VARCHAR DEFAULT 'normal',
p_scheduled_for TIMESTAMPTZ DEFAULT NOW()
) RETURNS UUID AS $$
DECLARE
v_queue_id UUID;
BEGIN
INSERT INTO notifications.notification_queue (
notification_id,
channel,
priority_value,
scheduled_for,
status
) VALUES (
p_notification_id,
p_channel,
notifications.get_priority_value(p_priority),
p_scheduled_for,
'queued'
) RETURNING id INTO v_queue_id;
RETURN v_queue_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.enqueue_notification IS 'Encola notificacion para procesamiento asincrono';
-- Funcion: Obtener items pendientes de la cola (con lock)
CREATE OR REPLACE FUNCTION notifications.get_pending_queue_items(
p_limit INT DEFAULT 100,
p_channel notifications.channel DEFAULT NULL
) RETURNS TABLE (
id UUID,
notification_id UUID,
channel notifications.channel,
priority_value INT,
attempts INT,
user_id UUID,
tenant_id UUID,
subject VARCHAR,
body TEXT,
body_html TEXT,
recipient_email VARCHAR,
metadata JSONB
) AS $$
BEGIN
RETURN QUERY
WITH pending AS (
SELECT q.*
FROM notifications.notification_queue q
WHERE q.status IN ('queued', 'retrying')
AND (q.scheduled_for IS NULL OR q.scheduled_for <= NOW())
AND (q.next_retry_at IS NULL OR q.next_retry_at <= NOW())
AND (p_channel IS NULL OR q.channel = p_channel)
ORDER BY q.priority_value DESC, q.created_at ASC
LIMIT p_limit
FOR UPDATE SKIP LOCKED
)
SELECT
p.id,
p.notification_id,
p.channel,
p.priority_value,
p.attempts,
n.user_id,
n.tenant_id,
n.subject,
n.body,
n.body_html,
n.recipient_email,
n.metadata
FROM pending p
JOIN notifications.notifications n ON n.id = p.notification_id;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.get_pending_queue_items IS 'Obtiene items pendientes con FOR UPDATE SKIP LOCKED para procesamiento concurrente';
-- Funcion: Completar item de cola
CREATE OR REPLACE FUNCTION notifications.complete_queue_item(
p_queue_id UUID,
p_status VARCHAR,
p_error_message TEXT DEFAULT NULL,
p_provider VARCHAR DEFAULT NULL,
p_provider_message_id VARCHAR DEFAULT NULL,
p_provider_response JSONB DEFAULT NULL
) RETURNS VOID AS $$
DECLARE
v_queue RECORD;
v_notification_id UUID;
v_channel notifications.channel;
BEGIN
-- Obtener datos del queue item
SELECT q.*, n.id as notif_id
INTO v_queue
FROM notifications.notification_queue q
JOIN notifications.notifications n ON n.id = q.notification_id
WHERE q.id = p_queue_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Queue item % not found', p_queue_id;
END IF;
v_notification_id := v_queue.notification_id;
v_channel := v_queue.channel;
IF p_status = 'sent' THEN
-- Marcar como enviado
UPDATE notifications.notification_queue
SET status = 'sent',
completed_at = NOW(),
last_attempt_at = NOW(),
attempts = attempts + 1
WHERE id = p_queue_id;
-- Actualizar notificacion
UPDATE notifications.notifications
SET status = 'sent',
sent_at = NOW()
WHERE id = v_notification_id
AND status = 'pending';
-- Crear log de exito
INSERT INTO notifications.notification_logs (
notification_id, queue_id, channel, status,
provider, provider_message_id, provider_response,
delivered_at
) VALUES (
v_notification_id, p_queue_id, v_channel, 'sent',
p_provider, p_provider_message_id, p_provider_response,
NOW()
);
ELSIF p_status = 'failed' THEN
IF v_queue.attempts + 1 >= v_queue.max_attempts THEN
-- Fallo definitivo
UPDATE notifications.notification_queue
SET status = 'failed',
last_attempt_at = NOW(),
attempts = attempts + 1,
error_message = p_error_message,
error_count = error_count + 1
WHERE id = p_queue_id;
-- Actualizar notificacion
UPDATE notifications.notifications
SET status = 'failed',
failed_at = NOW(),
failure_reason = p_error_message
WHERE id = v_notification_id;
ELSE
-- Programar retry con backoff exponencial (1, 2, 4, 8... minutos)
UPDATE notifications.notification_queue
SET status = 'retrying',
last_attempt_at = NOW(),
attempts = attempts + 1,
next_retry_at = NOW() + (POWER(2, v_queue.attempts) * INTERVAL '1 minute'),
error_message = p_error_message,
error_count = error_count + 1
WHERE id = p_queue_id;
END IF;
-- Crear log de error
INSERT INTO notifications.notification_logs (
notification_id, queue_id, channel, status,
provider, error_message
) VALUES (
v_notification_id, p_queue_id, v_channel, 'failed',
p_provider, p_error_message
);
END IF;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.complete_queue_item IS 'Marca item como completado o fallido, con retry automatico';
-- Funcion: Limpiar notificaciones antiguas
CREATE OR REPLACE FUNCTION notifications.cleanup_old_notifications(
p_days_to_keep INT DEFAULT 30
) RETURNS INT AS $$
DECLARE
v_deleted INT;
BEGIN
WITH deleted AS (
DELETE FROM notifications.notifications
WHERE created_at < NOW() - (p_days_to_keep || ' days')::INTERVAL
AND read_at IS NOT NULL
RETURNING id
)
SELECT COUNT(*) INTO v_deleted FROM deleted;
RETURN v_deleted;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.cleanup_old_notifications IS 'Elimina notificaciones leidas mas antiguas que p_days_to_keep dias';
-- Funcion: Obtener dispositivos activos de un usuario
CREATE OR REPLACE FUNCTION notifications.get_user_active_devices(
p_user_id UUID
) RETURNS TABLE (
id UUID,
device_type notifications.device_type,
device_token TEXT,
device_name VARCHAR,
browser VARCHAR,
os VARCHAR,
last_used_at TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
d.id,
d.device_type,
d.device_token,
d.device_name,
d.browser,
d.os,
d.last_used_at
FROM notifications.user_devices d
WHERE d.user_id = p_user_id
AND d.is_active = TRUE
ORDER BY d.last_used_at DESC;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.get_user_active_devices IS 'Obtiene dispositivos activos de un usuario para push notifications';
-- Funcion: Estadisticas de cola
CREATE OR REPLACE FUNCTION notifications.get_queue_stats()
RETURNS TABLE (
status notifications.queue_status,
channel notifications.channel,
count BIGINT,
oldest_created_at TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
q.status,
q.channel,
COUNT(*)::BIGINT,
MIN(q.created_at)
FROM notifications.notification_queue q
GROUP BY q.status, q.channel
ORDER BY q.status, q.channel;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION notifications.get_queue_stats IS 'Estadisticas de la cola de notificaciones por status y canal';
-- ============================================
-- TRIGGERS
-- ============================================
-- Trigger para actualizar last_used_at en user_devices
CREATE OR REPLACE FUNCTION notifications.update_device_last_used()
RETURNS TRIGGER AS $$
BEGIN
NEW.last_used_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_user_devices_last_used ON notifications.user_devices;
CREATE TRIGGER trg_user_devices_last_used
BEFORE UPDATE ON notifications.user_devices
FOR EACH ROW
WHEN (OLD.device_token IS DISTINCT FROM NEW.device_token)
EXECUTE FUNCTION notifications.update_device_last_used();
-- ============================================
-- GRANTS (si se usa rol de aplicacion)
-- ============================================
-- Grants para el rol de aplicacion (descomentar si se usa)
-- GRANT SELECT, INSERT, UPDATE, DELETE ON notifications.user_devices TO app_user;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON notifications.notification_queue TO app_user;
-- GRANT SELECT, INSERT ON notifications.notification_logs TO app_user;
-- GRANT EXECUTE ON FUNCTION notifications.enqueue_notification TO app_user;
-- GRANT EXECUTE ON FUNCTION notifications.get_pending_queue_items TO app_user;
-- GRANT EXECUTE ON FUNCTION notifications.complete_queue_item TO app_user;
-- GRANT EXECUTE ON FUNCTION notifications.get_user_active_devices TO app_user;
-- ============================================
-- FIN
-- ============================================