Migración desde workspace-v2/projects/template-saas/apps/database Este repositorio es parte del estándar multi-repo v2 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
231 lines
7.3 KiB
PL/PgSQL
231 lines
7.3 KiB
PL/PgSQL
-- ============================================
|
|
-- TEMPLATE-SAAS: Feature Flags
|
|
-- Schema: feature_flags
|
|
-- Version: 1.0.0
|
|
-- ============================================
|
|
|
|
-- Feature flag definitions (global)
|
|
CREATE TABLE feature_flags.flags (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
-- Flag identification
|
|
code VARCHAR(100) UNIQUE NOT NULL,
|
|
name VARCHAR(200) NOT NULL,
|
|
description TEXT,
|
|
category VARCHAR(100), -- 'billing', 'ui', 'api', 'experimental'
|
|
|
|
-- Status
|
|
status feature_flags.flag_status DEFAULT 'disabled' NOT NULL,
|
|
|
|
-- Rollout
|
|
rollout_stage feature_flags.rollout_stage DEFAULT 'development',
|
|
rollout_percentage INT DEFAULT 0, -- 0-100
|
|
|
|
-- Default value
|
|
default_value BOOLEAN DEFAULT FALSE,
|
|
|
|
-- Targeting rules (JSONB)
|
|
targeting_rules JSONB DEFAULT '[]'::jsonb,
|
|
-- Example:
|
|
-- [
|
|
-- { "type": "plan", "operator": "in", "values": ["pro", "enterprise"] },
|
|
-- { "type": "user_attribute", "attribute": "country", "operator": "eq", "value": "MX" }
|
|
-- ]
|
|
|
|
-- Metadata
|
|
metadata JSONB DEFAULT '{}'::jsonb,
|
|
tags JSONB DEFAULT '[]'::jsonb,
|
|
|
|
-- Lifecycle
|
|
is_permanent BOOLEAN DEFAULT FALSE, -- Won't be cleaned up
|
|
expires_at TIMESTAMPTZ,
|
|
|
|
-- Audit
|
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
|
created_by VARCHAR(100),
|
|
|
|
-- Constraints
|
|
CONSTRAINT valid_flag_code CHECK (code ~ '^[a-z][a-z0-9_]*$'),
|
|
CONSTRAINT valid_percentage CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100)
|
|
);
|
|
|
|
-- Tenant-specific flag overrides
|
|
CREATE TABLE feature_flags.tenant_flags (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
|
flag_id UUID NOT NULL REFERENCES feature_flags.flags(id) ON DELETE CASCADE,
|
|
|
|
-- Override value
|
|
is_enabled BOOLEAN NOT NULL,
|
|
|
|
-- Reason
|
|
reason VARCHAR(500),
|
|
|
|
-- Audit
|
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
|
updated_by UUID,
|
|
|
|
-- Constraints
|
|
CONSTRAINT unique_tenant_flag UNIQUE (tenant_id, flag_id)
|
|
);
|
|
|
|
-- User-specific flag overrides
|
|
CREATE TABLE feature_flags.user_flags (
|
|
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,
|
|
flag_id UUID NOT NULL REFERENCES feature_flags.flags(id) ON DELETE CASCADE,
|
|
|
|
-- Override value
|
|
is_enabled BOOLEAN NOT NULL,
|
|
|
|
-- Reason
|
|
reason VARCHAR(500),
|
|
|
|
-- Expiration
|
|
expires_at TIMESTAMPTZ,
|
|
|
|
-- Audit
|
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
|
updated_by UUID,
|
|
|
|
-- Constraints
|
|
CONSTRAINT unique_user_flag UNIQUE (user_id, flag_id)
|
|
);
|
|
|
|
-- Flag evaluation history (for analytics)
|
|
CREATE TABLE feature_flags.evaluations (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
|
flag_id UUID NOT NULL REFERENCES feature_flags.flags(id) ON DELETE CASCADE,
|
|
user_id UUID REFERENCES users.users(id),
|
|
|
|
-- Result
|
|
flag_code VARCHAR(100) NOT NULL,
|
|
result BOOLEAN NOT NULL,
|
|
evaluation_reason VARCHAR(100), -- 'default', 'tenant_override', 'user_override', 'targeting_rule', 'percentage'
|
|
|
|
-- Context
|
|
context JSONB DEFAULT '{}'::jsonb,
|
|
|
|
-- Timestamp
|
|
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
|
);
|
|
|
|
-- Indexes
|
|
CREATE INDEX idx_flags_code ON feature_flags.flags(code);
|
|
CREATE INDEX idx_flags_status ON feature_flags.flags(status) WHERE status != 'disabled';
|
|
CREATE INDEX idx_flags_category ON feature_flags.flags(category);
|
|
|
|
CREATE INDEX idx_tenant_flags_tenant ON feature_flags.tenant_flags(tenant_id);
|
|
CREATE INDEX idx_tenant_flags_flag ON feature_flags.tenant_flags(flag_id);
|
|
|
|
CREATE INDEX idx_user_flags_user ON feature_flags.user_flags(user_id);
|
|
CREATE INDEX idx_user_flags_tenant ON feature_flags.user_flags(tenant_id);
|
|
|
|
CREATE INDEX idx_evaluations_tenant ON feature_flags.evaluations(tenant_id, created_at DESC);
|
|
CREATE INDEX idx_evaluations_flag ON feature_flags.evaluations(flag_id, created_at DESC);
|
|
|
|
-- RLS
|
|
ALTER TABLE feature_flags.tenant_flags ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE feature_flags.user_flags ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE feature_flags.evaluations ENABLE ROW LEVEL SECURITY;
|
|
|
|
CREATE POLICY tenant_flags_isolation ON feature_flags.tenant_flags
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
|
|
|
CREATE POLICY user_flags_isolation ON feature_flags.user_flags
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
|
|
|
CREATE POLICY evaluations_isolation ON feature_flags.evaluations
|
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
|
|
|
-- Trigger
|
|
CREATE OR REPLACE FUNCTION feature_flags.update_updated_at()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = NOW();
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE TRIGGER trg_flags_updated_at
|
|
BEFORE UPDATE ON feature_flags.flags
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION feature_flags.update_updated_at();
|
|
|
|
CREATE TRIGGER trg_tenant_flags_updated_at
|
|
BEFORE UPDATE ON feature_flags.tenant_flags
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION feature_flags.update_updated_at();
|
|
|
|
CREATE TRIGGER trg_user_flags_updated_at
|
|
BEFORE UPDATE ON feature_flags.user_flags
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION feature_flags.update_updated_at();
|
|
|
|
-- Function to evaluate flag
|
|
CREATE OR REPLACE FUNCTION feature_flags.evaluate_flag(
|
|
p_flag_code VARCHAR,
|
|
p_tenant_id UUID,
|
|
p_user_id UUID DEFAULT NULL
|
|
)
|
|
RETURNS BOOLEAN AS $$
|
|
DECLARE
|
|
v_flag RECORD;
|
|
v_tenant_override RECORD;
|
|
v_user_override RECORD;
|
|
v_result BOOLEAN;
|
|
BEGIN
|
|
-- Get flag
|
|
SELECT * INTO v_flag FROM feature_flags.flags WHERE code = p_flag_code;
|
|
|
|
IF NOT FOUND THEN
|
|
RETURN FALSE;
|
|
END IF;
|
|
|
|
-- Check user override
|
|
IF p_user_id IS NOT NULL THEN
|
|
SELECT * INTO v_user_override
|
|
FROM feature_flags.user_flags
|
|
WHERE flag_id = v_flag.id AND user_id = p_user_id
|
|
AND (expires_at IS NULL OR expires_at > NOW());
|
|
|
|
IF FOUND THEN
|
|
RETURN v_user_override.is_enabled;
|
|
END IF;
|
|
END IF;
|
|
|
|
-- Check tenant override
|
|
SELECT * INTO v_tenant_override
|
|
FROM feature_flags.tenant_flags
|
|
WHERE flag_id = v_flag.id AND tenant_id = p_tenant_id;
|
|
|
|
IF FOUND THEN
|
|
RETURN v_tenant_override.is_enabled;
|
|
END IF;
|
|
|
|
-- Check status
|
|
IF v_flag.status = 'disabled' THEN
|
|
RETURN FALSE;
|
|
ELSIF v_flag.status = 'enabled' THEN
|
|
RETURN TRUE;
|
|
ELSIF v_flag.status = 'percentage' THEN
|
|
-- Simple percentage rollout based on tenant_id
|
|
RETURN (abs(hashtext(p_tenant_id::TEXT)) % 100) < v_flag.rollout_percentage;
|
|
END IF;
|
|
|
|
RETURN v_flag.default_value;
|
|
END;
|
|
$$ LANGUAGE plpgsql STABLE;
|
|
|
|
-- Comments
|
|
COMMENT ON TABLE feature_flags.flags IS 'Global feature flag definitions';
|
|
COMMENT ON TABLE feature_flags.tenant_flags IS 'Tenant-specific flag overrides';
|
|
COMMENT ON TABLE feature_flags.user_flags IS 'User-specific flag overrides';
|
|
COMMENT ON TABLE feature_flags.evaluations IS 'Flag evaluation history for analytics';
|
|
COMMENT ON COLUMN feature_flags.flags.targeting_rules IS 'JSON rules for advanced targeting';
|