-- ============================================================================ -- SCHEMA: notifications -- TABLE: notifications -- DESCRIPTION: Sistema de notificaciones del sistema -- VERSION: 1.0.0 -- CREATED: 2026-01-16 -- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026 -- ============================================================================ -- Crear schema si no existe CREATE SCHEMA IF NOT EXISTS notifications; -- Grant usage GRANT USAGE ON SCHEMA notifications TO trading_app; GRANT USAGE ON SCHEMA notifications TO trading_readonly; -- Enum para tipo de notificacion DO $$ BEGIN CREATE TYPE notifications.notification_type AS ENUM ( 'info', -- Informativa general 'success', -- Accion exitosa 'warning', -- Advertencia 'error', -- Error 'alert', -- Alerta importante 'trade', -- Relacionada a trading 'signal', -- Senal de trading 'payment', -- Pago/transaccion 'account', -- Cuenta de usuario 'security', -- Seguridad 'system', -- Sistema/mantenimiento 'promotion' -- Promocional ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Enum para prioridad DO $$ BEGIN CREATE TYPE notifications.notification_priority AS ENUM ( 'low', 'normal', 'high', 'critical' ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Enum para canal de entrega DO $$ BEGIN CREATE TYPE notifications.delivery_channel AS ENUM ( 'in_app', -- Notificacion en app 'email', -- Email 'push', -- Push notification 'sms', -- SMS 'webhook' -- Webhook externo ); EXCEPTION WHEN duplicate_object THEN null; END $$; -- Tabla principal de Notificaciones CREATE TABLE IF NOT EXISTS notifications.notifications ( -- Identificadores 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, -- Clasificacion type notifications.notification_type NOT NULL DEFAULT 'info', priority notifications.notification_priority NOT NULL DEFAULT 'normal', category VARCHAR(50), -- Subcategoria libre -- Contenido title VARCHAR(255) NOT NULL, message TEXT NOT NULL, message_html TEXT, -- Version HTML del mensaje -- Datos estructurados data JSONB DEFAULT '{}'::JSONB, -- Datos adicionales para la UI action_url TEXT, -- URL de accion principal action_label VARCHAR(100), -- Texto del boton de accion actions JSONB, -- Array de acciones multiples -- Estado is_read BOOLEAN NOT NULL DEFAULT FALSE, read_at TIMESTAMPTZ, is_archived BOOLEAN NOT NULL DEFAULT FALSE, archived_at TIMESTAMPTZ, is_deleted BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMPTZ, -- Entrega multicanal channels notifications.delivery_channel[] NOT NULL DEFAULT ARRAY['in_app']::notifications.delivery_channel[], delivery_status JSONB DEFAULT '{}'::JSONB, -- Estado por canal -- Tracking de emails email_sent BOOLEAN NOT NULL DEFAULT FALSE, email_sent_at TIMESTAMPTZ, email_opened BOOLEAN NOT NULL DEFAULT FALSE, email_opened_at TIMESTAMPTZ, email_clicked BOOLEAN NOT NULL DEFAULT FALSE, email_clicked_at TIMESTAMPTZ, -- Push notifications push_sent BOOLEAN NOT NULL DEFAULT FALSE, push_sent_at TIMESTAMPTZ, push_delivered BOOLEAN NOT NULL DEFAULT FALSE, push_delivered_at TIMESTAMPTZ, -- Agrupacion group_key VARCHAR(100), -- Agrupar notificaciones similares group_count INTEGER DEFAULT 1, -- Contador de agrupacion -- Programacion scheduled_for TIMESTAMPTZ, -- Envio programado expires_at TIMESTAMPTZ, -- Expiracion automatica -- Origen source VARCHAR(50) NOT NULL DEFAULT 'system', -- 'system', 'user', 'bot', 'webhook' source_id VARCHAR(255), -- ID del recurso origen source_type VARCHAR(50), -- Tipo de recurso origen -- Metadata metadata JSONB DEFAULT '{}'::JSONB, -- Timestamps created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); COMMENT ON TABLE notifications.notifications IS 'Notificaciones del sistema para usuarios, soporta multicanal'; COMMENT ON COLUMN notifications.notifications.channels IS 'Canales de entrega: in_app, email, push, sms, webhook'; COMMENT ON COLUMN notifications.notifications.group_key IS 'Clave para agrupar notificaciones similares y evitar spam'; -- Indices para queries comunes CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON notifications.notifications(user_id, created_at DESC) WHERE is_read = FALSE AND is_deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_notifications_user_all ON notifications.notifications(user_id, created_at DESC) WHERE is_deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_notifications_tenant ON notifications.notifications(tenant_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_notifications_type ON notifications.notifications(type); CREATE INDEX IF NOT EXISTS idx_notifications_priority ON notifications.notifications(priority) WHERE priority IN ('high', 'critical'); CREATE INDEX IF NOT EXISTS idx_notifications_scheduled ON notifications.notifications(scheduled_for) WHERE scheduled_for IS NOT NULL AND scheduled_for > NOW(); CREATE INDEX IF NOT EXISTS idx_notifications_expires ON notifications.notifications(expires_at) WHERE expires_at IS NOT NULL AND is_deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_notifications_group ON notifications.notifications(user_id, group_key, created_at DESC) WHERE group_key IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_notifications_source ON notifications.notifications(source, source_type, source_id); -- GIN index para busqueda en datos JSONB CREATE INDEX IF NOT EXISTS idx_notifications_data_gin ON notifications.notifications USING GIN (data); -- Trigger para updated_at CREATE OR REPLACE FUNCTION notifications.update_notification_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS notification_updated_at ON notifications.notifications; CREATE TRIGGER notification_updated_at BEFORE UPDATE ON notifications.notifications FOR EACH ROW EXECUTE FUNCTION notifications.update_notification_timestamp(); -- Trigger para marcar read_at cuando is_read cambia a true CREATE OR REPLACE FUNCTION notifications.set_read_timestamp() RETURNS TRIGGER AS $$ BEGIN IF NEW.is_read = TRUE AND OLD.is_read = FALSE THEN NEW.read_at = NOW(); END IF; IF NEW.is_archived = TRUE AND OLD.is_archived = FALSE THEN NEW.archived_at = NOW(); END IF; IF NEW.is_deleted = TRUE AND OLD.is_deleted = FALSE THEN NEW.deleted_at = NOW(); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS notification_status_timestamps ON notifications.notifications; CREATE TRIGGER notification_status_timestamps BEFORE UPDATE ON notifications.notifications FOR EACH ROW EXECUTE FUNCTION notifications.set_read_timestamp(); -- Funcion para crear notificacion CREATE OR REPLACE FUNCTION notifications.create_notification( p_tenant_id UUID, p_user_id UUID, p_type notifications.notification_type, p_title VARCHAR(255), p_message TEXT, p_priority notifications.notification_priority DEFAULT 'normal', p_channels notifications.delivery_channel[] DEFAULT ARRAY['in_app']::notifications.delivery_channel[], p_data JSONB DEFAULT '{}'::JSONB, p_action_url TEXT DEFAULT NULL, p_source VARCHAR(50) DEFAULT 'system', p_source_type VARCHAR(50) DEFAULT NULL, p_source_id VARCHAR(255) DEFAULT NULL, p_group_key VARCHAR(100) DEFAULT NULL, p_scheduled_for TIMESTAMPTZ DEFAULT NULL, p_expires_at TIMESTAMPTZ DEFAULT NULL ) RETURNS UUID AS $$ DECLARE v_notification_id UUID; BEGIN INSERT INTO notifications.notifications ( tenant_id, user_id, type, title, message, priority, channels, data, action_url, source, source_type, source_id, group_key, scheduled_for, expires_at ) VALUES ( p_tenant_id, p_user_id, p_type, p_title, p_message, p_priority, p_channels, p_data, p_action_url, p_source, p_source_type, p_source_id, p_group_key, p_scheduled_for, p_expires_at ) RETURNING id INTO v_notification_id; RETURN v_notification_id; END; $$ LANGUAGE plpgsql; -- Funcion para marcar como leidas por lotes CREATE OR REPLACE FUNCTION notifications.mark_all_read( p_user_id UUID, p_before TIMESTAMPTZ DEFAULT NULL ) RETURNS INTEGER AS $$ DECLARE v_count INTEGER; BEGIN UPDATE notifications.notifications SET is_read = TRUE, read_at = NOW() WHERE user_id = p_user_id AND is_read = FALSE AND is_deleted = FALSE AND (p_before IS NULL OR created_at <= p_before); GET DIAGNOSTICS v_count = ROW_COUNT; RETURN v_count; END; $$ LANGUAGE plpgsql; -- Funcion para limpiar notificaciones expiradas CREATE OR REPLACE FUNCTION notifications.cleanup_expired() RETURNS INTEGER AS $$ DECLARE v_count INTEGER; BEGIN UPDATE notifications.notifications SET is_deleted = TRUE, deleted_at = NOW() WHERE expires_at IS NOT NULL AND expires_at < NOW() AND is_deleted = FALSE; GET DIAGNOSTICS v_count = ROW_COUNT; RETURN v_count; END; $$ LANGUAGE plpgsql; -- Vista de notificaciones no leidas CREATE OR REPLACE VIEW notifications.v_unread_notifications AS SELECT n.id, n.tenant_id, n.user_id, n.type, n.priority, n.title, n.message, n.data, n.action_url, n.source, n.created_at FROM notifications.notifications n WHERE n.is_read = FALSE AND n.is_deleted = FALSE AND (n.expires_at IS NULL OR n.expires_at > NOW()) AND (n.scheduled_for IS NULL OR n.scheduled_for <= NOW()) ORDER BY CASE n.priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 WHEN 'low' THEN 4 END, n.created_at DESC; -- Vista de conteo por tipo para dashboard CREATE OR REPLACE VIEW notifications.v_notification_counts AS SELECT user_id, COUNT(*) FILTER (WHERE is_read = FALSE) AS unread_count, COUNT(*) FILTER (WHERE priority = 'critical' AND is_read = FALSE) AS critical_count, COUNT(*) FILTER (WHERE priority = 'high' AND is_read = FALSE) AS high_priority_count, COUNT(*) FILTER (WHERE type = 'trade' AND is_read = FALSE) AS trade_count, COUNT(*) FILTER (WHERE type = 'signal' AND is_read = FALSE) AS signal_count FROM notifications.notifications WHERE is_deleted = FALSE AND (expires_at IS NULL OR expires_at > NOW()) GROUP BY user_id; -- RLS Policy para multi-tenancy ALTER TABLE notifications.notifications ENABLE ROW LEVEL SECURITY; CREATE POLICY notifications_tenant_isolation ON notifications.notifications FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); -- Los usuarios solo pueden ver sus propias notificaciones CREATE POLICY notifications_user_isolation ON notifications.notifications FOR SELECT USING (user_id = current_setting('app.current_user_id', true)::UUID); -- Grants GRANT SELECT, INSERT, UPDATE ON notifications.notifications TO trading_app; GRANT SELECT ON notifications.notifications TO trading_readonly; GRANT SELECT ON notifications.v_unread_notifications TO trading_app; GRANT SELECT ON notifications.v_notification_counts TO trading_app; GRANT EXECUTE ON FUNCTION notifications.create_notification TO trading_app; GRANT EXECUTE ON FUNCTION notifications.mark_all_read TO trading_app; GRANT EXECUTE ON FUNCTION notifications.cleanup_expired TO trading_app;