workspace-v1/shared/libs/feature-flags/IMPLEMENTATION.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
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
2026-01-04 03:37:42 -06:00

23 KiB

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

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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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

-- 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

// 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

@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

// 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

# 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

# 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