workspace/projects/gamilit/docs/97-adr/ADR-016-simplificar-backend-xp-acumulacion.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- Configure workspace Git repository with comprehensive .gitignore
- Add Odoo as submodule for ERP reference code
- Include documentation: SETUP.md, GIT-STRUCTURE.md
- Add gitignore templates for projects (backend, frontend, database)
- Structure supports independent repos per project/subproject level

Workspace includes:
- core/ - Reusable patterns, modules, orchestration system
- projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.)
- knowledge-base/ - Reference code and patterns (includes Odoo submodule)
- devtools/ - Development tools and templates
- customers/ - Client implementations template

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:44:23 -06:00

12 KiB

ADR-016: Simplificar Backend XP Acumulación - Delegar Promoción a Trigger DB

Estado: Aceptado e Implementado Fecha: 2025-11-24 Autor: Architecture-Analyst Relacionado con: Bug Fix - Sistema de Rangos Maya (ET-GAM-003)


Contexto

El sistema de rangos Maya tiene un bug crítico que impide que los usuarios suban de rango:

Problema:

  • Usuarios completan múltiples módulos pero NO avanzan de rango (Ajaw → Nacom → Ah K'in, etc.)
  • El campo total_xp en gamification_system.user_stats nunca alcanza los umbrales (500, 1000, 1500, 2250)
  • El trigger trg_check_rank_promotion_on_xp_gain NO detecta promociones

Causa raíz: El método UserStatsService.addXp() tenía lógica que restaba XP en lugar de acumularlo:

// ❌ CÓDIGO PROBLEMÁTICO
async addXp(userId: string, xpAmount: number): Promise<UserStats> {
  const stats = await this.findByUserId(userId);
  stats.total_xp += xpAmount;  // ✅ Suma inicial

  // ❌ RESTA EL XP AQUÍ
  while (stats.total_xp >= stats.xp_to_next_level) {
    stats.total_xp -= stats.xp_to_next_level;  // ❌ BUG
    stats.level += 1;
  }

  return await this.userStatsRepo.save(stats);
}

Resultado: Usuario con 10 ejercicios completados (~1,000 XP esperados) tiene total_xp = 0-100 en DB.


Decisión

Eliminamos la lógica de backend que maneja promociones de rango y delegamos completamente al trigger de base de datos.

Nueva arquitectura:

Backend (NestJS):
  ├─ addXp(userId, xpAmount)
  │   └─ stats.total_xp += xpAmount  // SOLO sumar
  │   └─ save(stats)                 // SOLO guardar
  └─ (FIN)

Database (PostgreSQL):
  ├─ TRIGGER trg_check_rank_promotion_on_xp_gain
  │   └─ WHEN (NEW.total_xp > OLD.total_xp)
  │   └─ EXECUTE check_rank_promotion(user_id)
  │       ├─ IF total_xp >= min_xp_required THEN
  │       │   └─ promote_to_next_rank(user_id, next_rank)
  │       │       ├─ UPDATE current_rank
  │       │       ├─ CREATE achievement
  │       │       ├─ INSERT rank_history
  │       │       ├─ UPDATE ml_coins
  │       │       └─ CREATE notification
  │       └─ END IF
  └─ (FIN)

Backend simplificado:

// ✅ CÓDIGO CORRECTO
async addXp(userId: string, xpAmount: number): Promise<UserStats> {
  const stats = await this.findByUserId(userId);
  stats.total_xp += xpAmount;
  return await this.userStatsRepo.save(stats);
}

Razones

1. Arquitectura ya diseñada para triggers

La especificación técnica (ET-GAM-003) dice:

"Promoción automática mediante triggers PostgreSQL"

El trigger trg_check_rank_promotion_on_xp_gain ya existe y está correctamente implementado. Solo faltaba que el backend NO interfiriera.

2. Fuente única de verdad

Antes: Backend y DB tenían lógicas diferentes → conflicto

  • Backend: Promoción basada en niveles (cada 5 niveles = 1 rango)
  • DB: Promoción basada en XP total (500, 1000, 1500, 2250)

Ahora: Solo DB maneja promociones → consistencia

  • Umbrales de XP configurables en tabla maya_ranks
  • Cambios de configuración NO requieren deploy de backend

3. Simplicidad y mantenibilidad

Antes: 40+ líneas de lógica compleja

- calculateXpForLevel()
- checkRankPromotion()
- Lógica de while loop
- Degradación de rank
- Cálculo de rank_progress

Ahora: 3 líneas simples

stats.total_xp += xpAmount;
return save(stats);

4. Alineación con documentación

El documento de diseño (DocumentoDeDiseño_Mecanicas_GAMILIT_v6_1.md) define:

Rango Umbral XP
Ajaw 0-499
Nacom 500-999
Ah K'in 1,000-1,499
Halach Uinic 1,500-2,249
K'uk'ulkan 2,250+

Esto se cumple CON el trigger de DB, NO con la lógica de backend.

5. Performance

Ventaja: Trigger ejecuta en SQL (más rápido que ORM)

  • No hay round-trips adicionales
  • Transacción atómica garantizada
  • Índices optimizados en total_xp

Consecuencias

Positivas

  1. Bug solucionado: XP se acumula correctamente
  2. Sistema de rangos funciona: Usuarios promocionan al alcanzar umbrales
  3. Código más simple: 3 líneas vs 40+ líneas
  4. Mantenimiento reducido: Menos lógica = menos bugs
  5. Fuente única de verdad: DB es canonical source para rangos
  6. Configuración dinámica: Cambios en maya_ranks sin deploy

Negativas/Trade-offs ⚠️

  1. Campo level deprecado: Ya no se actualiza

    • Impacto: Si frontend usa level, verá valores obsoletos
    • Mitigación: Decidir si eliminar o reimplementar sistema de niveles
  2. Lógica en DB en lugar de código

    • Impacto: Desarrolladores deben conocer SQL para entender promociones
    • Mitigación: Documentación actualizada (ET-GAM-003)
  3. Testing más complejo

    • Impacto: Tests deben usar base de datos real (no mocks simples)
    • Mitigación: Usar testcontainers o DB en memoria

Desconocidas

  1. Sistema de "niveles" visual: ¿Se necesita?
    • Si sí: Implementar por separado de rangos
    • Si no: Eliminar campo level de DB

Alternativas Consideradas

Alternativa 1: Mantener lógica de backend pero corregir bug

Descripción: Corregir la resta de XP pero mantener lógica de promoción en backend

Pros:

  • Lógica en código TypeScript (más familiar)
  • Tests unitarios más simples

Cons:

  • Duplicación de lógica (backend y DB)
  • Dos fuentes de verdad → conflictos
  • No alineado con documentación que especifica "trigger automático"
  • Más código = más superficie de bugs

Razón de rechazo: Va contra la arquitectura documentada

Alternativa 2: Eliminar trigger y solo usar backend

Descripción: Desactivar trigger de DB y manejar todo en NestJS

Pros:

  • Todo en un lugar (backend)
  • Más control desde código

Cons:

  • Requiere reescribir toda la lógica de promoción
  • Pérdida de atomicidad de transacciones
  • No aprovecha capacidades de DB (triggers, constraints)
  • Viola arquitectura diseñada (especificación dice "trigger automático")

Razón de rechazo: Arquitectura ya diseñada con triggers (ET-GAM-003)

Alternativa 3: Sistema híbrido

Descripción: Backend calcula, DB valida

Pros:

  • Validación redundante

Cons:

  • Complejidad máxima
  • Dos puntos de falla
  • Overhead de performance

Razón de rechazo: Over-engineering innecesario


Implementación

Archivos Modificados

Backend:

apps/backend/src/modules/gamification/services/user-stats.service.ts
  - addXp() simplificado (líneas 138-151)
  - checkRankPromotion() marcado @deprecated (línea 161)

Documentación:

docs/01-fase-alcance-inicial/EAI-003-gamificacion/especificaciones/ET-GAM-003-rangos-maya.md
  - Sección "FIX IMPLEMENTADO - 2025-11-24" agregada
  - Historial de cambios actualizado (versión 1.2)

Reportes:

orchestration/agentes/architecture-analyst/analisis-sistema-xp-rangos-2025-11-24/
  - REPORTE-BUG-XP-NO-ACUMULA.md (reporte técnico completo)

Código Específico

Antes:

async addXp(userId: string, xpAmount: number): Promise<UserStats> {
  const stats = await this.findByUserId(userId);
  stats.total_xp += xpAmount;

  while (stats.total_xp >= stats.xp_to_next_level) {
    stats.total_xp -= stats.xp_to_next_level;  // ❌
    stats.level += 1;
    stats.xp_to_next_level = this.calculateXpForLevel(stats.level);
    await this.checkRankPromotion(stats);
  }

  return await this.userStatsRepo.save(stats);
}

Después:

async addXp(userId: string, xpAmount: number): Promise<UserStats> {
  const stats = await this.findByUserId(userId);
  stats.total_xp += xpAmount;
  return await this.userStatsRepo.save(stats);
}

Validación

Criterios de Éxito

  • Código compila sin errores
  • Tests existentes pasan
  • XP se acumula correctamente (total_xp aumenta)
  • Usuario con 500 XP promociona a Nacom (pendiente testing manual)
  • Trigger crea achievement automáticamente (pendiente testing manual)
  • Notificación rank_up se envía (pendiente testing manual)

Pruebas Requeridas

1. Test unitario de addXp():

it('should accumulate XP correctly', async () => {
  const user = await createUser({ total_xp: 450 });
  await userStatsService.addXp(user.id, 100);
  const updated = await getUserStats(user.id);
  expect(updated.total_xp).toBe(550);  // ✅ Acumulado
});

2. Test de integración con trigger:

it('should promote to Nacom at 500 XP', async () => {
  const user = await createUser({ total_xp: 450, current_rank: 'Ajaw' });
  await userStatsService.addXp(user.id, 100);
  const updated = await getUserStats(user.id);
  expect(updated.total_xp).toBe(550);
  expect(updated.current_rank).toBe('Nacom');  // ✅ Promocionado por trigger
});

3. Test de ML Coins bonus:

it('should award 100 ML Coins on Nacom promotion', async () => {
  const user = await createUser({ total_xp: 450, ml_coins: 100 });
  await userStatsService.addXp(user.id, 100);
  const updated = await getUserStats(user.id);
  expect(updated.ml_coins).toBe(200);  // ✅ 100 + 100 bonus
});

Referencias

Documentación

Código DDL

  • apps/database/ddl/schemas/gamification_system/triggers/trg_check_rank_promotion_on_xp_gain.sql
  • apps/database/ddl/schemas/gamification_system/functions/check_rank_promotion.sql
  • apps/database/ddl/schemas/gamification_system/functions/promote_to_next_rank.sql
  • apps/database/seeds/dev/gamification_system/03-maya_ranks.sql

Issues Relacionados

  • Bug reportado por usuario: "Completé 2 módulos pero no subo de rango"
  • GAP-001: Backend resta XP (crítico)
  • GAP-002: Lógica de rango duplicada (alto)
  • GAP-003: Campo level no documentado (medio)

Decisiones Futuras

1. Campo level en user_stats

Opciones:

  • A) Eliminar completamente: Remover de DB schema y entity
  • B) Reimplementar correctamente: Como sistema visual separado de rangos
  • C) Dejar deprecated: Mantener pero no usar

