erp-core-database/ddl/11-feature-flags.sql
rckrdmrd 5043a640e4 refactor: Restructure DDL with numbered schema files
- Replace old DDL structure with new numbered files (01-24)
- Update migrations and seeds for new schema
- Clean up deprecated files

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

425 lines
15 KiB
PL/PgSQL

-- =============================================================
-- 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';