-- ============================================ -- 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 is_read = TRUE 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 -- ============================================