diff --git a/README/FEATURE_FLAGS_QUICKSTART.md b/README/FEATURE_FLAGS_QUICKSTART.md new file mode 100644 index 0000000..8aa5c47 --- /dev/null +++ b/README/FEATURE_FLAGS_QUICKSTART.md @@ -0,0 +1,300 @@ +# 🎮 Feature Flags - Guía Rápida de Instalación + +## 📦 Lo que se creó + +1. **Schema de Prisma** (`prisma/schema.prisma`) + - Modelo `FeatureFlag` con todos los campos necesarios + +2. **Servicio Principal** (`src/core/services/FeatureFlagService.ts`) + - Singleton con caché en memoria + - Evaluación de flags con contexto + - Estrategias de rollout (percentage, whitelist, blacklist, gradual, random) + - Sistema de estadísticas + +3. **Tipos TypeScript** (`src/core/types/featureFlags.ts`) + - Tipos completos para el sistema + - Interfaces para configuración y evaluación + +4. **Helpers y Decoradores** (`src/core/lib/featureFlagHelpers.ts`) + - `@RequireFeature` - Decorador para proteger métodos + - `featureGuard` - Guard con respuesta automática + - `isFeatureEnabled` - Check básico + - `abTest` - A/B testing + - Y más... + +5. **Comando de Administración** (`src/commands/admin/featureflags.ts`) + - Crear, listar, actualizar, eliminar flags + - Configurar rollouts + - Ver estadísticas + +6. **Loader** (`src/core/loaders/featureFlagsLoader.ts`) + - Inicialización automática del servicio + +7. **Documentación** (`README/FEATURE_FLAGS_SYSTEM.md`) + - Guía completa con ejemplos + +8. **Scripts** (`scripts/setupFeatureFlags.ts`) + - Setup inicial con flags de ejemplo + +9. **Ejemplos** (`src/examples/featureFlagsUsage.ts`) + - 11 patrones de uso diferentes + +--- + +## 🚀 Instalación (Paso a Paso) + +### 1. Genera el cliente de Prisma + +```bash +npx prisma generate +``` + +Esto creará los tipos TypeScript para el nuevo modelo `FeatureFlag`. + +### 2. Ejecuta la migración + +```bash +npx prisma migrate dev --name add_feature_flags +``` + +Esto creará la tabla en tu base de datos. + +### 3. (Opcional) Crea flags de ejemplo + +```bash +npx tsx scripts/setupFeatureFlags.ts +``` + +Esto creará 8 feature flags de ejemplo para que puedas probar el sistema. + +### 4. Integra el loader en tu bot + +Abre tu archivo principal donde cargas los servicios (probablemente `src/main.ts` o similar) y añade: + +```typescript +import { loadFeatureFlags } from './core/loaders/featureFlagsLoader'; + +// Antes de iniciar el bot +await loadFeatureFlags(); +``` + +O si tienes un sistema de loaders centralizado, simplemente impórtalo ahí. + +### 5. ¡Listo! Empieza a usar feature flags + +--- + +## 💡 Uso Rápido + +### En un comando nuevo o existente: + +```typescript +import { featureGuard } from '@/core/lib/featureFlagHelpers'; + +export async function execute(interaction: CommandInteraction) { + // Check si el flag está habilitado + if (!await featureGuard('new_feature', interaction)) { + return; // Automáticamente responde al usuario si está disabled + } + + // Tu código aquí (solo se ejecuta si el flag está habilitado) + await interaction.reply('✨ Feature habilitada!'); +} +``` + +### Crear un flag desde Discord: + +``` +/featureflags create name:new_feature status:disabled target:global description:"Mi nueva feature" +``` + +### Habilitarlo: + +``` +/featureflags update flag:new_feature status:enabled +``` + +### Rollout progresivo (25% de usuarios): + +``` +/featureflags rollout flag:new_feature strategy:percentage percentage:25 +``` + +--- + +## 🎯 Casos de Uso Comunes + +### 1. Desplegar una feature nueva gradualmente + +```bash +# Día 1: Deshabilitada, en desarrollo +/featureflags create name:pvp_system status:disabled target:global + +# Día 5: Beta testing con usuarios específicos +/featureflags rollout flag:pvp_system strategy:whitelist +# (añade IDs mediante código o actualiza la config) + +# Día 10: 10% de usuarios +/featureflags rollout flag:pvp_system strategy:percentage percentage:10 + +# Día 15: 50% de usuarios +/featureflags update flag:pvp_system +/featureflags rollout flag:pvp_system strategy:percentage percentage:50 + +# Día 20: 100% de usuarios +/featureflags update flag:pvp_system status:enabled + +# Día 30: Eliminar el flag y el código del check +/featureflags delete flag:pvp_system +``` + +### 2. A/B Testing + +```typescript +import { abTest, extractContext } from '@/core/lib/featureFlagHelpers'; + +const context = extractContext(interaction); + +await abTest('new_algorithm', context, { + variant: async () => { + // 50% de usuarios ven esto + return newAlgorithm(); + }, + control: async () => { + // 50% ven esto + return oldAlgorithm(); + } +}); +``` + +### 3. Kill Switch (emergencias) + +Si hay un bug crítico en una feature: + +```bash +/featureflags update flag:problematic_feature status:maintenance +``` + +Esto la deshabilitará inmediatamente sin necesidad de redeploy. + +### 4. Eventos temporales + +```typescript +// Configurar con fechas +await featureFlagService.setFlag({ + name: 'christmas_event', + status: 'enabled', + target: 'global', + startDate: new Date('2025-12-15'), + endDate: new Date('2025-12-31') +}); + +// El flag se auto-deshabilitará el 1 de enero +``` + +--- + +## 📊 Monitoreo + +### Ver estadísticas de uso: + +``` +/featureflags stats flag:nombre_flag +``` + +Te mostrará: +- Total de evaluaciones +- Cuántas veces se habilitó +- Cuántas veces se deshabilitó +- Tasa de habilitación (%) + +### Ver todos los flags: + +``` +/featureflags list +``` + +### Refrescar caché: + +``` +/featureflags refresh +``` + +--- + +## 🔧 Troubleshooting + +### Los flags no se aplican + +1. Verifica que el servicio está inicializado: + ```typescript + await loadFeatureFlags(); + ``` + +2. Refresca el caché: + ``` + /featureflags refresh + ``` + +3. Verifica que el flag existe: + ``` + /featureflags info flag:nombre_flag + ``` + +### Errores de TypeScript + +Si ves errores tipo "Property 'featureFlag' does not exist": + +```bash +npx prisma generate +``` + +Esto regenerará los tipos de Prisma. + +### La migración falla + +Si la migración falla, verifica tu conexión a la base de datos en `.env`: + +```env +XATA_DB="postgresql://..." +XATA_SHADOW_DB="postgresql://..." +``` + +--- + +## 📚 Recursos + +- **Documentación completa**: `README/FEATURE_FLAGS_SYSTEM.md` +- **Ejemplos de uso**: `src/examples/featureFlagsUsage.ts` +- **Tipos**: `src/core/types/featureFlags.ts` +- **Servicio**: `src/core/services/FeatureFlagService.ts` +- **Helpers**: `src/core/lib/featureFlagHelpers.ts` + +--- + +## 🎉 ¡Listo! + +Ahora tienes un sistema completo de Feature Flags. Puedes: + +✅ Desplegar features sin miedo a romper producción +✅ Hacer rollouts progresivos +✅ A/B testing +✅ Kill switches para emergencias +✅ Eventos temporales +✅ Beta testing con usuarios específicos +✅ Monitorear el uso de cada feature + +--- + +**Tip Pro**: Combina feature flags con tus deployments. Por ejemplo: + +1. Despliega código nuevo con flag disabled +2. Verifica que el deploy fue exitoso +3. Habilita el flag progresivamente (10% → 50% → 100%) +4. Monitorea métricas/errores +5. Si hay problemas, desactiva el flag instantáneamente +6. Una vez estable, elimina el flag y el código antiguo + +--- + +Creado con 🎮 para Amayo Bot diff --git a/README/FEATURE_FLAGS_SYSTEM.md b/README/FEATURE_FLAGS_SYSTEM.md new file mode 100644 index 0000000..44c1f70 --- /dev/null +++ b/README/FEATURE_FLAGS_SYSTEM.md @@ -0,0 +1,626 @@ +# 🎮 Feature Flags System + +Sistema completo de Feature Flags para control de funcionalidades, rollouts progresivos, A/B testing y toggles dinámicos. + +## 📋 Índice + +- [Instalación](#instalación) +- [Conceptos](#conceptos) +- [Uso Básico](#uso-básico) +- [Ejemplos Avanzados](#ejemplos-avanzados) +- [Comando de Administración](#comando-de-administración) +- [Estrategias de Rollout](#estrategias-de-rollout) +- [Best Practices](#best-practices) + +--- + +## 🚀 Instalación + +### 1. Migración de Base de Datos + +```bash +npx prisma migrate dev --name add_feature_flags +``` + +### 2. Inicialización del Servicio + +En tu `src/loaders/` o punto de entrada principal: + +```typescript +import { featureFlagService } from '@/core/services/FeatureFlagService'; + +// Inicializar el servicio +await featureFlagService.initialize(); +``` + +--- + +## 🧠 Conceptos + +### Estados de Flags + +- **`enabled`**: Habilitado para todos +- **`disabled`**: Deshabilitado para todos +- **`rollout`**: Rollout progresivo según estrategia +- **`maintenance`**: Deshabilitado por mantenimiento + +### Targets + +- **`global`**: Aplica a todo el bot +- **`guild`**: Aplica por servidor +- **`user`**: Aplica por usuario +- **`channel`**: Aplica por canal + +### Estrategias de Rollout + +- **`percentage`**: Basado en % de usuarios +- **`whitelist`**: Solo IDs específicos +- **`blacklist`**: Todos excepto IDs específicos +- **`gradual`**: Rollout gradual en el tiempo +- **`random`**: Aleatorio por sesión + +--- + +## 💡 Uso Básico + +### 1. En Comandos con Decorador + +```typescript +import { RequireFeature } from '@/core/lib/featureFlagHelpers'; + +class ShopCommand { + @RequireFeature('new_shop_system', { + fallbackMessage: '🔧 El nuevo sistema de tienda estará disponible pronto' + }) + async execute(interaction: CommandInteraction) { + // Este código solo se ejecuta si el flag está habilitado + await interaction.reply('Bienvenido a la nueva tienda!'); + } +} +``` + +### 2. Con Guard en el Handler + +```typescript +import { featureGuard } from '@/core/lib/featureFlagHelpers'; + +async function handleMineCommand(interaction: CommandInteraction) { + // Check del flag + if (!await featureGuard('new_mining_system', interaction)) { + return; // Automáticamente responde al usuario + } + + // Código del comando nuevo + await doNewMining(interaction); +} +``` + +### 3. Check Manual + +```typescript +import { isFeatureEnabledForInteraction } from '@/core/lib/featureFlagHelpers'; + +async function execute(interaction: CommandInteraction) { + const useNewAlgorithm = await isFeatureEnabledForInteraction( + 'improved_algorithm', + interaction + ); + + if (useNewAlgorithm) { + await newAlgorithm(); + } else { + await oldAlgorithm(); + } +} +``` + +--- + +## 🎯 Ejemplos Avanzados + +### A/B Testing + +```typescript +import { abTest, extractContext } from '@/core/lib/featureFlagHelpers'; + +async function handleShop(interaction: CommandInteraction) { + const context = extractContext(interaction); + + const result = await abTest('new_shop_ui', context, { + variant: async () => { + // Nueva UI + return buildNewShopUI(); + }, + control: async () => { + // UI antigua + return buildOldShopUI(); + } + }); + + await interaction.reply(result); +} +``` + +### Múltiples Flags (AND) + +```typescript +import { requireAllFeatures, extractContext } from '@/core/lib/featureFlagHelpers'; + +async function handlePremiumFeature(interaction: CommandInteraction) { + const context = extractContext(interaction); + + const hasAccess = await requireAllFeatures( + ['premium_features', 'beta_access', 'new_ui'], + context + ); + + if (!hasAccess) { + await interaction.reply('No tienes acceso a esta funcionalidad'); + return; + } + + // Código de la feature premium +} +``` + +### Múltiples Flags (OR) + +```typescript +import { requireAnyFeature, extractContext } from '@/core/lib/featureFlagHelpers'; + +async function handleSpecialEvent(interaction: CommandInteraction) { + const context = extractContext(interaction); + + const hasEventAccess = await requireAnyFeature( + ['halloween_event', 'christmas_event', 'beta_events'], + context + ); + + if (!hasEventAccess) { + await interaction.reply('No hay eventos activos para ti'); + return; + } + + // Código del evento +} +``` + +### Con Fallback + +```typescript +import { withFeature, extractContext } from '@/core/lib/featureFlagHelpers'; + +async function getData(interaction: CommandInteraction) { + const context = extractContext(interaction); + + const data = await withFeature( + 'new_data_source', + context, + async () => { + // Fuente nueva + return fetchFromNewAPI(); + }, + async () => { + // Fuente antigua (fallback) + return fetchFromOldAPI(); + } + ); + + return data; +} +``` + +--- + +## 🛠️ Comando de Administración + +### Crear un Flag + +``` +/featureflags create name:new_shop_system status:disabled target:global description:"Nuevo sistema de tienda" +``` + +### Listar Flags + +``` +/featureflags list +``` + +### Ver Info de un Flag + +``` +/featureflags info flag:new_shop_system +``` + +### Actualizar Estado + +``` +/featureflags update flag:new_shop_system status:enabled +``` + +### Configurar Rollout Progresivo + +``` +/featureflags rollout flag:new_shop_system strategy:percentage percentage:25 +``` + +Esto habilitará la feature para el 25% de los usuarios. + +### Configurar Rollout Gradual + +```typescript +// Programáticamente +await featureFlagService.setFlag({ + name: 'new_combat_system', + status: 'rollout', + target: 'user', + rolloutStrategy: 'gradual', + rolloutConfig: { + gradual: { + startPercentage: 10, // Empieza con 10% + targetPercentage: 100, // Llega al 100% + durationDays: 7 // En 7 días + } + }, + startDate: new Date() +}); +``` + +### Ver Estadísticas + +``` +/featureflags stats flag:new_shop_system +``` + +### Refrescar Caché + +``` +/featureflags refresh +``` + +### Eliminar Flag + +``` +/featureflags delete flag:old_feature +``` + +--- + +## 📊 Estrategias de Rollout + +### 1. Percentage (Porcentaje) + +Distribuye la feature a un % de usuarios de forma determinista. + +```typescript +await featureFlagService.setFlag({ + name: 'feature_x', + status: 'rollout', + target: 'user', + rolloutStrategy: 'percentage', + rolloutConfig: { + percentage: 50 // 50% de usuarios + } +}); +``` + +### 2. Whitelist (Lista Blanca) + +Solo para IDs específicos. + +```typescript +await featureFlagService.setFlag({ + name: 'beta_features', + status: 'rollout', + target: 'user', + rolloutStrategy: 'whitelist', + rolloutConfig: { + targetIds: [ + '123456789', // User ID 1 + '987654321' // User ID 2 + ] + } +}); +``` + +### 3. Blacklist (Lista Negra) + +Para todos excepto IDs específicos. + +```typescript +await featureFlagService.setFlag({ + name: 'stable_feature', + status: 'rollout', + target: 'guild', + rolloutStrategy: 'blacklist', + rolloutConfig: { + targetIds: [ + 'guild_id_problematico' + ] + } +}); +``` + +### 4. Gradual (Progresivo en el Tiempo) + +Rollout gradual durante X días. + +```typescript +await featureFlagService.setFlag({ + name: 'major_update', + status: 'rollout', + target: 'user', + rolloutStrategy: 'gradual', + rolloutConfig: { + gradual: { + startPercentage: 5, // Empieza con 5% + targetPercentage: 100, // Termina en 100% + durationDays: 14 // Durante 14 días + } + }, + startDate: new Date() // Importante: define cuándo empieza +}); +``` + +--- + +## ✨ Best Practices + +### 1. Nombres Claros y Descriptivos + +```typescript +// ❌ Mal +'flag_1' +'test' +'new' + +// ✅ Bien +'new_shop_ui_v2' +'improved_combat_algorithm' +'halloween_2025_event' +``` + +### 2. Siempre con Descripción + +```typescript +await featureFlagService.setFlag({ + name: 'new_mining_system', + description: 'Sistema de minería rediseñado con durabilidad de herramientas', + status: 'disabled', + target: 'global' +}); +``` + +### 3. Rollouts Graduales para Cambios Grandes + +Para cambios importantes, usa rollout gradual: + +1. Día 1-3: 10% de usuarios +2. Día 4-7: 50% de usuarios +3. Día 8-14: 100% de usuarios + +### 4. Limpiar Flags Obsoletos + +Una vez que una feature está 100% desplegada y estable: + +1. Elimina el flag +2. Elimina el código del check +3. Mantén solo la nueva implementación + +### 5. Usar Whitelists para Beta Testers + +```typescript +await featureFlagService.setFlag({ + name: 'experimental_features', + status: 'rollout', + target: 'user', + rolloutStrategy: 'whitelist', + rolloutConfig: { + targetIds: BETA_TESTER_IDS // Array de tus beta testers + } +}); +``` + +### 6. Fechas de Expiración para Eventos + +```typescript +await featureFlagService.setFlag({ + name: 'christmas_2025_event', + status: 'enabled', + target: 'global', + startDate: new Date('2025-12-01'), + endDate: new Date('2025-12-31') +}); +``` + +El flag se auto-deshabilitará después del 31 de diciembre. + +### 7. Caché y Performance + +El servicio cachea flags en memoria por 5 minutos. Si necesitas actualizaciones inmediatas: + +``` +/featureflags refresh +``` + +O programáticamente: + +```typescript +await featureFlagService.refreshCache(); +``` + +--- + +## 🔥 Casos de Uso Reales + +### Lanzamiento de Comando Nuevo + +```typescript +// Fase 1: Desarrollo - Deshabilitado +await featureFlagService.setFlag({ + name: 'pvp_arena_command', + status: 'disabled', + target: 'global' +}); + +// Fase 2: Beta Testing - Solo whitelisted +await featureFlagService.setFlag({ + name: 'pvp_arena_command', + status: 'rollout', + target: 'guild', + rolloutStrategy: 'whitelist', + rolloutConfig: { + targetIds: ['guild_beta_1', 'guild_beta_2'] + } +}); + +// Fase 3: Rollout Progresivo - 25% +await featureFlagService.setFlag({ + name: 'pvp_arena_command', + status: 'rollout', + target: 'user', + rolloutStrategy: 'percentage', + rolloutConfig: { percentage: 25 } +}); + +// Fase 4: Habilitado para Todos +await featureFlagService.setFlag({ + name: 'pvp_arena_command', + status: 'enabled', + target: 'global' +}); + +// Fase 5: Cleanup - Eliminar flag y código del check +await featureFlagService.removeFlag('pvp_arena_command'); +``` + +### Migración de Sistema Antiguo a Nuevo + +```typescript +// En el comando +async function handleInventory(interaction: CommandInteraction) { + const context = extractContext(interaction); + + await abTest('inventory_system_v2', context, { + variant: async () => { + // Sistema nuevo + return await newInventorySystem.show(interaction); + }, + control: async () => { + // Sistema antiguo + return await oldInventorySystem.show(interaction); + } + }); +} +``` + +Luego gradualmente aumentas el % hasta 100% y eliminas el código antiguo. + +### Kill Switch para Emergencias + +```typescript +// Si hay un bug crítico en una feature: +await featureFlagService.setFlag({ + name: 'problematic_feature', + status: 'maintenance', // Deshabilitado inmediatamente + target: 'global' +}); + +// O via comando Discord: +// /featureflags update flag:problematic_feature status:maintenance +``` + +--- + +## 📚 API Reference + +Ver `src/core/types/featureFlags.ts` para tipos completos. + +### FeatureFlagService + +```typescript +// Inicializar +await featureFlagService.initialize(); + +// Check si está habilitado +const enabled = await featureFlagService.isEnabled('flag_name', context); + +// Crear/actualizar flag +await featureFlagService.setFlag(config); + +// Eliminar flag +await featureFlagService.removeFlag('flag_name'); + +// Obtener flag +const flag = featureFlagService.getFlag('flag_name'); + +// Obtener todos los flags +const flags = featureFlagService.getFlags(); + +// Estadísticas +const stats = featureFlagService.getStats('flag_name'); +const allStats = featureFlagService.getAllStats(); + +// Refrescar caché +await featureFlagService.refreshCache(); +featureFlagService.clearEvaluationCache(); +``` + +### Helpers + +```typescript +// Check básico +await isFeatureEnabled(flagName, context); +await isFeatureEnabledForInteraction(flagName, interaction); + +// Guards +await featureGuard(flagName, interaction, options); + +// Decorador +@RequireFeature('flag_name', options) + +// A/B Testing +await abTest(flagName, context, { variant, control }); + +// Wrapper +await withFeature(flagName, context, fn, fallback); + +// Múltiples flags +await requireAllFeatures(flags, context); // AND +await requireAnyFeature(flags, context); // OR +``` + +--- + +## 🎮 Integración con tu Bot + +El sistema se integra automáticamente si añades la inicialización en tu loader: + +```typescript +// src/loaders/featureFlagsLoader.ts +import { featureFlagService } from '../services/FeatureFlagService'; +import logger from '../lib/logger'; + +export async function loadFeatureFlags() { + try { + await featureFlagService.initialize(); + logger.info('[FeatureFlags] Sistema inicializado'); + } catch (error) { + logger.error('[FeatureFlags] Error al inicializar:', error); + } +} +``` + +Luego en tu `main.ts` o donde cargues servicios: + +```typescript +import { loadFeatureFlags } from './loaders/featureFlagsLoader'; + +// ... +await loadFeatureFlags(); +// ... +``` + +--- + +Creado con 🎮 para el bot Amayo diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d381e0b..636289b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1052,3 +1052,40 @@ model DeathLog { @@index([userId, guildId]) @@index([createdAt]) } + +/** + * ----------------------------------------------------------------------------- + * Sistema de Feature Flags + * ----------------------------------------------------------------------------- + * Control de features para rollouts progresivos, A/B testing y toggles + * Permite activar/desactivar funcionalidades sin deployar código + */ +model FeatureFlag { + id String @id @default(cuid()) + name String @unique + + description String? + status String @default("disabled") // enabled|disabled|rollout|maintenance + + // Nivel de aplicación: global, guild, user, channel + target String @default("global") + + // Estrategia de rollout (para status = rollout) + rolloutStrategy String? // percentage|whitelist|blacklist|gradual|random + + // Configuración de la estrategia (JSON) + rolloutConfig String? // JSON serializado + + // Fechas de inicio/fin + startDate DateTime? + endDate DateTime? + + // Metadata adicional + metadata String? // JSON serializado + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([target]) +} diff --git a/scripts/setupFeatureFlags.ts b/scripts/setupFeatureFlags.ts new file mode 100644 index 0000000..46256e8 --- /dev/null +++ b/scripts/setupFeatureFlags.ts @@ -0,0 +1,173 @@ +/** + * Setup inicial del sistema de Feature Flags + * + * Este script: + * 1. Crea algunos feature flags de ejemplo + * 2. Muestra cómo configurar diferentes estrategias + */ + +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function setupFeatureFlags() { + console.log("🎮 Configurando Feature Flags de ejemplo...\n"); + + try { + // 1. Flag deshabilitado por defecto (en desarrollo) + await prisma.featureFlag.upsert({ + where: { name: "new_shop_system" }, + create: { + name: "new_shop_system", + description: "Nuevo sistema de tienda con UI mejorada", + status: "disabled", + target: "global", + }, + update: {}, + }); + console.log('✅ Flag "new_shop_system" creado (disabled)'); + + // 2. Flag habilitado globalmente + await prisma.featureFlag.upsert({ + where: { name: "inventory_ui_v2" }, + create: { + name: "inventory_ui_v2", + description: "Nueva UI del inventario con mejor UX", + status: "enabled", + target: "global", + }, + update: {}, + }); + console.log('✅ Flag "inventory_ui_v2" creado (enabled)'); + + // 3. Flag con rollout por porcentaje (25% de usuarios) + await prisma.featureFlag.upsert({ + where: { name: "improved_combat_algorithm" }, + create: { + name: "improved_combat_algorithm", + description: "Algoritmo de combate mejorado con mejor balance", + status: "rollout", + target: "user", + rolloutStrategy: "percentage", + rolloutConfig: JSON.stringify({ + percentage: 25, + }), + }, + update: {}, + }); + console.log('✅ Flag "improved_combat_algorithm" creado (rollout 25%)'); + + // 4. Flag con rollout gradual (de 10% a 100% en 7 días) + const startDate = new Date(); + await prisma.featureFlag.upsert({ + where: { name: "economy_system_v2" }, + create: { + name: "economy_system_v2", + description: "Sistema de economía rediseñado", + status: "rollout", + target: "user", + rolloutStrategy: "gradual", + rolloutConfig: JSON.stringify({ + gradual: { + startPercentage: 10, + targetPercentage: 100, + durationDays: 7, + }, + }), + startDate, + }, + update: {}, + }); + console.log('✅ Flag "economy_system_v2" creado (gradual rollout)'); + + // 5. Flag de evento temporal (Halloween) + const halloweenStart = new Date("2025-10-25"); + const halloweenEnd = new Date("2025-11-01"); + await prisma.featureFlag.upsert({ + where: { name: "halloween_2025" }, + create: { + name: "halloween_2025", + description: "Evento de Halloween 2025", + status: "enabled", + target: "global", + startDate: halloweenStart, + endDate: halloweenEnd, + }, + update: {}, + }); + console.log('✅ Flag "halloween_2025" creado (evento temporal)'); + + // 6. Flag experimental con whitelist (solo para beta testers) + await prisma.featureFlag.upsert({ + where: { name: "experimental_features" }, + create: { + name: "experimental_features", + description: "Funcionalidades experimentales solo para beta testers", + status: "rollout", + target: "user", + rolloutStrategy: "whitelist", + rolloutConfig: JSON.stringify({ + targetIds: [ + // Añade aquí los IDs de tus beta testers + "BETA_TESTER_USER_ID_1", + "BETA_TESTER_USER_ID_2", + ], + }), + }, + update: {}, + }); + console.log('✅ Flag "experimental_features" creado (whitelist)'); + + // 7. Flag premium con múltiples requisitos + await prisma.featureFlag.upsert({ + where: { name: "premium_features" }, + create: { + name: "premium_features", + description: "Funcionalidades premium del bot", + status: "disabled", + target: "guild", + metadata: JSON.stringify({ + requiresSubscription: true, + tier: "premium", + }), + }, + update: {}, + }); + console.log('✅ Flag "premium_features" creado (disabled, metadata)'); + + // 8. Flag en mantenimiento + await prisma.featureFlag.upsert({ + where: { name: "trading_system" }, + create: { + name: "trading_system", + description: "Sistema de intercambio entre usuarios", + status: "maintenance", + target: "global", + }, + update: {}, + }); + console.log('✅ Flag "trading_system" creado (maintenance)'); + + console.log("\n🎉 Feature Flags de ejemplo creados exitosamente!"); + console.log("\n📝 Próximos pasos:"); + console.log("1. Usa /featureflags list para ver todos los flags"); + console.log("2. Usa /featureflags info flag:nombre para ver detalles"); + console.log( + "3. Usa /featureflags update flag:nombre status:enabled para habilitar" + ); + console.log("4. Lee README/FEATURE_FLAGS_SYSTEM.md para más información"); + } catch (error) { + console.error("❌ Error al crear flags:", error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +// Ejecutar +setupFeatureFlags() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/src/commands/messages/admin/areaEliminar.ts b/src/.backup/admin/areaEliminar.ts similarity index 100% rename from src/commands/messages/admin/areaEliminar.ts rename to src/.backup/admin/areaEliminar.ts diff --git a/src/commands/messages/admin/areasLista.ts b/src/.backup/admin/areasLista.ts similarity index 100% rename from src/commands/messages/admin/areasLista.ts rename to src/.backup/admin/areasLista.ts diff --git a/src/commands/messages/admin/debugInv.ts b/src/.backup/admin/debugInv.ts similarity index 100% rename from src/commands/messages/admin/debugInv.ts rename to src/.backup/admin/debugInv.ts diff --git a/src/commands/messages/admin/fixDurability.ts b/src/.backup/admin/fixDurability.ts similarity index 100% rename from src/commands/messages/admin/fixDurability.ts rename to src/.backup/admin/fixDurability.ts diff --git a/src/commands/messages/admin/itemEliminar.ts b/src/.backup/admin/itemEliminar.ts similarity index 100% rename from src/commands/messages/admin/itemEliminar.ts rename to src/.backup/admin/itemEliminar.ts diff --git a/src/commands/messages/admin/itemVer.ts b/src/.backup/admin/itemVer.ts similarity index 100% rename from src/commands/messages/admin/itemVer.ts rename to src/.backup/admin/itemVer.ts diff --git a/src/commands/messages/admin/itemsLista.ts b/src/.backup/admin/itemsLista.ts similarity index 100% rename from src/commands/messages/admin/itemsLista.ts rename to src/.backup/admin/itemsLista.ts diff --git a/src/commands/messages/admin/logroCrear.ts b/src/.backup/admin/logroCrear.ts similarity index 100% rename from src/commands/messages/admin/logroCrear.ts rename to src/.backup/admin/logroCrear.ts diff --git a/src/commands/messages/admin/logroEliminar.ts b/src/.backup/admin/logroEliminar.ts similarity index 100% rename from src/commands/messages/admin/logroEliminar.ts rename to src/.backup/admin/logroEliminar.ts diff --git a/src/commands/messages/admin/logroVer.ts b/src/.backup/admin/logroVer.ts similarity index 100% rename from src/commands/messages/admin/logroVer.ts rename to src/.backup/admin/logroVer.ts diff --git a/src/commands/messages/admin/logrosLista.ts b/src/.backup/admin/logrosLista.ts similarity index 100% rename from src/commands/messages/admin/logrosLista.ts rename to src/.backup/admin/logrosLista.ts diff --git a/src/commands/messages/admin/misionCrear.ts b/src/.backup/admin/misionCrear.ts similarity index 100% rename from src/commands/messages/admin/misionCrear.ts rename to src/.backup/admin/misionCrear.ts diff --git a/src/commands/messages/admin/misionEliminar.ts b/src/.backup/admin/misionEliminar.ts similarity index 100% rename from src/commands/messages/admin/misionEliminar.ts rename to src/.backup/admin/misionEliminar.ts diff --git a/src/commands/messages/admin/misionVer.ts b/src/.backup/admin/misionVer.ts similarity index 100% rename from src/commands/messages/admin/misionVer.ts rename to src/.backup/admin/misionVer.ts diff --git a/src/commands/messages/admin/misionesLista.ts b/src/.backup/admin/misionesLista.ts similarity index 100% rename from src/commands/messages/admin/misionesLista.ts rename to src/.backup/admin/misionesLista.ts diff --git a/src/commands/messages/admin/mobEliminar.ts b/src/.backup/admin/mobEliminar.ts similarity index 100% rename from src/commands/messages/admin/mobEliminar.ts rename to src/.backup/admin/mobEliminar.ts diff --git a/src/commands/messages/admin/mobsLista.ts b/src/.backup/admin/mobsLista.ts similarity index 100% rename from src/commands/messages/admin/mobsLista.ts rename to src/.backup/admin/mobsLista.ts diff --git a/src/commands/messages/admin/resetInventory.ts b/src/.backup/admin/resetInventory.ts similarity index 100% rename from src/commands/messages/admin/resetInventory.ts rename to src/.backup/admin/resetInventory.ts diff --git a/src/commands/messages/game/_helpers.ts b/src/.backup/game/_helpers.ts similarity index 100% rename from src/commands/messages/game/_helpers.ts rename to src/.backup/game/_helpers.ts diff --git a/src/commands/messages/game/abrir.ts b/src/.backup/game/abrir.ts similarity index 100% rename from src/commands/messages/game/abrir.ts rename to src/.backup/game/abrir.ts diff --git a/src/commands/messages/game/areaCreate.ts b/src/.backup/game/areaCreate.ts similarity index 100% rename from src/commands/messages/game/areaCreate.ts rename to src/.backup/game/areaCreate.ts diff --git a/src/commands/messages/game/areaEdit.ts b/src/.backup/game/areaEdit.ts similarity index 100% rename from src/commands/messages/game/areaEdit.ts rename to src/.backup/game/areaEdit.ts diff --git a/src/commands/messages/game/areaNivel.ts b/src/.backup/game/areaNivel.ts similarity index 100% rename from src/commands/messages/game/areaNivel.ts rename to src/.backup/game/areaNivel.ts diff --git a/src/commands/messages/game/combatehistorial.ts b/src/.backup/game/combatehistorial.ts similarity index 100% rename from src/commands/messages/game/combatehistorial.ts rename to src/.backup/game/combatehistorial.ts diff --git a/src/commands/messages/game/comer.ts b/src/.backup/game/comer.ts similarity index 100% rename from src/commands/messages/game/comer.ts rename to src/.backup/game/comer.ts diff --git a/src/commands/messages/game/comprar.ts b/src/.backup/game/comprar.ts similarity index 100% rename from src/commands/messages/game/comprar.ts rename to src/.backup/game/comprar.ts diff --git a/src/commands/messages/game/cooldowns.ts b/src/.backup/game/cooldowns.ts similarity index 100% rename from src/commands/messages/game/cooldowns.ts rename to src/.backup/game/cooldowns.ts diff --git a/src/commands/messages/game/craftear.ts b/src/.backup/game/craftear.ts similarity index 100% rename from src/commands/messages/game/craftear.ts rename to src/.backup/game/craftear.ts diff --git a/src/commands/messages/game/deathlog.ts b/src/.backup/game/deathlog.ts similarity index 100% rename from src/commands/messages/game/deathlog.ts rename to src/.backup/game/deathlog.ts diff --git a/src/commands/messages/game/durabilidad.ts b/src/.backup/game/durabilidad.ts similarity index 100% rename from src/commands/messages/game/durabilidad.ts rename to src/.backup/game/durabilidad.ts diff --git a/src/commands/messages/game/effects.ts b/src/.backup/game/effects.ts similarity index 100% rename from src/commands/messages/game/effects.ts rename to src/.backup/game/effects.ts diff --git a/src/commands/messages/game/encantar.ts b/src/.backup/game/encantar.ts similarity index 100% rename from src/commands/messages/game/encantar.ts rename to src/.backup/game/encantar.ts diff --git a/src/commands/messages/game/equipar.ts b/src/.backup/game/equipar.ts similarity index 100% rename from src/commands/messages/game/equipar.ts rename to src/.backup/game/equipar.ts diff --git a/src/commands/messages/game/fundir.ts b/src/.backup/game/fundir.ts similarity index 100% rename from src/commands/messages/game/fundir.ts rename to src/.backup/game/fundir.ts diff --git a/src/commands/messages/game/fundirReclamar.ts b/src/.backup/game/fundirReclamar.ts similarity index 100% rename from src/commands/messages/game/fundirReclamar.ts rename to src/.backup/game/fundirReclamar.ts diff --git a/src/commands/messages/game/inventario.ts b/src/.backup/game/inventario.ts similarity index 100% rename from src/commands/messages/game/inventario.ts rename to src/.backup/game/inventario.ts diff --git a/src/commands/messages/game/itemCreate.ts b/src/.backup/game/itemCreate.ts similarity index 100% rename from src/commands/messages/game/itemCreate.ts rename to src/.backup/game/itemCreate.ts diff --git a/src/commands/messages/game/itemEdit.ts b/src/.backup/game/itemEdit.ts similarity index 100% rename from src/commands/messages/game/itemEdit.ts rename to src/.backup/game/itemEdit.ts diff --git a/src/commands/messages/game/logros.ts b/src/.backup/game/logros.ts similarity index 100% rename from src/commands/messages/game/logros.ts rename to src/.backup/game/logros.ts diff --git a/src/commands/messages/game/mina.ts b/src/.backup/game/mina.ts similarity index 100% rename from src/commands/messages/game/mina.ts rename to src/.backup/game/mina.ts diff --git a/src/commands/messages/game/misionReclamar.ts b/src/.backup/game/misionReclamar.ts similarity index 100% rename from src/commands/messages/game/misionReclamar.ts rename to src/.backup/game/misionReclamar.ts diff --git a/src/commands/messages/game/misiones.ts b/src/.backup/game/misiones.ts similarity index 100% rename from src/commands/messages/game/misiones.ts rename to src/.backup/game/misiones.ts diff --git a/src/commands/messages/game/mobCreate.ts b/src/.backup/game/mobCreate.ts similarity index 100% rename from src/commands/messages/game/mobCreate.ts rename to src/.backup/game/mobCreate.ts diff --git a/src/commands/messages/game/mobDelete.ts b/src/.backup/game/mobDelete.ts similarity index 100% rename from src/commands/messages/game/mobDelete.ts rename to src/.backup/game/mobDelete.ts diff --git a/src/commands/messages/game/mobEdit.ts b/src/.backup/game/mobEdit.ts similarity index 100% rename from src/commands/messages/game/mobEdit.ts rename to src/.backup/game/mobEdit.ts diff --git a/src/commands/messages/game/monedas.ts b/src/.backup/game/monedas.ts similarity index 100% rename from src/commands/messages/game/monedas.ts rename to src/.backup/game/monedas.ts diff --git a/src/commands/messages/game/offerCreate.ts b/src/.backup/game/offerCreate.ts similarity index 100% rename from src/commands/messages/game/offerCreate.ts rename to src/.backup/game/offerCreate.ts diff --git a/src/commands/messages/game/offerEdit.ts b/src/.backup/game/offerEdit.ts similarity index 100% rename from src/commands/messages/game/offerEdit.ts rename to src/.backup/game/offerEdit.ts diff --git a/src/commands/messages/game/pelear.ts b/src/.backup/game/pelear.ts similarity index 100% rename from src/commands/messages/game/pelear.ts rename to src/.backup/game/pelear.ts diff --git a/src/commands/messages/game/pescar.ts b/src/.backup/game/pescar.ts similarity index 100% rename from src/commands/messages/game/pescar.ts rename to src/.backup/game/pescar.ts diff --git a/src/commands/messages/game/plantar.ts b/src/.backup/game/plantar.ts similarity index 100% rename from src/commands/messages/game/plantar.ts rename to src/.backup/game/plantar.ts diff --git a/src/commands/messages/game/player.ts b/src/.backup/game/player.ts similarity index 100% rename from src/commands/messages/game/player.ts rename to src/.backup/game/player.ts diff --git a/src/commands/messages/game/racha.ts b/src/.backup/game/racha.ts similarity index 100% rename from src/commands/messages/game/racha.ts rename to src/.backup/game/racha.ts diff --git a/src/commands/messages/game/setup.ts b/src/.backup/game/setup.ts similarity index 100% rename from src/commands/messages/game/setup.ts rename to src/.backup/game/setup.ts diff --git a/src/commands/messages/game/stats.ts b/src/.backup/game/stats.ts similarity index 100% rename from src/commands/messages/game/stats.ts rename to src/.backup/game/stats.ts diff --git a/src/commands/messages/game/tienda.ts b/src/.backup/game/tienda.ts similarity index 100% rename from src/commands/messages/game/tienda.ts rename to src/.backup/game/tienda.ts diff --git a/src/commands/messages/game/toolbreaks.ts b/src/.backup/game/toolbreaks.ts similarity index 100% rename from src/commands/messages/game/toolbreaks.ts rename to src/.backup/game/toolbreaks.ts diff --git a/src/commands/messages/game/toolinfo.ts b/src/.backup/game/toolinfo.ts similarity index 100% rename from src/commands/messages/game/toolinfo.ts rename to src/.backup/game/toolinfo.ts diff --git a/src/game/achievements/seed.ts b/src/.backup/game_core/game/achievements/seed.ts similarity index 100% rename from src/game/achievements/seed.ts rename to src/.backup/game_core/game/achievements/seed.ts diff --git a/src/game/achievements/service.ts b/src/.backup/game_core/game/achievements/service.ts similarity index 100% rename from src/game/achievements/service.ts rename to src/.backup/game_core/game/achievements/service.ts diff --git a/src/game/combat/attacksWorker.ts b/src/.backup/game_core/game/combat/attacksWorker.ts similarity index 100% rename from src/game/combat/attacksWorker.ts rename to src/.backup/game_core/game/combat/attacksWorker.ts diff --git a/src/game/combat/equipmentService.ts b/src/.backup/game_core/game/combat/equipmentService.ts similarity index 100% rename from src/game/combat/equipmentService.ts rename to src/.backup/game_core/game/combat/equipmentService.ts diff --git a/src/game/combat/statusEffectsService.ts b/src/.backup/game_core/game/combat/statusEffectsService.ts similarity index 100% rename from src/game/combat/statusEffectsService.ts rename to src/.backup/game_core/game/combat/statusEffectsService.ts diff --git a/src/game/consumables/service.ts b/src/.backup/game_core/game/consumables/service.ts similarity index 100% rename from src/game/consumables/service.ts rename to src/.backup/game_core/game/consumables/service.ts diff --git a/src/game/consumables/utils.ts b/src/.backup/game_core/game/consumables/utils.ts similarity index 100% rename from src/game/consumables/utils.ts rename to src/.backup/game_core/game/consumables/utils.ts diff --git a/src/game/cooldowns/service.ts b/src/.backup/game_core/game/cooldowns/service.ts similarity index 100% rename from src/game/cooldowns/service.ts rename to src/.backup/game_core/game/cooldowns/service.ts diff --git a/src/game/core/userService.ts b/src/.backup/game_core/game/core/userService.ts similarity index 100% rename from src/game/core/userService.ts rename to src/.backup/game_core/game/core/userService.ts diff --git a/src/game/core/utils.ts b/src/.backup/game_core/game/core/utils.ts similarity index 100% rename from src/game/core/utils.ts rename to src/.backup/game_core/game/core/utils.ts diff --git a/src/game/economy/seedPurgePotion.ts b/src/.backup/game_core/game/economy/seedPurgePotion.ts similarity index 100% rename from src/game/economy/seedPurgePotion.ts rename to src/.backup/game_core/game/economy/seedPurgePotion.ts diff --git a/src/game/economy/service.ts b/src/.backup/game_core/game/economy/service.ts similarity index 100% rename from src/game/economy/service.ts rename to src/.backup/game_core/game/economy/service.ts diff --git a/src/game/economy/types.ts b/src/.backup/game_core/game/economy/types.ts similarity index 100% rename from src/game/economy/types.ts rename to src/.backup/game_core/game/economy/types.ts diff --git a/src/game/lib/rpgFormat.ts b/src/.backup/game_core/game/lib/rpgFormat.ts similarity index 100% rename from src/game/lib/rpgFormat.ts rename to src/.backup/game_core/game/lib/rpgFormat.ts diff --git a/src/game/lib/toolBreakLog.ts b/src/.backup/game_core/game/lib/toolBreakLog.ts similarity index 100% rename from src/game/lib/toolBreakLog.ts rename to src/.backup/game_core/game/lib/toolBreakLog.ts diff --git a/src/game/minigames/demoRun.ts b/src/.backup/game_core/game/minigames/demoRun.ts similarity index 100% rename from src/game/minigames/demoRun.ts rename to src/.backup/game_core/game/minigames/demoRun.ts diff --git a/src/game/minigames/seed.ts b/src/.backup/game_core/game/minigames/seed.ts similarity index 100% rename from src/game/minigames/seed.ts rename to src/.backup/game_core/game/minigames/seed.ts diff --git a/src/game/minigames/service.ts b/src/.backup/game_core/game/minigames/service.ts similarity index 100% rename from src/game/minigames/service.ts rename to src/.backup/game_core/game/minigames/service.ts diff --git a/src/game/minigames/testHelpers.ts b/src/.backup/game_core/game/minigames/testHelpers.ts similarity index 100% rename from src/game/minigames/testHelpers.ts rename to src/.backup/game_core/game/minigames/testHelpers.ts diff --git a/src/game/minigames/types.ts b/src/.backup/game_core/game/minigames/types.ts similarity index 100% rename from src/game/minigames/types.ts rename to src/.backup/game_core/game/minigames/types.ts diff --git a/src/game/mobs/README.md b/src/.backup/game_core/game/mobs/README.md similarity index 100% rename from src/game/mobs/README.md rename to src/.backup/game_core/game/mobs/README.md diff --git a/src/game/mobs/admin.ts b/src/.backup/game_core/game/mobs/admin.ts similarity index 100% rename from src/game/mobs/admin.ts rename to src/.backup/game_core/game/mobs/admin.ts diff --git a/src/game/mobs/mobData.ts b/src/.backup/game_core/game/mobs/mobData.ts similarity index 100% rename from src/game/mobs/mobData.ts rename to src/.backup/game_core/game/mobs/mobData.ts diff --git a/src/game/mutations/service.ts b/src/.backup/game_core/game/mutations/service.ts similarity index 100% rename from src/game/mutations/service.ts rename to src/.backup/game_core/game/mutations/service.ts diff --git a/src/game/quests/service.ts b/src/.backup/game_core/game/quests/service.ts similarity index 100% rename from src/game/quests/service.ts rename to src/.backup/game_core/game/quests/service.ts diff --git a/src/game/rewards/service.ts b/src/.backup/game_core/game/rewards/service.ts similarity index 100% rename from src/game/rewards/service.ts rename to src/.backup/game_core/game/rewards/service.ts diff --git a/src/game/smelting/service.ts b/src/.backup/game_core/game/smelting/service.ts similarity index 100% rename from src/game/smelting/service.ts rename to src/.backup/game_core/game/smelting/service.ts diff --git a/src/game/stats/service.ts b/src/.backup/game_core/game/stats/service.ts similarity index 100% rename from src/game/stats/service.ts rename to src/.backup/game_core/game/stats/service.ts diff --git a/src/game/stats/types.ts b/src/.backup/game_core/game/stats/types.ts similarity index 100% rename from src/game/stats/types.ts rename to src/.backup/game_core/game/stats/types.ts diff --git a/src/game/streaks/service.ts b/src/.backup/game_core/game/streaks/service.ts similarity index 100% rename from src/game/streaks/service.ts rename to src/.backup/game_core/game/streaks/service.ts diff --git a/src/commands/splashcmd/net/featureflags.ts b/src/commands/splashcmd/net/featureflags.ts new file mode 100644 index 0000000..f56ee8f --- /dev/null +++ b/src/commands/splashcmd/net/featureflags.ts @@ -0,0 +1,488 @@ +/** + * Comando de administración de Feature Flags + */ + +import { + ChatInputCommandInteraction, + EmbedBuilder, + MessageFlags, +} from "discord.js"; +import { featureFlagService } from "../../../core/services/FeatureFlagService"; +import { + FeatureFlagConfig, + FeatureFlagStatus, + FeatureFlagTarget, + RolloutStrategy, +} from "../../../core/types/featureFlags"; +import logger from "../../../core/lib/logger"; +import { CommandSlash } from "../../../core/types/commands"; + +export const command: CommandSlash = { + name: "featureflags", + description: "Administra los feature flags del bot", + type: "slash", + cooldown: 5, + options: [ + { + name: "list", + description: "Lista todos los feature flags", + type: 1, + }, + { + name: "info", + description: "Muestra información detallada de un flag", + type: 1, + options: [ + { + name: "flag", + description: "Nombre del flag", + type: 3, + required: true, + autocomplete: true, + }, + ], + }, + { + name: "create", + description: "Crea un nuevo feature flag", + type: 1, + options: [ + { + name: "name", + description: "Nombre único del flag", + type: 3, + required: true, + }, + { + name: "status", + description: "Estado del flag", + type: 3, + required: true, + choices: [ + { name: "Habilitado", value: "enabled" }, + { name: "Deshabilitado", value: "disabled" }, + { name: "Rollout", value: "rollout" }, + { name: "Mantenimiento", value: "maintenance" }, + ], + }, + { + name: "target", + description: "Nivel de aplicación", + type: 3, + required: true, + choices: [ + { name: "Global", value: "global" }, + { name: "Guild", value: "guild" }, + { name: "Usuario", value: "user" }, + { name: "Canal", value: "channel" }, + ], + }, + { + name: "description", + description: "Descripción del flag", + type: 3, + required: false, + }, + ], + }, + { + name: "update", + description: "Actualiza un feature flag existente", + type: 1, + options: [ + { + name: "flag", + description: "Nombre del flag", + type: 3, + required: true, + autocomplete: true, + }, + { + name: "status", + description: "Nuevo estado", + type: 3, + required: false, + choices: [ + { name: "Habilitado", value: "enabled" }, + { name: "Deshabilitado", value: "disabled" }, + { name: "Rollout", value: "rollout" }, + { name: "Mantenimiento", value: "maintenance" }, + ], + }, + ], + }, + { + name: "rollout", + description: "Configura rollout progresivo", + type: 1, + options: [ + { + name: "flag", + description: "Nombre del flag", + type: 3, + required: true, + autocomplete: true, + }, + { + name: "strategy", + description: "Estrategia de rollout", + type: 3, + required: true, + choices: [ + { name: "Porcentaje", value: "percentage" }, + { name: "Whitelist", value: "whitelist" }, + { name: "Blacklist", value: "blacklist" }, + { name: "Gradual", value: "gradual" }, + ], + }, + { + name: "percentage", + description: "Porcentaje de usuarios (0-100) para rollout", + type: 4, + required: false, + min_value: 0, + max_value: 100, + }, + ], + }, + { + name: "delete", + description: "Elimina un feature flag", + type: 1, + options: [ + { + name: "flag", + description: "Nombre del flag", + type: 3, + required: true, + autocomplete: true, + }, + ], + }, + { + name: "stats", + description: "Muestra estadísticas de uso de los flags", + type: 1, + options: [ + { + name: "flag", + description: "Nombre del flag (opcional)", + type: 3, + required: false, + autocomplete: true, + }, + ], + }, + { + name: "refresh", + description: "Refresca el caché de feature flags", + type: 1, + }, + ], + run: async (interaction) => { + const subcommand = interaction.options.getSubcommand(); + + try { + switch (subcommand) { + case "list": + await handleList(interaction); + break; + case "info": + await handleInfo(interaction); + break; + case "create": + await handleCreate(interaction); + break; + case "update": + await handleUpdate(interaction); + break; + case "rollout": + await handleRollout(interaction); + break; + case "delete": + await handleDelete(interaction); + break; + case "stats": + await handleStats(interaction); + break; + case "refresh": + await handleRefresh(interaction); + break; + default: + await interaction.reply({ + content: "❌ Subcomando no reconocido", + flags: MessageFlags.Ephemeral, + }); + } + } catch (error: any) { + logger.error("[FeatureFlagsCmd]", error?.message || error); + const errorMessage = "❌ Error al ejecutar el comando"; + + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: errorMessage, + flags: MessageFlags.Ephemeral, + }); + } else { + await interaction.reply({ + content: errorMessage, + flags: MessageFlags.Ephemeral, + }); + } + } + }, +}; + +async function handleList(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const flags = featureFlagService.getFlags(); + + if (flags.length === 0) { + await interaction.editReply("📋 No hay feature flags configurados."); + return; + } + + const embed = new EmbedBuilder() + .setTitle("🎮 Feature Flags") + .setColor(0x5865f2) + .setDescription(`Total: ${flags.length} flags`); + + for (const flag of flags.slice(0, 25)) { + embed.addFields({ + name: `${getStatusEmoji(flag.status)} ${flag.name}`, + value: `**Status:** ${flag.status}\n**Target:** ${flag.target}${ + flag.description ? `\n${flag.description}` : "" + } `, + inline: true, + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleInfo(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const flagName = interaction.options.getString("flag", true); + const flag = featureFlagService.getFlag(flagName); + + if (!flag) { + await interaction.editReply(`❌ Flag "${flagName}" no encontrado`); + return; + } + + const stats = featureFlagService.getStats(flagName); + const embed = new EmbedBuilder() + .setTitle(`🎮 ${flag.name}`) + .setColor(getStatusColor(flag.status)) + .addFields( + { + name: "Estado", + value: `${getStatusEmoji(flag.status)} ${flag.status}`, + inline: true, + }, + { name: "Target", value: flag.target, inline: true }, + { name: "Estrategia", value: flag.rolloutStrategy || "N/A", inline: true } + ); + + if (flag.description) embed.setDescription(flag.description); + if (flag.rolloutConfig) { + let cfg = ""; + if (flag.rolloutConfig.percentage !== undefined) + cfg += `Porcentaje: ${flag.rolloutConfig.percentage}%\n`; + if (flag.rolloutConfig.targetIds?.length) + cfg += `IDs: ${flag.rolloutConfig.targetIds.length} configurados\n`; + if (cfg) embed.addFields({ name: "Configuración", value: cfg }); + } + + if (flag.startDate || flag.endDate) { + let dates = ""; + if (flag.startDate) + dates += `Inicio: \n`; + if (flag.endDate) + dates += `Fin: \n`; + embed.addFields({ name: "Fechas", value: dates }); + } + + if (stats) { + embed.addFields({ + name: "📊 Estadísticas", + value: `Evaluaciones: ${stats.totalEvaluations}\nHabilitado: ${ + stats.enabledCount + } (${stats.enablementRate.toFixed(1)}%)\nDeshabilitado: ${ + stats.disabledCount + }`, + }); + } + + await interaction.editReply({ embeds: [embed] }); +} + +async function handleCreate(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const config: FeatureFlagConfig = { + name: interaction.options.getString("name", true), + status: interaction.options.getString("status", true) as FeatureFlagStatus, + target: interaction.options.getString("target", true) as FeatureFlagTarget, + description: interaction.options.getString("description") || undefined, + }; + + await featureFlagService.setFlag(config); + await interaction.editReply( + `✅ Feature flag "${config.name}" creado exitosamente` + ); +} + +async function handleUpdate(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const flagName = interaction.options.getString("flag", true); + const flag = featureFlagService.getFlag(flagName); + + if (!flag) { + await interaction.editReply(`❌ Flag "${flagName}" no encontrado`); + return; + } + + const newStatus = interaction.options.getString( + "status" + ) as FeatureFlagStatus | null; + if (newStatus) flag.status = newStatus; + + await featureFlagService.setFlag(flag); + await interaction.editReply(`✅ Feature flag "${flagName}" actualizado`); +} + +async function handleRollout(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const flagName = interaction.options.getString("flag", true); + const flag = featureFlagService.getFlag(flagName); + + if (!flag) { + await interaction.editReply(`❌ Flag "${flagName}" no encontrado`); + return; + } + + flag.status = "rollout"; + flag.rolloutStrategy = interaction.options.getString( + "strategy", + true + ) as RolloutStrategy; + flag.rolloutConfig = flag.rolloutConfig || {}; + + const percentage = interaction.options.getInteger("percentage"); + if (percentage !== null) flag.rolloutConfig.percentage = percentage; + + await featureFlagService.setFlag(flag); + await interaction.editReply( + `✅ Rollout configurado para "${flagName}"\nEstrategia: ${ + flag.rolloutStrategy + } ${percentage !== null ? `\nPorcentaje: ${percentage}%` : ""}` + ); +} + +async function handleDelete(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const flagName = interaction.options.getString("flag", true); + await featureFlagService.removeFlag(flagName); + await interaction.editReply(`✅ Feature flag "\${flagName}" eliminado`); +} + +async function handleStats(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const flagName = interaction.options.getString("flag"); + + if (flagName) { + const stats = featureFlagService.getStats(flagName); + if (!stats) { + await interaction.editReply(`❌ No hay estadísticas para "${flagName}"`); + return; + } + + const embed = new EmbedBuilder() + .setTitle(`📊 Estadísticas: ${flagName}`) + .setColor(0x5865f2) + .addFields( + { + name: "Total Evaluaciones", + value: stats.totalEvaluations.toString(), + inline: true, + }, + { + name: "Habilitado", + value: `${stats.enabledCount} (${stats.enablementRate.toFixed(1)}%)`, + inline: true, + }, + { + name: "Deshabilitado", + value: stats.disabledCount.toString(), + inline: true, + } + ); + + if (stats.lastEvaluation) { + embed.addFields({ + name: "Última Evaluación", + value: ``, + }); + } + + await interaction.editReply({ embeds: [embed] }); + } else { + const allStats = featureFlagService.getAllStats(); + if (allStats.length === 0) { + await interaction.editReply("📊 No hay estadísticas disponibles"); + return; + } + + const embed = new EmbedBuilder() + .setTitle("📊 Estadísticas de Feature Flags") + .setColor(0x5865f2); + for (const stats of allStats.slice(0, 10)) { + embed.addFields({ + name: stats.flagName, + value: `Evaluaciones: ${ + stats.totalEvaluations + }\nTasa: ${stats.enablementRate.toFixed(1)}%`, + inline: true, + }); + } + + await interaction.editReply({ embeds: [embed] }); + } +} + +async function handleRefresh(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await featureFlagService.refreshCache(); + featureFlagService.clearEvaluationCache(); + await interaction.editReply("✅ Caché de feature flags refrescado"); +} + +function getStatusEmoji(status: FeatureFlagStatus): string { + switch (status) { + case "enabled": + return "✅"; + case "disabled": + return "❌"; + case "rollout": + return "🔄"; + case "maintenance": + return "🔧"; + default: + return "❓"; + } +} + +function getStatusColor(status: FeatureFlagStatus): number { + switch (status) { + case "enabled": + return 0x57f287; + case "disabled": + return 0xed4245; + case "rollout": + return 0xfee75c; + case "maintenance": + return 0xeb459e; + default: + return 0x5865f2; + } +} diff --git a/src/core/lib/featureFlagHelpers.ts b/src/core/lib/featureFlagHelpers.ts new file mode 100644 index 0000000..b0e472c --- /dev/null +++ b/src/core/lib/featureFlagHelpers.ts @@ -0,0 +1,265 @@ +/** + * Feature Flag Helpers & Decorators + * Utilities para usar feature flags fácilmente en comandos + */ + +import { + CommandInteraction, + Message, + GuildMember, + User as DiscordUser, +} from "discord.js"; +import { featureFlagService } from "../services/FeatureFlagService"; +import { FeatureFlagContext } from "../types/featureFlags"; +import logger from "./logger"; +import { MessageFlags } from "discord.js"; + +/** + * Extrae contexto de un comando o mensaje de Discord + */ +export function extractContext( + source: CommandInteraction | Message +): FeatureFlagContext { + const context: FeatureFlagContext = { + timestamp: Date.now(), + }; + + if (source instanceof CommandInteraction) { + context.userId = source.user.id; + context.guildId = source.guildId || undefined; + context.channelId = source.channelId; + } else { + context.userId = source.author.id; + context.guildId = source.guildId || undefined; + context.channelId = source.channelId; + } + + return context; +} + +/** + * Check si un feature flag está habilitado para un contexto + */ +export async function isFeatureEnabled( + flagName: string, + context: FeatureFlagContext +): Promise { + try { + return await featureFlagService.isEnabled(flagName, context); + } catch (error) { + //@ts-ignore + logger.error(`[FeatureFlags] Error al verificar "${flagName}":`, error); + return false; + } +} + +/** + * Check si un feature flag está habilitado para un comando/mensaje + */ +export async function isFeatureEnabledForInteraction( + flagName: string, + source: CommandInteraction | Message +): Promise { + const context = extractContext(source); + return isFeatureEnabled(flagName, context); +} + +/** + * Decorador para proteger métodos con feature flags + * + * @example + * ```ts + * class MyCommand { + * @RequireFeature('new_shop_system') + * async execute(interaction: CommandInteraction) { + * // Este método solo se ejecuta si el flag está habilitado + * } + * } + * ``` + */ +export function RequireFeature( + flagName: string, + options: { + fallbackMessage?: string; + silent?: boolean; + } = {} +): MethodDecorator { + return function ( + target: any, + propertyKey: string | symbol, + descriptor: PropertyDescriptor + ) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + // Intentar extraer el contexto del primer argumento + const firstArg = args[0]; + let context: FeatureFlagContext = {}; + + if ( + firstArg instanceof CommandInteraction || + firstArg instanceof Message + ) { + context = extractContext(firstArg); + } + + const enabled = await isFeatureEnabled(flagName, context); + + if (!enabled) { + if (!options.silent) { + const message = + options.fallbackMessage || + "Esta funcionalidad no está disponible en este momento."; + + if (firstArg instanceof CommandInteraction) { + if (firstArg.replied || firstArg.deferred) { + await firstArg.followUp({ + content: message, + flags: MessageFlags.Ephemeral, + }); + } else { + await firstArg.reply({ + content: message, + flags: MessageFlags.Ephemeral, + }); + } + } else if (firstArg instanceof Message) { + await firstArg.reply(message); + } + } + + logger.debug( + `[FeatureFlags] Método ${String( + propertyKey + )} bloqueado por flag "${flagName}"` + ); + return; + } + + return originalMethod.apply(this, args); + }; + + return descriptor; + }; +} + +/** + * Guard para usar en handlers de comandos + * + * @example + * ```ts + * if (await featureGuard('new_shop', interaction)) { + * // Código solo si el flag está habilitado + * } + * ``` + */ +export async function featureGuard( + flagName: string, + source: CommandInteraction | Message, + options: { + replyIfDisabled?: boolean; + customMessage?: string; + } = { replyIfDisabled: true } +): Promise { + const enabled = await isFeatureEnabledForInteraction(flagName, source); + + if (!enabled && options.replyIfDisabled) { + const message = + options.customMessage || + "⚠️ Esta funcionalidad está deshabilitada temporalmente."; + + if (source instanceof CommandInteraction) { + if (source.replied || source.deferred) { + await source.followUp({ + content: message, + flags: MessageFlags.Ephemeral, + }); + } else { + await source.reply({ content: message, flags: MessageFlags.Ephemeral }); + } + } else { + await source.reply(message); + } + } + + return enabled; +} + +/** + * Wrapper para funciones que requieren feature flags + * + * @example + * ```ts + * await withFeature('new_mining', context, async () => { + * // Este código solo se ejecuta si el flag está habilitado + * await doMining(); + * }); + * ``` + */ +export async function withFeature( + flagName: string, + context: FeatureFlagContext, + fn: () => Promise, + fallback?: () => Promise +): Promise { + const enabled = await isFeatureEnabled(flagName, context); + + if (enabled) { + return fn(); + } + + if (fallback) { + return fallback(); + } + + return undefined; +} + +/** + * Helper para A/B testing - ejecuta una función u otra según el flag + * + * @example + * ```ts + * await abTest('new_algorithm', context, { + * variant: async () => { /* nueva versión *\/ }, + * control: async () => { /* versión antigua *\/ } + * }); + * ``` + */ +export async function abTest( + flagName: string, + context: FeatureFlagContext, + variants: { + variant: () => Promise; + control: () => Promise; + } +): Promise { + const enabled = await isFeatureEnabled(flagName, context); + return enabled ? variants.variant() : variants.control(); +} + +/** + * Helper para ejecutar código solo si múltiples flags están habilitados + */ +export async function requireAllFeatures( + flagNames: string[], + context: FeatureFlagContext +): Promise { + const results = await Promise.all( + flagNames.map((flag) => isFeatureEnabled(flag, context)) + ); + return results.every((enabled) => enabled); +} + +/** + * Helper para ejecutar código si al menos un flag está habilitado + */ +export async function requireAnyFeature( + flagNames: string[], + context: FeatureFlagContext +): Promise { + const results = await Promise.all( + flagNames.map((flag) => isFeatureEnabled(flag, context)) + ); + return results.some((enabled) => enabled); +} diff --git a/src/core/loaders/featureFlagsLoader.ts b/src/core/loaders/featureFlagsLoader.ts new file mode 100644 index 0000000..32dd094 --- /dev/null +++ b/src/core/loaders/featureFlagsLoader.ts @@ -0,0 +1,22 @@ +/** + * Feature Flags Loader + * Inicializa el servicio de feature flags al arrancar el bot + */ + +import { featureFlagService } from "../services/FeatureFlagService"; +import logger from "../lib/logger"; + +export async function loadFeatureFlags(): Promise { + try { + logger.info("[FeatureFlags] Inicializando servicio..."); + await featureFlagService.initialize(); + logger.info("[FeatureFlags] ✅ Servicio inicializado correctamente"); + } catch (error) { + //@ts-ignore + logger.error("[FeatureFlags] ❌ Error al inicializar:", error); + // No lanzamos el error para no bloquear el arranque del bot + // El servicio funcionará en modo fail-safe (todos los flags disabled) + } +} + +export default loadFeatureFlags; diff --git a/src/core/services/FeatureFlagService.ts b/src/core/services/FeatureFlagService.ts new file mode 100644 index 0000000..73df0d1 --- /dev/null +++ b/src/core/services/FeatureFlagService.ts @@ -0,0 +1,629 @@ +/** + * Feature Flag Service + * Sistema de control de features para rollouts progresivos, A/B testing y feature toggles + * + * Características: + * - Rollouts progresivos por porcentaje + * - Whitelisting/blacklisting de usuarios/guilds + * - A/B testing + * - Caché en memoria para performance + * - Persistencia en Prisma + AppWrite + * - Sistema de evaluación con contexto + */ + +import { Collection } from "discord.js"; +import { prisma } from "../database/prisma"; +import logger from "../lib/logger"; +import { + FeatureFlagConfig, + FeatureFlagContext, + FeatureFlagEvaluation, + FeatureFlagStats, + FeatureFlagStatus, + RolloutStrategy, +} from "../types/featureFlags"; + +class FeatureFlagService { + // Caché en memoria para flags (evitar golpear DB constantemente) + private flagsCache: Collection; + + // Caché de evaluaciones por contexto (para consistencia en la sesión) + private evaluationCache: Collection>; + + // Stats en memoria + private stats: Collection; + + // TTL del caché (5 minutos) + private cacheTTL: number = 5 * 60 * 1000; + + // Última actualización del caché + private lastCacheUpdate: number = 0; + + // Flag para saber si está inicializado + private initialized: boolean = false; + + constructor() { + this.flagsCache = new Collection(); + this.evaluationCache = new Collection(); + this.stats = new Collection(); + } + + /** + * Inicializa el servicio y carga los flags desde la DB + */ + async initialize(): Promise { + if (this.initialized) { + logger.warn("[FeatureFlags] Ya inicializado, omitiendo..."); + return; + } + + try { + logger.info("[FeatureFlags] Inicializando servicio..."); + await this.refreshCache(); + this.initialized = true; + logger.info( + `[FeatureFlags] Inicializado con ${this.flagsCache.size} flags` + ); + } catch (error) { + //@ts-ignore + logger.error("[FeatureFlags] Error al inicializar:", error); + throw error; + } + } + + /** + * Refresca el caché de flags desde la base de datos + */ + async refreshCache(): Promise { + try { + const flags = await prisma.featureFlag.findMany(); + + this.flagsCache.clear(); + + for (const flag of flags) { + const config: FeatureFlagConfig = { + name: flag.name, + description: flag.description || undefined, + status: flag.status as FeatureFlagStatus, + target: flag.target as any, + rolloutStrategy: flag.rolloutStrategy as RolloutStrategy | undefined, + rolloutConfig: flag.rolloutConfig + ? JSON.parse(flag.rolloutConfig as string) + : undefined, + startDate: flag.startDate || undefined, + endDate: flag.endDate || undefined, + metadata: flag.metadata + ? JSON.parse(flag.metadata as string) + : undefined, + createdAt: flag.createdAt, + updatedAt: flag.updatedAt, + }; + + this.flagsCache.set(flag.name, config); + } + + this.lastCacheUpdate = Date.now(); + logger.debug( + `[FeatureFlags] Caché actualizado: ${this.flagsCache.size} flags` + ); + } catch (error) { + //@ts-ignore + logger.error("[FeatureFlags] Error al refrescar caché:", error); + throw error; + } + } + + /** + * Verifica si el caché necesita actualizarse + */ + private async checkCacheValidity(): Promise { + const now = Date.now(); + if (now - this.lastCacheUpdate > this.cacheTTL) { + await this.refreshCache(); + } + } + + /** + * Evalúa si un feature flag está habilitado para un contexto dado + */ + async isEnabled( + flagName: string, + context: FeatureFlagContext = {} + ): Promise { + try { + await this.checkCacheValidity(); + + const flag = this.flagsCache.get(flagName); + + // Si el flag no existe, asumimos deshabilitado + if (!flag) { + logger.warn( + `[FeatureFlags] Flag "${flagName}" no encontrado, retornando false` + ); + return false; + } + + // Verificar si hay una evaluación cacheada para este contexto + const cacheKey = this.generateContextKey(flagName, context); + const contextCache = this.evaluationCache.get(cacheKey); + if (contextCache !== undefined) { + const cachedResult = contextCache.get(flagName); + if (cachedResult !== undefined) { + return cachedResult; + } + } + + // Evaluar el flag + const evaluation = await this.evaluate(flag, context); + + // Cachear resultado + this.cacheEvaluation(cacheKey, flagName, evaluation.enabled); + + // Actualizar stats + this.updateStats(flagName, evaluation.enabled); + + logger.debug( + `[FeatureFlags] "${flagName}" evaluado: ${evaluation.enabled} (${evaluation.reason})` + ); + + return evaluation.enabled; + } catch (error) { + //@ts-ignore + logger.error(`[FeatureFlags] Error al evaluar "${flagName}":`, error); + return false; // Fail-safe: si hay error, deshabilitamos + } + } + + /** + * Evalúa un flag según su configuración + */ + private async evaluate( + flag: FeatureFlagConfig, + context: FeatureFlagContext + ): Promise { + const now = Date.now(); + + // Verificar fechas de inicio/fin + if (flag.startDate && now < flag.startDate.getTime()) { + return { + flagName: flag.name, + enabled: false, + reason: "Flag aún no iniciado", + timestamp: now, + }; + } + + if (flag.endDate && now > flag.endDate.getTime()) { + return { + flagName: flag.name, + enabled: false, + reason: "Flag expirado", + timestamp: now, + }; + } + + // Evaluar según status + switch (flag.status) { + case "enabled": + return { + flagName: flag.name, + enabled: true, + reason: "Flag habilitado globalmente", + timestamp: now, + }; + + case "disabled": + return { + flagName: flag.name, + enabled: false, + reason: "Flag deshabilitado", + timestamp: now, + }; + + case "maintenance": + return { + flagName: flag.name, + enabled: false, + reason: "Flag en mantenimiento", + timestamp: now, + }; + + case "rollout": + return this.evaluateRollout(flag, context, now); + + default: + return { + flagName: flag.name, + enabled: false, + reason: "Status desconocido", + timestamp: now, + }; + } + } + + /** + * Evalúa una estrategia de rollout + */ + private evaluateRollout( + flag: FeatureFlagConfig, + context: FeatureFlagContext, + timestamp: number + ): FeatureFlagEvaluation { + const strategy = flag.rolloutStrategy; + const config = flag.rolloutConfig; + + if (!strategy || !config) { + return { + flagName: flag.name, + enabled: false, + reason: "Rollout sin configuración", + strategy, + timestamp, + }; + } + + switch (strategy) { + case "whitelist": + return this.evaluateWhitelist(flag, context, timestamp); + + case "blacklist": + return this.evaluateBlacklist(flag, context, timestamp); + + case "percentage": + return this.evaluatePercentage(flag, context, timestamp); + + case "gradual": + return this.evaluateGradual(flag, context, timestamp); + + case "random": + return this.evaluateRandom(flag, context, timestamp); + + default: + return { + flagName: flag.name, + enabled: false, + reason: "Estrategia desconocida", + strategy, + timestamp, + }; + } + } + + /** + * Evalúa estrategia de whitelist + */ + private evaluateWhitelist( + flag: FeatureFlagConfig, + context: FeatureFlagContext, + timestamp: number + ): FeatureFlagEvaluation { + const targetIds = flag.rolloutConfig?.targetIds || []; + const contextId = this.getContextId(flag, context); + + const enabled = targetIds.includes(contextId); + + return { + flagName: flag.name, + enabled, + reason: enabled ? "ID en whitelist" : "ID no en whitelist", + strategy: "whitelist", + timestamp, + }; + } + + /** + * Evalúa estrategia de blacklist + */ + private evaluateBlacklist( + flag: FeatureFlagConfig, + context: FeatureFlagContext, + timestamp: number + ): FeatureFlagEvaluation { + const targetIds = flag.rolloutConfig?.targetIds || []; + const contextId = this.getContextId(flag, context); + + const enabled = !targetIds.includes(contextId); + + return { + flagName: flag.name, + enabled, + reason: enabled ? "ID no en blacklist" : "ID en blacklist", + strategy: "blacklist", + timestamp, + }; + } + + /** + * Evalúa estrategia de porcentaje + */ + private evaluatePercentage( + flag: FeatureFlagConfig, + context: FeatureFlagContext, + timestamp: number + ): FeatureFlagEvaluation { + const percentage = flag.rolloutConfig?.percentage || 0; + const contextId = this.getContextId(flag, context); + + // Hash determinista basado en el ID + const hash = this.hashString(contextId + flag.name); + const userPercentage = (hash % 100) + 1; + + const enabled = userPercentage <= percentage; + + return { + flagName: flag.name, + enabled, + reason: `Porcentaje: ${userPercentage}% <= ${percentage}%`, + strategy: "percentage", + timestamp, + }; + } + + /** + * Evalúa estrategia de rollout gradual + */ + private evaluateGradual( + flag: FeatureFlagConfig, + context: FeatureFlagContext, + timestamp: number + ): FeatureFlagEvaluation { + const gradual = flag.rolloutConfig?.gradual; + + if (!gradual || !flag.startDate) { + return { + flagName: flag.name, + enabled: false, + reason: "Gradual sin configuración válida", + strategy: "gradual", + timestamp, + }; + } + + const startTime = flag.startDate.getTime(); + const durationMs = gradual.durationDays * 24 * 60 * 60 * 1000; + const elapsed = timestamp - startTime; + + if (elapsed < 0) { + return { + flagName: flag.name, + enabled: false, + reason: "Rollout gradual no iniciado", + strategy: "gradual", + timestamp, + }; + } + + // Calcular porcentaje actual del rollout + const progress = Math.min(1, elapsed / durationMs); + const currentPercentage = + gradual.startPercentage + + (gradual.targetPercentage - gradual.startPercentage) * progress; + + // Evaluar con el porcentaje actual + const contextId = this.getContextId(flag, context); + const hash = this.hashString(contextId + flag.name); + const userPercentage = (hash % 100) + 1; + + const enabled = userPercentage <= currentPercentage; + + return { + flagName: flag.name, + enabled, + reason: `Gradual: ${currentPercentage.toFixed(1)}% (día ${Math.floor( + elapsed / (24 * 60 * 60 * 1000) + )}/${gradual.durationDays})`, + strategy: "gradual", + timestamp, + }; + } + + /** + * Evalúa estrategia aleatoria + */ + private evaluateRandom( + flag: FeatureFlagConfig, + context: FeatureFlagContext, + timestamp: number + ): FeatureFlagEvaluation { + const seed = flag.rolloutConfig?.randomSeed || 0; + const contextId = this.getContextId(flag, context); + + // Pseudo-random determinista basado en seed y context + const hash = this.hashString(contextId + flag.name + seed); + const enabled = hash % 2 === 0; + + return { + flagName: flag.name, + enabled, + reason: enabled ? "Random: true" : "Random: false", + strategy: "random", + timestamp, + }; + } + + /** + * Obtiene el ID relevante del contexto según el target del flag + */ + private getContextId( + flag: FeatureFlagConfig, + context: FeatureFlagContext + ): string { + switch (flag.target) { + case "user": + return context.userId || "unknown"; + case "guild": + return context.guildId || "unknown"; + case "channel": + return context.channelId || "unknown"; + case "global": + default: + return "global"; + } + } + + /** + * Genera un hash simple de un string (para distribución consistente) + */ + private hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); + } + + /** + * Genera una clave única para el contexto + */ + private generateContextKey( + flagName: string, + context: FeatureFlagContext + ): string { + return `${flagName}:${context.userId || "none"}:${ + context.guildId || "none" + }:${context.channelId || "none"}`; + } + + /** + * Cachea una evaluación + */ + private cacheEvaluation( + contextKey: string, + flagName: string, + enabled: boolean + ): void { + if (!this.evaluationCache.has(contextKey)) { + this.evaluationCache.set(contextKey, new Map()); + } + this.evaluationCache.get(contextKey)!.set(flagName, enabled); + } + + /** + * Actualiza las estadísticas de un flag + */ + private updateStats(flagName: string, enabled: boolean): void { + if (!this.stats.has(flagName)) { + this.stats.set(flagName, { + flagName, + totalEvaluations: 0, + enabledCount: 0, + disabledCount: 0, + enablementRate: 0, + lastEvaluation: new Date(), + }); + } + + const stats = this.stats.get(flagName)!; + stats.totalEvaluations++; + if (enabled) { + stats.enabledCount++; + } else { + stats.disabledCount++; + } + stats.enablementRate = (stats.enabledCount / stats.totalEvaluations) * 100; + stats.lastEvaluation = new Date(); + } + + /** + * Crea o actualiza un feature flag + */ + async setFlag(config: FeatureFlagConfig): Promise { + try { + const data = { + name: config.name, + description: config.description || null, + status: config.status, + target: config.target, + rolloutStrategy: config.rolloutStrategy || null, + rolloutConfig: config.rolloutConfig + ? JSON.stringify(config.rolloutConfig) + : null, + startDate: config.startDate || null, + endDate: config.endDate || null, + metadata: config.metadata ? JSON.stringify(config.metadata) : null, + }; + + await prisma.featureFlag.upsert({ + where: { name: config.name }, + create: data, + update: data, + }); + + // Actualizar caché + this.flagsCache.set(config.name, config); + + logger.info(`[FeatureFlags] Flag "${config.name}" actualizado`); + } catch (error) { + logger.error( + `[FeatureFlags] Error al setear flag "${config.name}":`, + //@ts-ignore + error + ); + throw error; + } + } + + /** + * Elimina un feature flag + */ + async removeFlag(flagName: string): Promise { + try { + await prisma.featureFlag.delete({ + where: { name: flagName }, + }); + + this.flagsCache.delete(flagName); + this.stats.delete(flagName); + + logger.info(`[FeatureFlags] Flag "${flagName}" eliminado`); + } catch (error) { + logger.error( + `[FeatureFlags] Error al eliminar flag "${flagName}":`, + //@ts-ignore + error + ); + throw error; + } + } + + /** + * Obtiene todos los flags + */ + getFlags(): FeatureFlagConfig[] { + return Array.from(this.flagsCache.values()); + } + + /** + * Obtiene un flag específico + */ + getFlag(flagName: string): FeatureFlagConfig | undefined { + return this.flagsCache.get(flagName); + } + + /** + * Obtiene las estadísticas de un flag + */ + getStats(flagName: string): FeatureFlagStats | undefined { + return this.stats.get(flagName); + } + + /** + * Obtiene todas las estadísticas + */ + getAllStats(): FeatureFlagStats[] { + return Array.from(this.stats.values()); + } + + /** + * Limpia el caché de evaluaciones + */ + clearEvaluationCache(): void { + this.evaluationCache.clear(); + logger.info("[FeatureFlags] Caché de evaluaciones limpiado"); + } +} + +// Singleton +export const featureFlagService = new FeatureFlagService(); diff --git a/src/core/types/featureFlags.ts b/src/core/types/featureFlags.ts new file mode 100644 index 0000000..bf73548 --- /dev/null +++ b/src/core/types/featureFlags.ts @@ -0,0 +1,142 @@ +/** + * Feature Flags Types + * Sistema de control de features para rollouts progresivos, A/B testing y toggles + */ + +export type FeatureFlagStatus = + | "enabled" + | "disabled" + | "rollout" + | "maintenance"; + +export type FeatureFlagTarget = "global" | "guild" | "user" | "channel"; + +export type RolloutStrategy = + | "percentage" // Basado en % de usuarios + | "whitelist" // Lista específica de IDs + | "blacklist" // Todos excepto lista + | "gradual" // Rollout gradual basado en tiempo + | "random"; // Aleatorio por sesión + +export interface FeatureFlagConfig { + /** Nombre único del flag */ + name: string; + + /** Descripción del flag */ + description?: string; + + /** Estado del flag */ + status: FeatureFlagStatus; + + /** Nivel de aplicación del flag */ + target: FeatureFlagTarget; + + /** Estrategia de rollout (si status es 'rollout') */ + rolloutStrategy?: RolloutStrategy; + + /** Configuración específica de la estrategia */ + rolloutConfig?: RolloutConfig; + + /** Fecha de inicio del flag */ + startDate?: Date; + + /** Fecha de fin del flag (auto-deshabilitar) */ + endDate?: Date; + + /** Metadata adicional */ + metadata?: Record; + + /** Timestamp de creación */ + createdAt?: Date; + + /** Timestamp de última actualización */ + updatedAt?: Date; +} + +export interface RolloutConfig { + /** Porcentaje de usuarios (0-100) para estrategia 'percentage' */ + percentage?: number; + + /** Lista de IDs (guild/user/channel) para whitelist/blacklist */ + targetIds?: string[]; + + /** Configuración de rollout gradual */ + gradual?: { + /** Porcentaje inicial */ + startPercentage: number; + /** Porcentaje objetivo */ + targetPercentage: number; + /** Duración del rollout en días */ + durationDays: number; + }; + + /** Seed para aleatorización consistente */ + randomSeed?: number; +} + +export interface FeatureFlagContext { + /** ID del usuario */ + userId?: string; + + /** ID del guild */ + guildId?: string; + + /** ID del canal */ + channelId?: string; + + /** Timestamp de la evaluación */ + timestamp?: number; + + /** Metadata adicional del contexto */ + metadata?: Record; +} + +export interface FeatureFlagEvaluation { + /** Nombre del flag evaluado */ + flagName: string; + + /** Resultado de la evaluación */ + enabled: boolean; + + /** Razón de la decisión */ + reason: string; + + /** Estrategia aplicada */ + strategy?: RolloutStrategy; + + /** Timestamp de evaluación */ + timestamp: number; +} + +export interface FeatureFlagStats { + /** Nombre del flag */ + flagName: string; + + /** Total de evaluaciones */ + totalEvaluations: number; + + /** Evaluaciones positivas (enabled) */ + enabledCount: number; + + /** Evaluaciones negativas (disabled) */ + disabledCount: number; + + /** Tasa de activación (%) */ + enablementRate: number; + + /** Última evaluación */ + lastEvaluation?: Date; +} + +/** Decorador para proteger comandos con feature flags */ +export interface FeatureFlagDecorator { + ( + flagName: string, + options?: { + fallbackMessage?: string; + silent?: boolean; + checkUser?: boolean; + checkGuild?: boolean; + } + ): MethodDecorator; +} diff --git a/src/main.ts b/src/main.ts index c1ef036..9a8b61d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import { cleanExpiredGuildCache } from "./core/database/guildCache"; import logger from "./core/lib/logger"; import { applyModalSubmitInteractionPatch } from "./core/patches/discordModalPatch"; import { server } from "./server/server"; +import loadFeatureFlags from "./core/loaders/featureFlagsLoader"; // Activar monitor de memoria si se define la variable const __memInt = parseInt(process.env.MEMORY_LOG_INTERVAL_SECONDS || "0", 10); @@ -211,7 +212,13 @@ async function bootstrap() { } catch (e) { logger.error({ err: e }, "Error cargando eventos"); } + try { + await loadFeatureFlags(); + } catch (e) { + logger.error({ err: e }, "Error cargando feature flags"); + } + /* // Inicializar repositorio de mobs (intenta cargar mobs desde DB si existe) try { // import dinamico para evitar ciclos en startup @@ -220,6 +227,7 @@ async function bootstrap() { } catch (e) { logger.warn({ err: e }, "No se pudo inicializar el repositorio de mobs"); } + */ // Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot withRetry("Registrar slash commands", async () => { diff --git a/test/examples/featureFlagsUsage.ts b/test/examples/featureFlagsUsage.ts new file mode 100644 index 0000000..4c6a8bb --- /dev/null +++ b/test/examples/featureFlagsUsage.ts @@ -0,0 +1,296 @@ +/** + * Ejemplo de Uso de Feature Flags en Comandos + * + * Este archivo muestra varios patrones de uso del sistema de feature flags + */ + +import { CommandInteraction, Message } from "discord.js"; +import { + RequireFeature, + featureGuard, + isFeatureEnabledForInteraction, + abTest, + extractContext, + requireAllFeatures, +} from "../core/lib/featureFlagHelpers"; + +// ============================================================================ +// Ejemplo 1: Usar decorador @RequireFeature +// ============================================================================ +export class ShopCommand { + /** + * El decorador RequireFeature bloquea automáticamente la ejecución + * si el flag no está habilitado y responde al usuario + */ + @RequireFeature("new_shop_system", { + fallbackMessage: "🔧 El nuevo sistema de tienda estará disponible pronto.", + }) + async execute(interaction: CommandInteraction) { + // Este código solo se ejecuta si el flag está habilitado + await interaction.reply("¡Bienvenido a la nueva tienda!"); + } +} + +// ============================================================================ +// Ejemplo 2: Usar featureGuard (más control) +// ============================================================================ +export async function handleMiningCommand(interaction: CommandInteraction) { + // featureGuard devuelve true/false y opcionalmente responde al usuario + if ( + !(await featureGuard("new_mining_system", interaction, { + replyIfDisabled: true, + customMessage: "⛏️ El nuevo sistema de minería está en mantenimiento.", + })) + ) { + return; // Sale si el flag está deshabilitado + } + + // Código del nuevo sistema de minería + await interaction.reply("⛏️ Iniciando minería con el nuevo sistema..."); +} + +// ============================================================================ +// Ejemplo 3: Check manual (para lógica condicional) +// ============================================================================ +export async function handleInventoryCommand(interaction: CommandInteraction) { + const useNewUI = await isFeatureEnabledForInteraction( + "inventory_ui_v2", + interaction + ); + + if (useNewUI) { + // Muestra el inventario con la nueva UI + await showInventoryV2(interaction); + } else { + // Muestra el inventario con la UI antigua + await showInventoryV1(interaction); + } +} + +async function showInventoryV2(interaction: CommandInteraction) { + await interaction.reply("📦 Inventario (UI v2)"); +} + +async function showInventoryV1(interaction: CommandInteraction) { + await interaction.reply("📦 Inventario (UI v1)"); +} + +// ============================================================================ +// Ejemplo 4: A/B Testing +// ============================================================================ +export async function handleCombatCommand(interaction: CommandInteraction) { + const context = extractContext(interaction); + + // A/B testing: mitad de usuarios usa el algoritmo nuevo, mitad el viejo + const result = await abTest("improved_combat_algorithm", context, { + variant: async () => { + // Nueva versión del algoritmo + return calculateDamageV2(); + }, + control: async () => { + // Versión antigua del algoritmo + return calculateDamageV1(); + }, + }); + + await interaction.reply(`⚔️ Daño calculado: ${result}`); +} + +function calculateDamageV2(): number { + // Lógica nueva + return Math.floor(Math.random() * 100) + 50; +} + +function calculateDamageV1(): number { + // Lógica antigua + return Math.floor(Math.random() * 50) + 25; +} + +// ============================================================================ +// Ejemplo 5: Múltiples flags (acceso premium) +// ============================================================================ +export async function handlePremiumFeature(interaction: CommandInteraction) { + const context = extractContext(interaction); + + // Requiere que TODOS los flags estén habilitados + const hasAccess = await requireAllFeatures( + ["premium_features", "beta_access", "advanced_commands"], + context + ); + + if (!hasAccess) { + await interaction.reply({ + content: "❌ No tienes acceso a esta funcionalidad premium.", + flags: ["Ephemeral"], + }); + return; + } + + await interaction.reply("✨ Funcionalidad premium activada!"); +} + +// ============================================================================ +// Ejemplo 6: Migrando de sistema antiguo a nuevo gradualmente +// ============================================================================ +export async function handleEconomyCommand(interaction: CommandInteraction) { + const useNewSystem = await isFeatureEnabledForInteraction( + "economy_system_v2", + interaction + ); + + if (useNewSystem) { + // Nuevo sistema de economía + await newEconomySystem.processTransaction(interaction); + } else { + // Sistema antiguo (mantener por compatibilidad durante el rollout) + await oldEconomySystem.processTransaction(interaction); + } +} + +// Simulación de sistemas +const newEconomySystem = { + async processTransaction(interaction: CommandInteraction) { + await interaction.reply("💰 Transacción procesada (Sistema v2)"); + }, +}; + +const oldEconomySystem = { + async processTransaction(interaction: CommandInteraction) { + await interaction.reply("💰 Transacción procesada (Sistema v1)"); + }, +}; + +// ============================================================================ +// Ejemplo 7: Eventos temporales con fechas +// ============================================================================ +export async function handleHalloweenEvent(interaction: CommandInteraction) { + // El flag 'halloween_2025' tiene startDate y endDate configurados + // Se habilitará automáticamente durante el período del evento + if ( + !(await featureGuard("halloween_2025", interaction, { + replyIfDisabled: true, + customMessage: + "🎃 El evento de Halloween no está activo en este momento.", + })) + ) { + return; + } + + await interaction.reply("🎃 ¡Bienvenido al evento de Halloween 2025!"); +} + +// ============================================================================ +// Ejemplo 8: Kill Switch para emergencias +// ============================================================================ +export async function handleProblematicFeature( + interaction: CommandInteraction +) { + // Si hay un bug crítico, el administrador puede cambiar el flag a 'maintenance' + // inmediatamente sin necesidad de redeploy + if ( + !(await featureGuard("experimental_feature", interaction, { + replyIfDisabled: true, + customMessage: + "🔧 Esta funcionalidad está en mantenimiento temporalmente.", + })) + ) { + return; + } + + // Código que podría tener bugs + await experimentalLogic(interaction); +} + +async function experimentalLogic(interaction: CommandInteraction) { + await interaction.reply("🧪 Funcionalidad experimental activada"); +} + +// ============================================================================ +// Ejemplo 9: Beta Testing por Guild (servidor) +// ============================================================================ +export async function handleBetaCommand(interaction: CommandInteraction) { + // El flag 'beta_features' está configurado con: + // - target: 'guild' + // - rolloutStrategy: 'whitelist' + // - rolloutConfig: { targetIds: ['guild_id_1', 'guild_id_2'] } + + const context = extractContext(interaction); + + if ( + !(await featureGuard("beta_features", interaction, { + replyIfDisabled: true, + customMessage: + "🔒 Tu servidor no tiene acceso a las funcionalidades beta.", + })) + ) { + return; + } + + await interaction.reply( + "🧪 Funcionalidades beta activadas para este servidor!" + ); +} + +// ============================================================================ +// Ejemplo 10: Rollout progresivo por porcentaje +// ============================================================================ +export async function handleNewGameMode(interaction: CommandInteraction) { + // El flag 'new_game_mode' está configurado con: + // - status: 'rollout' + // - rolloutStrategy: 'percentage' + // - rolloutConfig: { percentage: 25 } + // + // Esto significa que el 25% de usuarios verán el nuevo modo de juego + // de forma determinista (el mismo usuario siempre verá lo mismo) + + if (!(await featureGuard("new_game_mode", interaction))) { + return; + } + + await interaction.reply("🎮 ¡Nuevo modo de juego desbloqueado!"); +} + +// ============================================================================ +// Ejemplo 11: Usando en Message Commands (comandos de texto) +// ============================================================================ +export async function handleTextCommand(message: Message, args: string[]) { + // También funciona con comandos de texto tradicionales + const context = extractContext(message); + + const useNewParser = await isFeatureEnabledForInteraction( + "new_command_parser", + message + ); + + if (useNewParser) { + await parseCommandV2(message, args); + } else { + await parseCommandV1(message, args); + } +} + +async function parseCommandV2(message: Message, args: string[]) { + await message.reply("Comando parseado con parser v2"); +} + +async function parseCommandV1(message: Message, args: string[]) { + await message.reply("Comando parseado con parser v1"); +} + +// ============================================================================ +// RESUMEN DE PATRONES +// ============================================================================ +/* + * 1. @RequireFeature - Para bloquear métodos enteros fácilmente + * 2. featureGuard - Para checks con respuesta automática al usuario + * 3. isFeatureEnabled - Para lógica condicional if/else + * 4. abTest - Para A/B testing + * 5. requireAllFeatures - Para requerir múltiples flags (AND) + * 6. requireAnyFeature - Para requerir al menos uno (OR) + * 7. withFeature - Para ejecutar código con fallback opcional + * + * Configuración de flags vía comando: + * /featureflags create name:flag_name status:disabled target:global + * /featureflags update flag:flag_name status:enabled + * /featureflags rollout flag:flag_name strategy:percentage percentage:25 + */ diff --git a/tsconfig.json b/tsconfig.json index 772aefc..bec4ed8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ "skipLibCheck": true, "resolveJsonModule": true }, - "include": ["src"] + "include": ["src", "test/examples"] } \ No newline at end of file