Recomendación: Opción A (eliminar) si no se usa en frontend Decisión: Pendiente de análisis de uso en frontend

2. Sistema de "niveles" visual

¿Se necesita sistema de niveles adicional a rangos?

Si SÍ:

  • Implementar como campo separado visual_level
  • Usar para progresión visual en UI
  • NO vincular a promociones de rango
  • Documentar diferencia: levelrank

Si NO:

  • Eliminar referencias a niveles
  • Actualizar frontend para usar solo rangos
  • Simplificar modelo de datos

Decisión: Pendiente de feedback de product owner


Métricas de Éxito

Antes del fix:

  • 0% de usuarios promocionados correctamente
  • 100% de usuarios bloqueados en Ajaw

Después del fix (esperado):

  • 100% de usuarios con ≥500 XP en Nacom
  • 100% de usuarios con ≥1,000 XP en Ah K'in
  • 0 errores en logs de trigger

Monitorear:

-- Distribución de rangos (debería ser piramidal)
SELECT current_rank, COUNT(*) as users
FROM gamification_system.user_stats
GROUP BY current_rank
ORDER BY
  CASE current_rank
    WHEN 'Ajaw' THEN 1
    WHEN 'Nacom' THEN 2
    WHEN 'Ah K''in' THEN 3
    WHEN 'Halach Uinic' THEN 4
    WHEN 'K''uk''ulkan' THEN 5
  END;

Estado: Aceptado e Implementado Próxima revisión: Después de testing manual en dev Aprobado por: Architecture-Analyst Fecha de aprobación: 2025-11-24