erp-core/docs/02-fase-core-business/MGN-006-settings/especificaciones/ET-SETTINGS-backend.md

15 KiB

ET-SETTINGS-BACKEND: Servicios y API REST

Identificacion

Campo Valor
ID ET-SETTINGS-BACKEND
Modulo MGN-006 Settings
Version 1.0
Estado En Diseno
Framework NestJS
Autor Requirements-Analyst
Fecha 2025-12-05

Estructura de Archivos

apps/backend/src/modules/settings/
├── settings.module.ts
├── controllers/
│   ├── system-settings.controller.ts
│   ├── tenant-settings.controller.ts
│   ├── user-preferences.controller.ts
│   └── feature-flags.controller.ts
├── services/
│   ├── system-settings.service.ts
│   ├── tenant-settings.service.ts
│   ├── user-preferences.service.ts
│   └── feature-flags.service.ts
├── entities/
│   ├── system-setting.entity.ts
│   ├── tenant-setting.entity.ts
│   ├── user-preference.entity.ts
│   ├── feature-flag.entity.ts
│   └── feature-flag-override.entity.ts
├── dto/
│   ├── update-setting.dto.ts
│   ├── update-preference.dto.ts
│   └── feature-flag.dto.ts
├── decorators/
│   └── setting.decorator.ts
└── guards/
    └── feature-flag.guard.ts

Entidades

SystemSetting Entity

@Entity('system_settings', { schema: 'core_settings' })
export class SystemSetting {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true, length: 100 })
  key: string;

  @Column({ type: 'jsonb' })
  value: any;

  @Column({ name: 'data_type', length: 20 })
  dataType: SettingDataType;

  @Column({ length: 50 })
  category: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ name: 'is_public', default: false })
  isPublic: boolean;

  @Column({ name: 'is_editable', default: true })
  isEditable: boolean;

  @Column({ name: 'default_value', type: 'jsonb', nullable: true })
  defaultValue: any;

  @Column({ name: 'validation_rules', type: 'jsonb', default: {} })
  validationRules: ValidationRules;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}

FeatureFlag Entity

@Entity('feature_flags', { schema: 'core_settings' })
export class FeatureFlag {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true, length: 100 })
  key: string;

  @Column({ length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ name: 'flag_type', length: 20, default: 'boolean' })
  flagType: FeatureFlagType;

  @Column({ name: 'default_value', type: 'jsonb', default: false })
  defaultValue: any;

  @Column({ name: 'rollout_config', type: 'jsonb', default: {} })
  rolloutConfig: RolloutConfig;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
  expiresAt: Date;

  @OneToMany(() => FeatureFlagOverride, o => o.featureFlag)
  overrides: FeatureFlagOverride[];
}

Servicios

SystemSettingsService

@Injectable()
export class SystemSettingsService {
  constructor(
    @InjectRepository(SystemSetting)
    private readonly repo: Repository<SystemSetting>,
    private readonly cacheManager: Cache,
  ) {}

  async getAll(category?: string): Promise<SystemSetting[]> {
    const cacheKey = `system_settings:${category || 'all'}`;
    let settings = await this.cacheManager.get<SystemSetting[]>(cacheKey);

    if (!settings) {
      const qb = this.repo.createQueryBuilder('s');
      if (category) qb.where('s.category = :category', { category });
      settings = await qb.orderBy('s.key').getMany();
      await this.cacheManager.set(cacheKey, settings, 3600);
    }

    return settings;
  }

  async get(key: string): Promise<SystemSetting> {
    const setting = await this.repo.findOne({ where: { key } });
    if (!setting) throw new NotFoundException(`Setting ${key} not found`);
    return setting;
  }

  async update(key: string, value: any, userId: string): Promise<SystemSetting> {
    const setting = await this.get(key);

    if (!setting.isEditable) {
      throw new ForbiddenException('Setting is not editable');
    }

    this.validateValue(value, setting.dataType, setting.validationRules);

    setting.value = value;
    const saved = await this.repo.save(setting);

    await this.cacheManager.del(`system_settings:${setting.category}`);
    await this.cacheManager.del('system_settings:all');

    return saved;
  }

  async reset(key: string): Promise<SystemSetting> {
    const setting = await this.get(key);
    setting.value = setting.defaultValue;
    return this.repo.save(setting);
  }

  private validateValue(value: any, dataType: string, rules: ValidationRules): void {
    // Implementar validacion segun tipo y reglas
  }
}

FeatureFlagsService

@Injectable()
export class FeatureFlagsService {
  constructor(
    @InjectRepository(FeatureFlag)
    private readonly flagRepo: Repository<FeatureFlag>,
    @InjectRepository(FeatureFlagOverride)
    private readonly overrideRepo: Repository<FeatureFlagOverride>,
    private readonly cacheManager: Cache,
  ) {}

