/** * FEATURE FLAGS SERVICE - REFERENCE IMPLEMENTATION * * @description Servicio de feature flags para activación/desactivación * de funcionalidades en runtime. * * @usage * ```typescript * if (await this.featureFlags.isEnabled('new-dashboard', userId)) { * return this.newDashboard(); * } * return this.legacyDashboard(); * ``` * * @origin gamilit/apps/backend/src/modules/admin/services/feature-flags.service.ts */ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; // Adaptar imports según proyecto // import { FeatureFlag, FeatureFlagOverride } from '../entities'; /** * Estrategias de rollout */ export enum RolloutStrategyEnum { /** Todos los usuarios */ ALL = 'all', /** Ningún usuario */ NONE = 'none', /** Porcentaje de usuarios */ PERCENTAGE = 'percentage', /** Lista de usuarios específicos */ USER_LIST = 'user_list', /** Por rol */ ROLE = 'role', /** Por tenant */ TENANT = 'tenant', } @Injectable() export class FeatureFlagsService { private readonly logger = new Logger(FeatureFlagsService.name); private cache = new Map(); private cacheExpiry = 0; private readonly CACHE_TTL = 60000; // 1 minuto constructor( @InjectRepository(FeatureFlag, 'config') private readonly flagRepo: Repository, @InjectRepository(FeatureFlagOverride, 'config') private readonly overrideRepo: Repository, ) {} /** * Verificar si una feature está habilitada * * @param flagKey - Clave única del feature flag * @param context - Contexto del usuario para evaluación */ async isEnabled(flagKey: string, context?: FeatureFlagContext): Promise { // 1. Verificar override específico del usuario if (context?.userId) { const override = await this.getUserOverride(flagKey, context.userId); if (override !== null) { return override; } } // 2. Obtener flag (con cache) const flag = await this.getFlag(flagKey); if (!flag) { this.logger.warn(`Feature flag not found: ${flagKey}`); return false; } // 3. Evaluar según estrategia return this.evaluateStrategy(flag, context); } /** * Obtener todos los flags con su estado para un usuario */ async getAllFlags(context?: FeatureFlagContext): Promise> { await this.refreshCacheIfNeeded(); const result: Record = {}; for (const [key, flag] of this.cache) { result[key] = await this.isEnabled(key, context); } return result; } /** * Crear o actualizar un feature flag */ async upsertFlag(dto: UpsertFeatureFlagDto): Promise { let flag = await this.flagRepo.findOne({ where: { key: dto.key } }); if (flag) { Object.assign(flag, dto); } else { flag = this.flagRepo.create(dto); } const saved = await this.flagRepo.save(flag); this.invalidateCache(); return saved; } /** * Establecer override para un usuario específico */ async setUserOverride( flagKey: string, userId: string, enabled: boolean, ): Promise { await this.overrideRepo.upsert( { flag_key: flagKey, user_id: userId, enabled }, ['flag_key', 'user_id'], ); } /** * Eliminar override de usuario */ async removeUserOverride(flagKey: string, userId: string): Promise { await this.overrideRepo.delete({ flag_key: flagKey, user_id: userId }); } // ============ HELPERS PRIVADOS ============ private async getFlag(key: string): Promise { await this.refreshCacheIfNeeded(); return this.cache.get(key) || null; } private async getUserOverride(flagKey: string, userId: string): Promise { const override = await this.overrideRepo.findOne({ where: { flag_key: flagKey, user_id: userId }, }); return override?.enabled ?? null; } private evaluateStrategy(flag: FeatureFlag, context?: FeatureFlagContext): boolean { if (!flag.is_active) return false; switch (flag.strategy) { case RolloutStrategyEnum.ALL: return true; case RolloutStrategyEnum.NONE: return false; case RolloutStrategyEnum.PERCENTAGE: if (!context?.userId) return false; // Hash consistente basado en userId const hash = this.hashUserId(context.userId, flag.key); return hash < (flag.percentage || 0); case RolloutStrategyEnum.USER_LIST: if (!context?.userId) return false; return (flag.user_list || []).includes(context.userId); case RolloutStrategyEnum.ROLE: if (!context?.role) return false; return (flag.allowed_roles || []).includes(context.role); case RolloutStrategyEnum.TENANT: if (!context?.tenantId) return false; return (flag.allowed_tenants || []).includes(context.tenantId); default: return false; } } private hashUserId(userId: string, flagKey: string): number { // Hash simple para distribución consistente const str = `${userId}-${flagKey}`; let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash |= 0; } return Math.abs(hash % 100); } private async refreshCacheIfNeeded(): Promise { if (Date.now() > this.cacheExpiry) { const flags = await this.flagRepo.find(); this.cache.clear(); for (const flag of flags) { this.cache.set(flag.key, flag); } this.cacheExpiry = Date.now() + this.CACHE_TTL; } } private invalidateCache(): void { this.cacheExpiry = 0; } } // ============ TIPOS ============ interface FeatureFlagContext { userId?: string; role?: string; tenantId?: string; } interface FeatureFlag { id: string; key: string; name: string; description?: string; is_active: boolean; strategy: RolloutStrategyEnum; percentage?: number; user_list?: string[]; allowed_roles?: string[]; allowed_tenants?: string[]; } interface FeatureFlagOverride { flag_key: string; user_id: string; enabled: boolean; } interface UpsertFeatureFlagDto { key: string; name: string; description?: string; is_active?: boolean; strategy?: RolloutStrategyEnum; percentage?: number; user_list?: string[]; allowed_roles?: string[]; allowed_tenants?: string[]; }