-- ============================================================= -- ARCHIVO: 11-feature-flags.sql -- DESCRIPCION: Sistema de Feature Flags para rollout gradual -- VERSION: 1.0.0 -- PROYECTO: ERP-Core V2 -- FECHA: 2026-01-10 -- EPIC: SAAS-BILLING (EPIC-SAAS-002) -- HISTORIAS: US-022 -- ============================================================= -- ===================== -- SCHEMA: flags -- ===================== CREATE SCHEMA IF NOT EXISTS flags; -- ===================== -- TABLA: flags.flags -- Definicion de feature flags globales (US-022) -- ===================== CREATE TABLE IF NOT EXISTS flags.flags ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificacion key VARCHAR(100) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, category VARCHAR(100), -- Estado global enabled BOOLEAN DEFAULT FALSE, -- Rollout gradual rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100), -- Targeting targeting_rules JSONB DEFAULT '[]', -- Ejemplo: [{"type": "tenant", "operator": "in", "values": ["uuid1", "uuid2"]}] -- Variantes (para A/B testing) variants JSONB DEFAULT '[]', -- Ejemplo: [{"key": "control", "weight": 50}, {"key": "variant_a", "weight": 50}] default_variant VARCHAR(100) DEFAULT 'control', -- Metadata metadata JSONB DEFAULT '{}', tags TEXT[] DEFAULT '{}', -- Lifecycle starts_at TIMESTAMPTZ, ends_at TIMESTAMPTZ, -- Audit created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, updated_by UUID REFERENCES auth.users(id), archived_at TIMESTAMPTZ ); -- Indices para flags CREATE INDEX IF NOT EXISTS idx_flags_key ON flags.flags(key); CREATE INDEX IF NOT EXISTS idx_flags_enabled ON flags.flags(enabled) WHERE enabled = TRUE; CREATE INDEX IF NOT EXISTS idx_flags_category ON flags.flags(category); CREATE INDEX IF NOT EXISTS idx_flags_tags ON flags.flags USING GIN(tags); CREATE INDEX IF NOT EXISTS idx_flags_active ON flags.flags(starts_at, ends_at) WHERE archived_at IS NULL; -- ===================== -- TABLA: flags.flag_overrides -- Overrides por tenant para feature flags (US-022) -- ===================== CREATE TABLE IF NOT EXISTS flags.flag_overrides ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), flag_id UUID NOT NULL REFERENCES flags.flags(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, -- Override enabled BOOLEAN NOT NULL, variant VARCHAR(100), -- Variante especifica para este tenant -- Razon reason TEXT, expires_at TIMESTAMPTZ, -- Audit created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, UNIQUE(flag_id, tenant_id) ); -- Indices para flag_overrides CREATE INDEX IF NOT EXISTS idx_flag_overrides_flag ON flags.flag_overrides(flag_id); CREATE INDEX IF NOT EXISTS idx_flag_overrides_tenant ON flags.flag_overrides(tenant_id); CREATE INDEX IF NOT EXISTS idx_flag_overrides_active ON flags.flag_overrides(expires_at) WHERE expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP; -- ===================== -- TABLA: flags.flag_evaluations -- Log de evaluaciones de flags (para analytics) -- ===================== CREATE TABLE IF NOT EXISTS flags.flag_evaluations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), flag_id UUID NOT NULL REFERENCES flags.flags(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, user_id UUID REFERENCES auth.users(id), -- Resultado result BOOLEAN NOT NULL, variant VARCHAR(100), -- Contexto de evaluacion evaluation_context JSONB DEFAULT '{}', evaluation_reason VARCHAR(100), -- 'override', 'targeting', 'rollout', 'default' evaluated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para flag_evaluations (particionado por fecha recomendado en produccion) CREATE INDEX IF NOT EXISTS idx_flag_evaluations_flag ON flags.flag_evaluations(flag_id); CREATE INDEX IF NOT EXISTS idx_flag_evaluations_tenant ON flags.flag_evaluations(tenant_id); CREATE INDEX IF NOT EXISTS idx_flag_evaluations_date ON flags.flag_evaluations(evaluated_at DESC); -- ===================== -- TABLA: flags.flag_segments -- Segmentos de usuarios para targeting avanzado -- ===================== CREATE TABLE IF NOT EXISTS flags.flag_segments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificacion key VARCHAR(100) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, description TEXT, -- Reglas del segmento rules JSONB NOT NULL DEFAULT '[]', -- Ejemplo: [{"attribute": "plan", "operator": "eq", "value": "business"}] -- Estado is_active BOOLEAN DEFAULT TRUE, -- Audit created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); -- Indices para flag_segments CREATE INDEX IF NOT EXISTS idx_flag_segments_key ON flags.flag_segments(key); CREATE INDEX IF NOT EXISTS idx_flag_segments_active ON flags.flag_segments(is_active) WHERE is_active = TRUE; -- ===================== -- RLS POLICIES -- ===================== -- Flags son globales, lectura publica ALTER TABLE flags.flags ENABLE ROW LEVEL SECURITY; CREATE POLICY public_read_flags ON flags.flags FOR SELECT USING (true); -- Overrides son por tenant ALTER TABLE flags.flag_overrides ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_overrides ON flags.flag_overrides USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Evaluations son por tenant ALTER TABLE flags.flag_evaluations ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_evaluations ON flags.flag_evaluations USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid); -- Segments son globales ALTER TABLE flags.flag_segments ENABLE ROW LEVEL SECURITY; CREATE POLICY public_read_segments ON flags.flag_segments FOR SELECT USING (true); -- ===================== -- FUNCIONES -- ===================== -- Funcion principal para evaluar un flag CREATE OR REPLACE FUNCTION flags.evaluate_flag( p_flag_key VARCHAR(100), p_tenant_id UUID, p_user_id UUID DEFAULT NULL, p_context JSONB DEFAULT '{}' ) RETURNS TABLE ( enabled BOOLEAN, variant VARCHAR(100), reason VARCHAR(100) ) AS $$ DECLARE v_flag RECORD; v_override RECORD; v_result BOOLEAN; v_variant VARCHAR(100); v_reason VARCHAR(100); BEGIN -- Obtener flag SELECT * INTO v_flag FROM flags.flags WHERE key = p_flag_key AND archived_at IS NULL AND (starts_at IS NULL OR starts_at <= CURRENT_TIMESTAMP) AND (ends_at IS NULL OR ends_at > CURRENT_TIMESTAMP); IF NOT FOUND THEN RETURN QUERY SELECT FALSE, 'control'::VARCHAR(100), 'flag_not_found'::VARCHAR(100); RETURN; END IF; -- Verificar override para el tenant SELECT * INTO v_override FROM flags.flag_overrides WHERE flag_id = v_flag.id AND tenant_id = p_tenant_id AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP); IF FOUND THEN v_result := v_override.enabled; v_variant := COALESCE(v_override.variant, v_flag.default_variant); v_reason := 'override'; ELSE -- Evaluar targeting rules (simplificado) IF v_flag.targeting_rules IS NOT NULL AND jsonb_array_length(v_flag.targeting_rules) > 0 THEN -- Por ahora, si hay targeting rules y el tenant esta en la lista, habilitar IF EXISTS ( SELECT 1 FROM jsonb_array_elements(v_flag.targeting_rules) AS rule WHERE rule->>'type' = 'tenant' AND p_tenant_id::text = ANY( SELECT jsonb_array_elements_text(rule->'values') ) ) THEN v_result := TRUE; v_reason := 'targeting'; END IF; END IF; -- Si no paso targeting, evaluar rollout IF v_reason IS NULL THEN IF v_flag.rollout_percentage > 0 THEN -- Usar hash del tenant_id para consistencia IF (abs(hashtext(p_tenant_id::text)) % 100) < v_flag.rollout_percentage THEN v_result := TRUE; v_reason := 'rollout'; ELSE v_result := v_flag.enabled; v_reason := 'default'; END IF; ELSE v_result := v_flag.enabled; v_reason := 'default'; END IF; END IF; v_variant := v_flag.default_variant; END IF; -- Registrar evaluacion (async en produccion) INSERT INTO flags.flag_evaluations (flag_id, tenant_id, user_id, result, variant, evaluation_context, evaluation_reason) VALUES (v_flag.id, p_tenant_id, p_user_id, v_result, v_variant, p_context, v_reason); RETURN QUERY SELECT v_result, v_variant, v_reason; END; $$ LANGUAGE plpgsql; -- Funcion simplificada para solo verificar si esta habilitado CREATE OR REPLACE FUNCTION flags.is_enabled( p_flag_key VARCHAR(100), p_tenant_id UUID ) RETURNS BOOLEAN AS $$ DECLARE v_enabled BOOLEAN; BEGIN SELECT enabled INTO v_enabled FROM flags.evaluate_flag(p_flag_key, p_tenant_id); RETURN COALESCE(v_enabled, FALSE); END; $$ LANGUAGE plpgsql; -- Funcion para obtener todos los flags de un tenant CREATE OR REPLACE FUNCTION flags.get_all_flags_for_tenant(p_tenant_id UUID) RETURNS TABLE ( flag_key VARCHAR(100), flag_name VARCHAR(255), enabled BOOLEAN, variant VARCHAR(100) ) AS $$ BEGIN RETURN QUERY SELECT f.key as flag_key, f.name as flag_name, COALESCE(fo.enabled, f.enabled) as enabled, COALESCE(fo.variant, f.default_variant) as variant FROM flags.flags f LEFT JOIN flags.flag_overrides fo ON fo.flag_id = f.id AND fo.tenant_id = p_tenant_id WHERE f.archived_at IS NULL AND (f.starts_at IS NULL OR f.starts_at <= CURRENT_TIMESTAMP) AND (f.ends_at IS NULL OR f.ends_at > CURRENT_TIMESTAMP); END; $$ LANGUAGE plpgsql STABLE; -- Funcion para obtener estadisticas de un flag CREATE OR REPLACE FUNCTION flags.get_flag_stats( p_flag_key VARCHAR(100), p_days INTEGER DEFAULT 7 ) RETURNS TABLE ( total_evaluations BIGINT, enabled_count BIGINT, disabled_count BIGINT, enabled_percentage DECIMAL, unique_tenants BIGINT ) AS $$ BEGIN RETURN QUERY SELECT COUNT(*) as total_evaluations, COUNT(*) FILTER (WHERE fe.result = TRUE) as enabled_count, COUNT(*) FILTER (WHERE fe.result = FALSE) as disabled_count, ROUND(COUNT(*) FILTER (WHERE fe.result = TRUE)::DECIMAL / NULLIF(COUNT(*), 0) * 100, 2) as enabled_percentage, COUNT(DISTINCT fe.tenant_id) as unique_tenants FROM flags.flag_evaluations fe JOIN flags.flags f ON f.id = fe.flag_id WHERE f.key = p_flag_key AND fe.evaluated_at >= CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL; END; $$ LANGUAGE plpgsql STABLE; -- Funcion para limpiar evaluaciones antiguas CREATE OR REPLACE FUNCTION flags.cleanup_old_evaluations(p_days INTEGER DEFAULT 30) RETURNS INTEGER AS $$ DECLARE deleted_count INTEGER; BEGIN DELETE FROM flags.flag_evaluations WHERE evaluated_at < CURRENT_TIMESTAMP - (p_days || ' days')::INTERVAL; GET DIAGNOSTICS deleted_count = ROW_COUNT; RETURN deleted_count; END; $$ LANGUAGE plpgsql; -- ===================== -- TRIGGERS -- ===================== -- Trigger para updated_at en flags CREATE OR REPLACE FUNCTION flags.update_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_flags_updated_at BEFORE UPDATE ON flags.flags FOR EACH ROW EXECUTE FUNCTION flags.update_timestamp(); CREATE TRIGGER trg_flag_overrides_updated_at BEFORE UPDATE ON flags.flag_overrides FOR EACH ROW EXECUTE FUNCTION flags.update_timestamp(); CREATE TRIGGER trg_flag_segments_updated_at BEFORE UPDATE ON flags.flag_segments FOR EACH ROW EXECUTE FUNCTION flags.update_timestamp(); -- ===================== -- SEED DATA: Feature Flags Base -- ===================== INSERT INTO flags.flags (key, name, description, category, enabled, rollout_percentage, tags) VALUES -- Features nuevas (deshabilitadas por default) ('new_dashboard', 'Nuevo Dashboard', 'Dashboard rediseƱado con metricas en tiempo real', 'ui', FALSE, 0, '{ui,beta}'), ('ai_assistant', 'Asistente IA', 'Chat con asistente de inteligencia artificial', 'ai', FALSE, 0, '{ai,premium}'), ('whatsapp_notifications', 'Notificaciones WhatsApp', 'Enviar notificaciones via WhatsApp', 'notifications', FALSE, 0, '{notifications,beta}'), ('offline_mode', 'Modo Offline Mejorado', 'Sincronizacion offline avanzada', 'mobile', FALSE, 0, '{mobile,beta}'), ('multi_currency', 'Multi-Moneda', 'Soporte para multiples monedas', 'billing', FALSE, 0, '{billing,premium}'), -- Features de rollout gradual ('new_checkout', 'Nuevo Flujo de Checkout', 'Checkout optimizado con menos pasos', 'billing', FALSE, 25, '{billing,ab_test}'), ('smart_inventory', 'Inventario Inteligente', 'Predicciones de stock con ML', 'inventory', FALSE, 10, '{inventory,ai,beta}'), -- Features habilitadas globalmente ('dark_mode', 'Modo Oscuro', 'Tema oscuro para la interfaz', 'ui', TRUE, 100, '{ui}'), ('export_csv', 'Exportar a CSV', 'Exportar datos a formato CSV', 'reports', TRUE, 100, '{reports}'), ('notifications_center', 'Centro de Notificaciones', 'Panel centralizado de notificaciones', 'notifications', TRUE, 100, '{notifications}'), -- Kill switches (para emergencias) ('maintenance_mode', 'Modo Mantenimiento', 'Mostrar pagina de mantenimiento', 'system', FALSE, 0, '{system,kill_switch}'), ('disable_signups', 'Deshabilitar Registros', 'Pausar nuevos registros', 'system', FALSE, 0, '{system,kill_switch}'), ('read_only_mode', 'Modo Solo Lectura', 'Deshabilitar escrituras en DB', 'system', FALSE, 0, '{system,kill_switch}') ON CONFLICT (key) DO NOTHING; -- ===================== -- SEED DATA: Segmentos -- ===================== INSERT INTO flags.flag_segments (key, name, description, rules) VALUES ('beta_testers', 'Beta Testers', 'Usuarios en programa beta', '[{"attribute": "tags", "operator": "contains", "value": "beta"}]'), ('enterprise_customers', 'Clientes Enterprise', 'Tenants con plan enterprise', '[{"attribute": "plan", "operator": "eq", "value": "enterprise"}]'), ('high_usage', 'Alto Uso', 'Tenants con alto volumen de uso', '[{"attribute": "monthly_transactions", "operator": "gt", "value": 1000}]'), ('mexico_only', 'Solo Mexico', 'Tenants en Mexico', '[{"attribute": "country", "operator": "eq", "value": "MX"}]') ON CONFLICT (key) DO NOTHING; -- ===================== -- COMENTARIOS -- ===================== COMMENT ON TABLE flags.flags IS 'Feature flags globales para control de funcionalidades'; COMMENT ON TABLE flags.flag_overrides IS 'Overrides de flags por tenant'; COMMENT ON TABLE flags.flag_evaluations IS 'Log de evaluaciones de flags para analytics'; COMMENT ON TABLE flags.flag_segments IS 'Segmentos de usuarios para targeting'; COMMENT ON FUNCTION flags.evaluate_flag IS 'Evalua un flag para un tenant, retorna enabled, variant y reason'; COMMENT ON FUNCTION flags.is_enabled IS 'Verifica si un flag esta habilitado para un tenant'; COMMENT ON FUNCTION flags.get_all_flags_for_tenant IS 'Obtiene todos los flags con su estado para un tenant'; COMMENT ON FUNCTION flags.get_flag_stats IS 'Obtiene estadisticas de uso de un flag';