  async evaluate(
    key: string,
    context: { tenantId?: string; userId?: string }
  ): Promise<FeatureEvaluationResult> {
    const cacheKey = `feature:${key}:${context.tenantId}:${context.userId}`;
    let result = await this.cacheManager.get<FeatureEvaluationResult>(cacheKey);

    if (result) return result;

    const flag = await this.flagRepo.findOne({
      where: { key, isActive: true },
      relations: ['overrides'],
    });

    if (!flag || (flag.expiresAt && flag.expiresAt < new Date())) {
      return { enabled: false, source: 'not_found' };
    }

    // Check user override
    if (context.userId) {
      const userOverride = flag.overrides.find(
        o => o.level === 'user' && o.levelId === context.userId && o.isActive
      );
      if (userOverride) {
        result = { enabled: userOverride.value, source: 'user_override' };
        await this.cacheManager.set(cacheKey, result, 300);
        return result;
      }
    }

    // Check tenant override
    if (context.tenantId) {
      const tenantOverride = flag.overrides.find(
        o => o.level === 'tenant' && o.levelId === context.tenantId && o.isActive
      );
      if (tenantOverride) {
        result = { enabled: tenantOverride.value, source: 'tenant_override' };
        await this.cacheManager.set(cacheKey, result, 300);
        return result;
      }
    }

    // Evaluate based on flag type
    result = this.evaluateFlag(flag, context);
    await this.cacheManager.set(cacheKey, result, 300);
    return result;
  }

  private evaluateFlag(
    flag: FeatureFlag,
    context: { userId?: string }
  ): FeatureEvaluationResult {
    switch (flag.flagType) {
      case 'boolean':
        return { enabled: flag.defaultValue, source: 'default' };

      case 'percentage':
        const config = flag.rolloutConfig as PercentageConfig;
        const hash = this.hashUserId(context.userId, flag.key);
        const enabled = hash < config.percentage;
        return { enabled, source: 'percentage' };

      case 'variant':
        const variantConfig = flag.rolloutConfig as VariantConfig;
        const variant = this.selectVariant(context.userId, flag.key, variantConfig);
        return { enabled: true, variant, source: 'variant' };

      default:
        return { enabled: false, source: 'unknown' };
    }
  }

  private hashUserId(userId: string, seed: string): number {
    // Deterministic hash 0-100
    const hash = createHash('md5').update(`${userId}:${seed}`).digest('hex');
    return parseInt(hash.substring(0, 8), 16) % 100;
  }

  async createOverride(
    flagKey: string,
    dto: CreateOverrideDto
  ): Promise<FeatureFlagOverride> {
    const flag = await this.flagRepo.findOne({ where: { key: flagKey } });
    if (!flag) throw new NotFoundException('Flag not found');

    const override = this.overrideRepo.create({
      featureFlagId: flag.id,
      level: dto.level,
      levelId: dto.levelId,
      value: dto.value,
    });

    const saved = await this.overrideRepo.save(override);
    await this.invalidateCache(flagKey);
    return saved;
  }

  private async invalidateCache(key: string): Promise<void> {
    const keys = await this.cacheManager.store.keys(`feature:${key}:*`);
    await Promise.all(keys.map(k => this.cacheManager.del(k)));
  }
}

UserPreferencesService

@Injectable()
export class UserPreferencesService {
  constructor(
    @InjectRepository(UserPreference)
    private readonly repo: Repository<UserPreference>,
    private readonly tenantSettingsService: TenantSettingsService,
  ) {}

  async getAll(userId: string): Promise<Record<string, any>> {
    const prefs = await this.repo.find({ where: { userId } });
    return prefs.reduce((acc, p) => {
      this.setNestedValue(acc, p.key, p.value);
      return acc;
    }, {});
  }

  async update(
    userId: string,
    updates: Record<string, any>
  ): Promise<{ updated: string[]; syncedAt: Date }> {
    const updated: string[] = [];
    const now = new Date();

    for (const [key, value] of Object.entries(updates)) {
      await this.repo.upsert(
        { userId, key, value, syncedAt: now },
        ['userId', 'key']
      );
      updated.push(key);
    }

    return { updated, syncedAt: now };
  }

  async reset(userId: string, keys?: string[]): Promise<void> {
    const qb = this.repo.createQueryBuilder()
      .delete()
      .where('user_id = :userId', { userId });

    if (keys?.length) {
      qb.andWhere('key IN (:...keys)', { keys });
    }

    await qb.execute();
  }

