Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
913 lines
23 KiB
Markdown
913 lines
23 KiB
Markdown
# Guía de Implementación: Feature Flags
|
|
|
|
**Versión:** 1.0.0
|
|
**Tiempo estimado:** 1-2 horas
|
|
**Complejidad:** Media
|
|
|
|
---
|
|
|
|
## Pre-requisitos
|
|
|
|
- [ ] Proyecto NestJS existente
|
|
- [ ] TypeORM configurado
|
|
- [ ] PostgreSQL como base de datos
|
|
|
|
---
|
|
|
|
## Paso 1: Crear Estructura de Directorios
|
|
|
|
```bash
|
|
mkdir -p src/modules/feature-flags/entities
|
|
mkdir -p src/modules/feature-flags/services
|
|
mkdir -p src/modules/feature-flags/controllers
|
|
mkdir -p src/modules/feature-flags/dto
|
|
mkdir -p src/modules/feature-flags/guards
|
|
mkdir -p src/modules/feature-flags/decorators
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 2: Crear Entidad FeatureFlag
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/entities/feature-flag.entity.ts
|
|
import {
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
Column,
|
|
CreateDateColumn,
|
|
UpdateDateColumn,
|
|
Index,
|
|
Check,
|
|
} from 'typeorm';
|
|
|
|
@Entity({ schema: 'config', name: 'feature_flags' })
|
|
@Index('idx_feature_flags_key', ['featureKey'])
|
|
@Index('idx_feature_flags_enabled', ['isEnabled'], { where: '"is_enabled" = true' })
|
|
@Check('"rollout_percentage" >= 0 AND "rollout_percentage" <= 100')
|
|
export class FeatureFlag {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
|
|
tenantId?: string;
|
|
|
|
@Column({ name: 'feature_key', type: 'varchar', length: 100, unique: true })
|
|
featureKey: string;
|
|
|
|
@Column({ name: 'feature_name', type: 'varchar', length: 255 })
|
|
featureName: string;
|
|
|
|
@Column({ type: 'text', nullable: true })
|
|
description?: string;
|
|
|
|
@Column({ name: 'is_enabled', type: 'boolean', default: false })
|
|
isEnabled: boolean;
|
|
|
|
@Column({ name: 'rollout_percentage', type: 'integer', default: 0 })
|
|
rolloutPercentage: number;
|
|
|
|
@Column({ name: 'target_users', type: 'uuid', array: true, nullable: true })
|
|
targetUsers?: string[];
|
|
|
|
@Column({ name: 'target_roles', type: 'varchar', array: true, nullable: true })
|
|
targetRoles?: string[];
|
|
|
|
@Column({ name: 'target_conditions', type: 'jsonb', default: {} })
|
|
targetConditions: Record<string, any>;
|
|
|
|
@Column({ name: 'starts_at', type: 'timestamp with time zone', nullable: true })
|
|
startsAt?: Date;
|
|
|
|
@Column({ name: 'ends_at', type: 'timestamp with time zone', nullable: true })
|
|
endsAt?: Date;
|
|
|
|
@Column({ type: 'jsonb', default: {} })
|
|
metadata: Record<string, any>;
|
|
|
|
@Column({ name: 'created_by', type: 'uuid', nullable: true })
|
|
createdBy?: string;
|
|
|
|
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
|
|
updatedBy?: string;
|
|
|
|
@CreateDateColumn({ name: 'created_at' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ name: 'updated_at' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 3: Crear DTOs
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/dto/create-feature-flag.dto.ts
|
|
import {
|
|
IsString,
|
|
IsBoolean,
|
|
IsOptional,
|
|
IsInt,
|
|
Min,
|
|
Max,
|
|
IsArray,
|
|
IsObject,
|
|
MaxLength,
|
|
Matches,
|
|
IsDateString,
|
|
} from 'class-validator';
|
|
|
|
export class CreateFeatureFlagDto {
|
|
@IsString()
|
|
@MaxLength(100)
|
|
@Matches(/^[a-z][a-z0-9_]*$/, {
|
|
message: 'Key debe ser snake_case y comenzar con letra',
|
|
})
|
|
key: string;
|
|
|
|
@IsString()
|
|
@MaxLength(255)
|
|
name: string;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
description?: string;
|
|
|
|
@IsBoolean()
|
|
@IsOptional()
|
|
isEnabled?: boolean;
|
|
|
|
@IsInt()
|
|
@Min(0)
|
|
@Max(100)
|
|
@IsOptional()
|
|
rolloutPercentage?: number;
|
|
|
|
@IsArray()
|
|
@IsString({ each: true })
|
|
@IsOptional()
|
|
targetUsers?: string[];
|
|
|
|
@IsArray()
|
|
@IsString({ each: true })
|
|
@IsOptional()
|
|
targetRoles?: string[];
|
|
|
|
@IsObject()
|
|
@IsOptional()
|
|
targetConditions?: Record<string, any>;
|
|
|
|
@IsDateString()
|
|
@IsOptional()
|
|
startsAt?: string;
|
|
|
|
@IsDateString()
|
|
@IsOptional()
|
|
endsAt?: string;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
category?: string;
|
|
|
|
@IsObject()
|
|
@IsOptional()
|
|
metadata?: Record<string, any>;
|
|
}
|
|
|
|
// src/modules/feature-flags/dto/update-feature-flag.dto.ts
|
|
import { PartialType } from '@nestjs/mapped-types';
|
|
import { CreateFeatureFlagDto } from './create-feature-flag.dto';
|
|
|
|
export class UpdateFeatureFlagDto extends PartialType(CreateFeatureFlagDto) {}
|
|
|
|
// src/modules/feature-flags/dto/feature-flag-query.dto.ts
|
|
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
|
import { Transform } from 'class-transformer';
|
|
|
|
export class FeatureFlagQueryDto {
|
|
@IsBoolean()
|
|
@IsOptional()
|
|
@Transform(({ value }) => value === 'true')
|
|
isEnabled?: boolean;
|
|
|
|
@IsString()
|
|
@IsOptional()
|
|
category?: string;
|
|
}
|
|
|
|
// src/modules/feature-flags/dto/check-result.dto.ts
|
|
export class FeatureFlagCheckResultDto {
|
|
enabled: boolean;
|
|
reason: string;
|
|
}
|
|
|
|
// src/modules/feature-flags/dto/index.ts
|
|
export * from './create-feature-flag.dto';
|
|
export * from './update-feature-flag.dto';
|
|
export * from './feature-flag-query.dto';
|
|
export * from './check-result.dto';
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 4: Crear Servicio
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/services/feature-flags.service.ts
|
|
import {
|
|
Injectable,
|
|
NotFoundException,
|
|
ConflictException,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { createHash } from 'crypto';
|
|
import { FeatureFlag } from '../entities/feature-flag.entity';
|
|
import {
|
|
CreateFeatureFlagDto,
|
|
UpdateFeatureFlagDto,
|
|
FeatureFlagQueryDto,
|
|
FeatureFlagCheckResultDto,
|
|
} from '../dto';
|
|
|
|
@Injectable()
|
|
export class FeatureFlagsService {
|
|
constructor(
|
|
@InjectRepository(FeatureFlag)
|
|
private readonly featureFlagRepo: Repository<FeatureFlag>,
|
|
) {}
|
|
|
|
/**
|
|
* Obtener todas las feature flags con filtros opcionales
|
|
*/
|
|
async findAll(query?: FeatureFlagQueryDto): Promise<FeatureFlag[]> {
|
|
const qb = this.featureFlagRepo.createQueryBuilder('ff');
|
|
|
|
if (query?.isEnabled !== undefined) {
|
|
qb.andWhere('ff.is_enabled = :isEnabled', { isEnabled: query.isEnabled });
|
|
}
|
|
|
|
if (query?.category) {
|
|
qb.andWhere("ff.metadata->>'category' = :category", {
|
|
category: query.category,
|
|
});
|
|
}
|
|
|
|
return qb.orderBy('ff.feature_name', 'ASC').getMany();
|
|
}
|
|
|
|
/**
|
|
* Obtener una feature flag por su key
|
|
*/
|
|
async findOne(key: string): Promise<FeatureFlag> {
|
|
const flag = await this.featureFlagRepo.findOne({
|
|
where: { featureKey: key },
|
|
});
|
|
|
|
if (!flag) {
|
|
throw new NotFoundException(`Feature flag "${key}" no encontrada`);
|
|
}
|
|
|
|
return flag;
|
|
}
|
|
|
|
/**
|
|
* Crear una nueva feature flag
|
|
*/
|
|
async create(dto: CreateFeatureFlagDto, createdBy?: string): Promise<FeatureFlag> {
|
|
const existing = await this.featureFlagRepo.findOne({
|
|
where: { featureKey: dto.key },
|
|
});
|
|
|
|
if (existing) {
|
|
throw new ConflictException(`Feature flag "${dto.key}" ya existe`);
|
|
}
|
|
|
|
const flag = this.featureFlagRepo.create({
|
|
featureKey: dto.key,
|
|
featureName: dto.name,
|
|
description: dto.description,
|
|
isEnabled: dto.isEnabled ?? false,
|
|
rolloutPercentage: dto.rolloutPercentage ?? 0,
|
|
targetUsers: dto.targetUsers,
|
|
targetRoles: dto.targetRoles,
|
|
targetConditions: dto.targetConditions ?? {},
|
|
startsAt: dto.startsAt ? new Date(dto.startsAt) : undefined,
|
|
endsAt: dto.endsAt ? new Date(dto.endsAt) : undefined,
|
|
metadata: {
|
|
...dto.metadata,
|
|
category: dto.category,
|
|
},
|
|
createdBy,
|
|
});
|
|
|
|
return this.featureFlagRepo.save(flag);
|
|
}
|
|
|
|
/**
|
|
* Actualizar una feature flag existente
|
|
*/
|
|
async update(
|
|
key: string,
|
|
dto: UpdateFeatureFlagDto,
|
|
updatedBy?: string,
|
|
): Promise<FeatureFlag> {
|
|
const flag = await this.findOne(key);
|
|
|
|
if (dto.name !== undefined) flag.featureName = dto.name;
|
|
if (dto.description !== undefined) flag.description = dto.description;
|
|
if (dto.isEnabled !== undefined) flag.isEnabled = dto.isEnabled;
|
|
if (dto.rolloutPercentage !== undefined) flag.rolloutPercentage = dto.rolloutPercentage;
|
|
if (dto.targetUsers !== undefined) flag.targetUsers = dto.targetUsers;
|
|
if (dto.targetRoles !== undefined) flag.targetRoles = dto.targetRoles;
|
|
if (dto.targetConditions !== undefined) flag.targetConditions = dto.targetConditions;
|
|
if (dto.startsAt !== undefined) flag.startsAt = new Date(dto.startsAt);
|
|
if (dto.endsAt !== undefined) flag.endsAt = new Date(dto.endsAt);
|
|
|
|
if (dto.metadata !== undefined || dto.category !== undefined) {
|
|
flag.metadata = {
|
|
...flag.metadata,
|
|
...dto.metadata,
|
|
...(dto.category && { category: dto.category }),
|
|
};
|
|
}
|
|
|
|
flag.updatedBy = updatedBy;
|
|
|
|
return this.featureFlagRepo.save(flag);
|
|
}
|
|
|
|
/**
|
|
* Eliminar una feature flag
|
|
*/
|
|
async remove(key: string): Promise<void> {
|
|
const flag = await this.findOne(key);
|
|
await this.featureFlagRepo.remove(flag);
|
|
}
|
|
|
|
/**
|
|
* Verificar si una feature está habilitada para un usuario
|
|
*/
|
|
async isEnabled(
|
|
key: string,
|
|
userId?: string,
|
|
userRoles?: string[],
|
|
): Promise<FeatureFlagCheckResultDto> {
|
|
try {
|
|
const flag = await this.findOne(key);
|
|
|
|
// 1. Feature habilitada globalmente?
|
|
if (!flag.isEnabled) {
|
|
return { enabled: false, reason: 'Feature deshabilitada globalmente' };
|
|
}
|
|
|
|
// 2. Verificar período de validez
|
|
const now = new Date();
|
|
if (flag.startsAt && now < flag.startsAt) {
|
|
return { enabled: false, reason: 'Feature no ha iniciado aún' };
|
|
}
|
|
if (flag.endsAt && now > flag.endsAt) {
|
|
return { enabled: false, reason: 'Feature ha expirado' };
|
|
}
|
|
|
|
// 3. Usuario en target_users (early access)?
|
|
if (userId && flag.targetUsers?.length > 0) {
|
|
if (flag.targetUsers.includes(userId)) {
|
|
return { enabled: true, reason: 'Usuario en lista de early access' };
|
|
}
|
|
}
|
|
|
|
// 4. Usuario tiene target_role?
|
|
if (userRoles && flag.targetRoles?.length > 0) {
|
|
const hasRole = userRoles.some((role) => flag.targetRoles?.includes(role));
|
|
if (hasRole) {
|
|
return { enabled: true, reason: 'Usuario tiene rol objetivo' };
|
|
}
|
|
}
|
|
|
|
// 5. Rollout 100%?
|
|
if (flag.rolloutPercentage === 100) {
|
|
return { enabled: true, reason: 'Rollout al 100%' };
|
|
}
|
|
|
|
// 6. Rollout 0%?
|
|
if (flag.rolloutPercentage === 0) {
|
|
return { enabled: false, reason: 'Rollout al 0%' };
|
|
}
|
|
|
|
// 7. Hash del userId para rollout gradual
|
|
if (userId) {
|
|
const hash = this.hashUserId(userId, key);
|
|
const isInRollout = hash < flag.rolloutPercentage;
|
|
return {
|
|
enabled: isInRollout,
|
|
reason: isInRollout
|
|
? `Usuario en grupo de rollout ${flag.rolloutPercentage}%`
|
|
: `Usuario fuera del grupo de rollout ${flag.rolloutPercentage}%`,
|
|
};
|
|
}
|
|
|
|
// Sin userId, usar probabilidad
|
|
return {
|
|
enabled: Math.random() * 100 < flag.rolloutPercentage,
|
|
reason: `Selección aleatoria basada en ${flag.rolloutPercentage}%`,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof NotFoundException) {
|
|
return { enabled: false, reason: 'Feature flag no encontrada' };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hash consistente para rollout gradual
|
|
* Retorna número entre 0 y 100
|
|
*/
|
|
private hashUserId(userId: string, featureKey: string): number {
|
|
const hash = createHash('sha256')
|
|
.update(`${userId}:${featureKey}`)
|
|
.digest('hex');
|
|
|
|
const hashInt = parseInt(hash.substring(0, 8), 16);
|
|
return hashInt % 101;
|
|
}
|
|
|
|
/**
|
|
* Habilitar feature flag
|
|
*/
|
|
async enable(key: string, updatedBy?: string): Promise<FeatureFlag> {
|
|
return this.update(key, { isEnabled: true }, updatedBy);
|
|
}
|
|
|
|
/**
|
|
* Deshabilitar feature flag
|
|
*/
|
|
async disable(key: string, updatedBy?: string): Promise<FeatureFlag> {
|
|
return this.update(key, { isEnabled: false }, updatedBy);
|
|
}
|
|
|
|
/**
|
|
* Actualizar porcentaje de rollout
|
|
*/
|
|
async updateRollout(
|
|
key: string,
|
|
percentage: number,
|
|
updatedBy?: string,
|
|
): Promise<FeatureFlag> {
|
|
return this.update(key, { rolloutPercentage: percentage }, updatedBy);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 5: Crear Guard
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/guards/feature-flag.guard.ts
|
|
import {
|
|
Injectable,
|
|
CanActivate,
|
|
ExecutionContext,
|
|
ForbiddenException,
|
|
} from '@nestjs/common';
|
|
import { Reflector } from '@nestjs/core';
|
|
import { FeatureFlagsService } from '../services/feature-flags.service';
|
|
import { FEATURE_FLAG_KEY } from '../decorators/feature-flag.decorator';
|
|
|
|
@Injectable()
|
|
export class FeatureFlagGuard implements CanActivate {
|
|
constructor(
|
|
private reflector: Reflector,
|
|
private featureFlagsService: FeatureFlagsService,
|
|
) {}
|
|
|
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
const featureKey = this.reflector.getAllAndOverride<string>(
|
|
FEATURE_FLAG_KEY,
|
|
[context.getHandler(), context.getClass()],
|
|
);
|
|
|
|
if (!featureKey) {
|
|
return true; // No feature flag requerida
|
|
}
|
|
|
|
const request = context.switchToHttp().getRequest();
|
|
const userId = request.user?.id;
|
|
const userRoles = request.user?.roles || [];
|
|
|
|
const result = await this.featureFlagsService.isEnabled(
|
|
featureKey,
|
|
userId,
|
|
userRoles,
|
|
);
|
|
|
|
if (!result.enabled) {
|
|
throw new ForbiddenException(
|
|
`Feature "${featureKey}" no disponible: ${result.reason}`,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 6: Crear Decoradores
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/decorators/feature-flag.decorator.ts
|
|
import { SetMetadata } from '@nestjs/common';
|
|
|
|
export const FEATURE_FLAG_KEY = 'feature_flag';
|
|
|
|
/**
|
|
* Decorador para marcar rutas que requieren una feature flag
|
|
* @param key - Clave de la feature flag
|
|
*/
|
|
export const FeatureFlag = (key: string) => SetMetadata(FEATURE_FLAG_KEY, key);
|
|
|
|
// src/modules/feature-flags/decorators/check-feature.decorator.ts
|
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
import { FeatureFlagsService } from '../services/feature-flags.service';
|
|
|
|
/**
|
|
* Decorador de parámetro para verificar feature inline
|
|
* Nota: Este decorador requiere inyección manual del servicio
|
|
*/
|
|
export const CheckFeature = createParamDecorator(
|
|
async (featureKey: string, ctx: ExecutionContext): Promise<boolean> => {
|
|
// Nota: Los decoradores de parámetro no pueden inyectar servicios directamente
|
|
// Este decorador retorna un placeholder, la verificación real se hace en el servicio
|
|
return featureKey;
|
|
},
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 7: Crear Controller
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/controllers/feature-flags.controller.ts
|
|
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
Request,
|
|
} from '@nestjs/common';
|
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
|
import { AdminGuard } from '../../auth/guards/admin.guard';
|
|
import { FeatureFlagsService } from '../services/feature-flags.service';
|
|
import {
|
|
CreateFeatureFlagDto,
|
|
UpdateFeatureFlagDto,
|
|
FeatureFlagQueryDto,
|
|
FeatureFlagCheckResultDto,
|
|
} from '../dto';
|
|
import { FeatureFlag } from '../entities/feature-flag.entity';
|
|
|
|
@Controller('admin/feature-flags')
|
|
@UseGuards(JwtAuthGuard, AdminGuard)
|
|
export class FeatureFlagsController {
|
|
constructor(private readonly featureFlagsService: FeatureFlagsService) {}
|
|
|
|
@Get()
|
|
async findAll(@Query() query: FeatureFlagQueryDto): Promise<FeatureFlag[]> {
|
|
return this.featureFlagsService.findAll(query);
|
|
}
|
|
|
|
@Get(':key')
|
|
async findOne(@Param('key') key: string): Promise<FeatureFlag> {
|
|
return this.featureFlagsService.findOne(key);
|
|
}
|
|
|
|
@Post()
|
|
async create(
|
|
@Body() dto: CreateFeatureFlagDto,
|
|
@Request() req: any,
|
|
): Promise<FeatureFlag> {
|
|
return this.featureFlagsService.create(dto, req.user?.id);
|
|
}
|
|
|
|
@Put(':key')
|
|
async update(
|
|
@Param('key') key: string,
|
|
@Body() dto: UpdateFeatureFlagDto,
|
|
@Request() req: any,
|
|
): Promise<FeatureFlag> {
|
|
return this.featureFlagsService.update(key, dto, req.user?.id);
|
|
}
|
|
|
|
@Delete(':key')
|
|
async remove(@Param('key') key: string): Promise<void> {
|
|
return this.featureFlagsService.remove(key);
|
|
}
|
|
|
|
@Post(':key/enable')
|
|
async enable(
|
|
@Param('key') key: string,
|
|
@Request() req: any,
|
|
): Promise<FeatureFlag> {
|
|
return this.featureFlagsService.enable(key, req.user?.id);
|
|
}
|
|
|
|
@Post(':key/disable')
|
|
async disable(
|
|
@Param('key') key: string,
|
|
@Request() req: any,
|
|
): Promise<FeatureFlag> {
|
|
return this.featureFlagsService.disable(key, req.user?.id);
|
|
}
|
|
|
|
@Put(':key/rollout')
|
|
async updateRollout(
|
|
@Param('key') key: string,
|
|
@Body('percentage') percentage: number,
|
|
@Request() req: any,
|
|
): Promise<FeatureFlag> {
|
|
return this.featureFlagsService.updateRollout(key, percentage, req.user?.id);
|
|
}
|
|
}
|
|
|
|
// Controller público para verificar features
|
|
@Controller('feature-flags')
|
|
export class FeatureFlagsPublicController {
|
|
constructor(private readonly featureFlagsService: FeatureFlagsService) {}
|
|
|
|
@Post(':key/check')
|
|
@UseGuards(JwtAuthGuard)
|
|
async checkFeature(
|
|
@Param('key') key: string,
|
|
@Request() req: any,
|
|
): Promise<FeatureFlagCheckResultDto> {
|
|
return this.featureFlagsService.isEnabled(
|
|
key,
|
|
req.user?.id,
|
|
req.user?.roles,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 8: Crear Módulo
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/feature-flags.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
import { FeatureFlag } from './entities/feature-flag.entity';
|
|
import { FeatureFlagsService } from './services/feature-flags.service';
|
|
import {
|
|
FeatureFlagsController,
|
|
FeatureFlagsPublicController,
|
|
} from './controllers/feature-flags.controller';
|
|
import { FeatureFlagGuard } from './guards/feature-flag.guard';
|
|
|
|
@Module({
|
|
imports: [TypeOrmModule.forFeature([FeatureFlag])],
|
|
controllers: [FeatureFlagsController, FeatureFlagsPublicController],
|
|
providers: [FeatureFlagsService, FeatureFlagGuard],
|
|
exports: [FeatureFlagsService, FeatureFlagGuard],
|
|
})
|
|
export class FeatureFlagsModule {}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 9: Registrar en AppModule
|
|
|
|
```typescript
|
|
// src/app.module.ts
|
|
import { Module } from '@nestjs/common';
|
|
import { FeatureFlagsModule } from './modules/feature-flags/feature-flags.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
// ... otros módulos
|
|
FeatureFlagsModule,
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 10: Migraciones SQL
|
|
|
|
```sql
|
|
-- migrations/001_create_feature_flags.sql
|
|
CREATE SCHEMA IF NOT EXISTS config;
|
|
|
|
CREATE TABLE config.feature_flags (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
tenant_id UUID,
|
|
feature_key VARCHAR(100) UNIQUE NOT NULL,
|
|
feature_name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
is_enabled BOOLEAN DEFAULT false,
|
|
rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100),
|
|
target_users UUID[],
|
|
target_roles VARCHAR(50)[],
|
|
target_conditions JSONB DEFAULT '{}',
|
|
starts_at TIMESTAMP WITH TIME ZONE,
|
|
ends_at TIMESTAMP WITH TIME ZONE,
|
|
metadata JSONB DEFAULT '{}',
|
|
created_by UUID,
|
|
updated_by UUID,
|
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
-- Índices
|
|
CREATE INDEX idx_feature_flags_key ON config.feature_flags(feature_key);
|
|
CREATE INDEX idx_feature_flags_enabled ON config.feature_flags(is_enabled) WHERE is_enabled = true;
|
|
CREATE INDEX idx_feature_flags_dates ON config.feature_flags(starts_at, ends_at) WHERE is_enabled = true;
|
|
|
|
-- Trigger para updated_at
|
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
|
RETURN NEW;
|
|
END;
|
|
$$ language 'plpgsql';
|
|
|
|
CREATE TRIGGER update_feature_flags_updated_at
|
|
BEFORE UPDATE ON config.feature_flags
|
|
FOR EACH ROW
|
|
EXECUTE FUNCTION update_updated_at_column();
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 11: Uso en Código
|
|
|
|
### Verificación programática
|
|
|
|
```typescript
|
|
// En cualquier servicio
|
|
@Injectable()
|
|
export class CheckoutService {
|
|
constructor(
|
|
private readonly featureFlagsService: FeatureFlagsService,
|
|
) {}
|
|
|
|
async processCheckout(userId: string, cart: Cart) {
|
|
const useNewCheckout = await this.featureFlagsService.isEnabled(
|
|
'new_checkout_flow',
|
|
userId,
|
|
);
|
|
|
|
if (useNewCheckout.enabled) {
|
|
return this.processNewCheckout(cart);
|
|
}
|
|
return this.processLegacyCheckout(cart);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Proteger rutas con Guard
|
|
|
|
```typescript
|
|
@Controller('experiments')
|
|
@UseGuards(JwtAuthGuard)
|
|
export class ExperimentsController {
|
|
@Get('new-dashboard')
|
|
@UseGuards(FeatureFlagGuard)
|
|
@FeatureFlag('new_dashboard')
|
|
async getNewDashboard() {
|
|
return { message: 'Bienvenido al nuevo dashboard!' };
|
|
}
|
|
}
|
|
```
|
|
|
|
### Helper para frontend
|
|
|
|
```typescript
|
|
// src/modules/feature-flags/feature-flags.helper.ts
|
|
import { FeatureFlagsService } from './services/feature-flags.service';
|
|
|
|
export async function getFeatureFlagsForUser(
|
|
service: FeatureFlagsService,
|
|
userId: string,
|
|
userRoles: string[],
|
|
featureKeys: string[],
|
|
): Promise<Record<string, boolean>> {
|
|
const results: Record<string, boolean> = {};
|
|
|
|
await Promise.all(
|
|
featureKeys.map(async (key) => {
|
|
const result = await service.isEnabled(key, userId, userRoles);
|
|
results[key] = result.enabled;
|
|
}),
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
// Uso: endpoint que retorna todas las features del usuario
|
|
@Get('my-features')
|
|
@UseGuards(JwtAuthGuard)
|
|
async getMyFeatures(@Request() req: any) {
|
|
const keys = ['new_checkout', 'dark_mode', 'beta_features'];
|
|
return getFeatureFlagsForUser(
|
|
this.featureFlagsService,
|
|
req.user.id,
|
|
req.user.roles,
|
|
keys,
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Variables de Entorno
|
|
|
|
```env
|
|
# Feature Flags (opcional)
|
|
FEATURE_FLAGS_CACHE_TTL=300
|
|
FEATURE_FLAGS_DEFAULT=false
|
|
```
|
|
|
|
---
|
|
|
|
## Checklist de Implementación
|
|
|
|
- [ ] Entidad FeatureFlag creada
|
|
- [ ] DTOs de validación creados
|
|
- [ ] FeatureFlagsService implementado con hash consistente
|
|
- [ ] FeatureFlagGuard creado
|
|
- [ ] Decorador @FeatureFlag creado
|
|
- [ ] Controllers (admin y público) implementados
|
|
- [ ] Módulo registrado en AppModule
|
|
- [ ] Migración SQL ejecutada
|
|
- [ ] Build pasa sin errores
|
|
- [ ] Test: crear flag y verificar isEnabled
|
|
|
|
---
|
|
|
|
## Verificar Funcionamiento
|
|
|
|
```bash
|
|
# 1. Crear feature flag
|
|
curl -X POST http://localhost:3000/api/admin/feature-flags \
|
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{
|
|
"key": "test_feature",
|
|
"name": "Test Feature",
|
|
"isEnabled": true,
|
|
"rolloutPercentage": 50
|
|
}'
|
|
|
|
# 2. Verificar si está habilitada para un usuario
|
|
curl -X POST http://localhost:3000/api/feature-flags/test_feature/check \
|
|
-H "Authorization: Bearer $USER_TOKEN"
|
|
|
|
# 3. Actualizar rollout
|
|
curl -X PUT http://localhost:3000/api/admin/feature-flags/test_feature/rollout \
|
|
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"percentage": 100}'
|
|
|
|
# 4. Deshabilitar
|
|
curl -X POST http://localhost:3000/api/admin/feature-flags/test_feature/disable \
|
|
-H "Authorization: Bearer $ADMIN_TOKEN"
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Feature siempre retorna false
|
|
- Verificar que `is_enabled = true`
|
|
- Verificar que `rollout_percentage > 0` o usuario en `target_users`
|
|
- Verificar período de validez (`starts_at`, `ends_at`)
|
|
|
|
### Rollout no es consistente
|
|
- Verificar que se pase el mismo `userId`
|
|
- El hash depende de `userId + featureKey`
|
|
|
|
### Guard no funciona
|
|
- Verificar que `FeatureFlagGuard` esté registrado en providers
|
|
- Verificar que `@FeatureFlag('key')` esté en el handler
|
|
|
|
---
|
|
|
|
**Versión:** 1.0.0
|
|
**Sistema:** SIMCO Catálogo
|