- 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>
425 lines
15 KiB
PL/PgSQL
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';
|