  async getEffective(
    userId: string,
    tenantId: string
  ): Promise<Record<string, any>> {
    const tenantSettings = await this.tenantSettingsService.getEffective(tenantId);
    const userPrefs = await this.getAll(userId);

    // User prefs override tenant settings
    return this.deepMerge(tenantSettings, userPrefs);
  }
}

Controladores

SystemSettingsController

@ApiTags('System Settings')
@Controller('settings/system')
@UseGuards(JwtAuthGuard, RbacGuard)
export class SystemSettingsController {
  constructor(private readonly service: SystemSettingsService) {}

  @Get()
  @Permissions('settings.system.read')
  async findAll(@Query('category') category?: string) {
    return this.service.getAll(category);
  }

  @Get(':key')
  @Permissions('settings.system.read')
  async findOne(@Param('key') key: string) {
    return this.service.get(key);
  }

  @Put(':key')
  @Permissions('settings.system.update')
  async update(
    @Param('key') key: string,
    @Body() dto: UpdateSettingDto,
    @CurrentUser() user: User
  ) {
    return this.service.update(key, dto.value, user.id);
  }

  @Post(':key/reset')
  @Permissions('settings.system.update')
  async reset(@Param('key') key: string) {
    return this.service.reset(key);
  }
}

FeatureFlagsController

@ApiTags('Feature Flags')
@Controller('features')
@UseGuards(JwtAuthGuard)
export class FeatureFlagsController {
  constructor(private readonly service: FeatureFlagsService) {}

  @Get(':key')
  async evaluate(
    @Param('key') key: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: User
  ) {
    return this.service.evaluate(key, { tenantId, userId: user.id });
  }

  @Post('evaluate')
  async evaluateMultiple(
    @Body('keys') keys: string[],
    @TenantId() tenantId: string,
    @CurrentUser() user: User
  ) {
    const results: Record<string, FeatureEvaluationResult> = {};
    for (const key of keys) {
      results[key] = await this.service.evaluate(key, { tenantId, userId: user.id });
    }
    return results;
  }
}

@ApiTags('Feature Flags Admin')
@Controller('admin/features')
@UseGuards(JwtAuthGuard, RbacGuard)
@Permissions('settings.features.manage')
export class FeatureFlagsAdminController {
  constructor(private readonly service: FeatureFlagsService) {}

  @Get()
  async findAll() {
    return this.service.findAll();
  }

  @Post(':key/overrides')
  async createOverride(
    @Param('key') key: string,
    @Body() dto: CreateOverrideDto
  ) {
    return this.service.createOverride(key, dto);
  }

  @Patch(':key')
  async updateRollout(
    @Param('key') key: string,
    @Body() dto: UpdateRolloutDto
  ) {
    return this.service.updateRollout(key, dto);
  }
}

Decoradores y Guards

FeatureFlag Guard

// guards/feature-flag.guard.ts
@Injectable()
export class FeatureFlagGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly featureFlagsService: FeatureFlagsService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const requiredFlag = this.reflector.get<string>(
      'feature_flag',
      context.getHandler()
    );

    if (!requiredFlag) return true;

    const request = context.switchToHttp().getRequest();
    const result = await this.featureFlagsService.evaluate(requiredFlag, {
      tenantId: request.tenantId,
      userId: request.user?.id,
    });

    if (!result.enabled) {
      throw new ForbiddenException('Feature not available');
    }

    return true;
  }
}

// decorators/feature-flag.decorator.ts
export const RequireFeature = (flag: string) =>
  SetMetadata('feature_flag', flag);

// Uso:
@Get('new-dashboard')
@RequireFeature('feature.new_dashboard')
async getNewDashboard() { ... }

API Endpoints Summary

Method Path Permission Description
GET /settings/system settings.system.read List system settings
GET /settings/system/:key settings.system.read Get setting
PUT /settings/system/:key settings.system.update Update setting
POST /settings/system/:key/reset settings.system.update Reset to default
GET /settings/tenant settings.tenant.read List tenant settings
GET /settings/tenant/effective settings.tenant.read Get effective settings
PUT /settings/tenant/:key settings.tenant.update Update tenant setting
DELETE /settings/tenant/:key settings.tenant.update Remove override
GET /settings/user - Get user preferences
PATCH /settings/user - Update preferences
POST /settings/user/reset - Reset preferences
GET /features/:key - Evaluate flag
POST /features/evaluate - Evaluate multiple
GET /admin/features settings.features.manage List flags
POST /admin/features/:key/overrides settings.features.manage Create override
PATCH /admin/features/:key settings.features.manage Update rollout

Historial

Version Fecha Autor Cambios
1.0 2025-12-05 Requirements-Analyst Creacion inicial