- 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>
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_xpengamification_system.user_statsnunca alcanza los umbrales (500, 1000, 1500, 2250) - El trigger
trg_check_rank_promotion_on_xp_gainNO 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 ✅
- Bug solucionado: XP se acumula correctamente
- Sistema de rangos funciona: Usuarios promocionan al alcanzar umbrales
- Código más simple: 3 líneas vs 40+ líneas
- Mantenimiento reducido: Menos lógica = menos bugs
- Fuente única de verdad: DB es canonical source para rangos
- Configuración dinámica: Cambios en
maya_rankssin deploy
Negativas/Trade-offs ⚠️
-
Campo
leveldeprecado: Ya no se actualiza- Impacto: Si frontend usa
level, verá valores obsoletos - Mitigación: Decidir si eliminar o reimplementar sistema de niveles
- Impacto: Si frontend usa
-
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)
-
Testing más complejo
- Impacto: Tests deben usar base de datos real (no mocks simples)
- Mitigación: Usar testcontainers o DB en memoria
Desconocidas ❓
- Sistema de "niveles" visual: ¿Se necesita?
- Si sí: Implementar por separado de rangos
- Si no: Eliminar campo
levelde 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_xpaumenta) - 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.sqlapps/database/ddl/schemas/gamification_system/functions/check_rank_promotion.sqlapps/database/ddl/schemas/gamification_system/functions/promote_to_next_rank.sqlapps/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
levelno 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:
level≠rank
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