New projects created: - michangarrito (marketplace mobile) - template-saas (SaaS template) - clinica-dental (dental ERP) - clinica-veterinaria (veterinary ERP) Architecture updates: - Move catalog from core/ to shared/ - Add MCP servers structure and templates - Add git management scripts - Update SUBREPOSITORIOS.md with 15 new repos - Update .gitignore for new projects Repository infrastructure: - 4 main repositories - 11 subrepositorios - Gitea remotes configured 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
248 lines
6.6 KiB
TypeScript
248 lines
6.6 KiB
TypeScript
/**
|
|
* 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<string, FeatureFlag>();
|
|
private cacheExpiry = 0;
|
|
private readonly CACHE_TTL = 60000; // 1 minuto
|
|
|
|
constructor(
|
|
@InjectRepository(FeatureFlag, 'config')
|
|
private readonly flagRepo: Repository<FeatureFlag>,
|
|
|
|
@InjectRepository(FeatureFlagOverride, 'config')
|
|
private readonly overrideRepo: Repository<FeatureFlagOverride>,
|
|
) {}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
// 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<Record<string, boolean>> {
|
|
await this.refreshCacheIfNeeded();
|
|
|
|
const result: Record<string, boolean> = {};
|
|
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<FeatureFlag> {
|
|
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<void> {
|
|
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<void> {
|
|
await this.overrideRepo.delete({ flag_key: flagKey, user_id: userId });
|
|
}
|
|
|
|
// ============ HELPERS PRIVADOS ============
|
|
|
|
private async getFlag(key: string): Promise<FeatureFlag | null> {
|
|
await this.refreshCacheIfNeeded();
|
|
return this.cache.get(key) || null;
|
|
}
|
|
|
|
private async getUserOverride(flagKey: string, userId: string): Promise<boolean | null> {
|
|
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<void> {
|
|
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[];
|
|
}
|