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>
507 lines
17 KiB
PL/PgSQL
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
|
|
-- ============================================
|