# FASE 5: REFINAMIENTO DEL PLAN - CAMBIOS ESPECÍFICOS **Fecha:** 2026-01-10 **Proyecto:** Gamilit **Estado:** REFINAMIENTO COMPLETADO --- ## 1. RESUMEN DE CAMBIOS ### Archivos a Modificar | # | Archivo | Tipo | Cambio Principal | |---|---------|------|------------------| | 1 | `01-trg_achievement_unlocked.sql` | SQL Trigger | Remover distribución rewards | | 2 | `check_and_award_achievements.sql` | SQL Function | Remover distribución rewards | | 3 | `achievements.service.ts` | Backend | Llamar SQL function en claimRewards | | 4 | `achievementsStore.ts` | Frontend | Migrar a gamificationApi | | 5 | `/hooks/useAchievements.ts` | Frontend | Agregar @deprecated | --- ## 2. CAMBIO A.1: Trigger fn_on_achievement_unlocked **Archivo:** `/apps/database/ddl/schemas/gamification_system/triggers/01-trg_achievement_unlocked.sql` **Objetivo:** Remover distribución de XP y ML Coins (secciones 1 y 2), mantener SOLO notificación (sección 3) ### Código ANTES (Líneas 37-104) ```sql -- ========== 1. Otorgar XP (si hay) ========== IF v_xp_reward > 0 THEN UPDATE gamification_system.user_stats SET total_xp = total_xp + v_xp_reward, updated_at = CURRENT_TIMESTAMP WHERE user_id = NEW.user_id; -- ... más código END IF; -- ========== 2. Otorgar ML Coins (si hay) ========== IF v_coins_reward > 0 THEN -- ... código de distribución de coins -- ... INSERT INTO ml_coins_transactions END IF; ``` ### Código DESPUÉS ```sql -- ========== 1. REMOVIDO: XP se otorga en claim_achievement_reward ========== -- Comentario: Modelo Claim-to-Earn - Rewards solo al reclamar -- IF v_xp_reward > 0 THEN ... END IF; -- ========== 2. REMOVIDO: ML Coins se otorgan en claim_achievement_reward ========== -- Comentario: Modelo Claim-to-Earn - Rewards solo al reclamar -- IF v_coins_reward > 0 THEN ... END IF; ``` ### Función Completa Refinada ```sql CREATE OR REPLACE FUNCTION gamification_system.fn_on_achievement_unlocked() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE v_achievement RECORD; v_xp_reward INTEGER; v_coins_reward INTEGER; v_notification_id UUID; BEGIN -- Solo ejecutar cuando se completa un achievement IF NEW.is_completed = true AND (OLD IS NULL OR OLD.is_completed = false) THEN -- Obtener datos del achievement SELECT id, name, description, rewards INTO v_achievement FROM gamification_system.achievements WHERE id = NEW.achievement_id; IF FOUND THEN -- Extraer recompensas para mostrar en notificación (NO se otorgan aquí) v_xp_reward := COALESCE((v_achievement.rewards->>'xp')::INTEGER, 0); v_coins_reward := COALESCE((v_achievement.rewards->>'ml_coins')::INTEGER, 0); -- ========== MODELO CLAIM-TO-EARN ========== -- NOTA: XP y ML Coins NO se otorgan aquí. -- Se otorgan ÚNICAMENTE al reclamar via claim_achievement_reward() -- Esto evita triple distribución de rewards -- ========== Crear Notificación (Sistema Multi-Canal) ========== INSERT INTO notifications.notifications ( user_id, type, title, message, data, priority, channels, status, metadata ) VALUES ( NEW.user_id, 'achievement', '🏆 ¡Achievement Desbloqueado!', format('Has desbloqueado: %s - ¡Reclama tus recompensas!', v_achievement.name), jsonb_build_object( 'achievement_id', v_achievement.id, 'achievement_name', v_achievement.name, 'xp_reward', v_xp_reward, 'coins_reward', v_coins_reward, 'claim_required', true -- Nuevo campo para indicar que debe reclamar ), 'high', ARRAY['in_app']::varchar[], 'sent', jsonb_build_object( 'icon', '🏆', 'action_url', format('/achievements?claim=%s', v_achievement.id), 'related_entity_type', 'achievement', 'related_entity_id', v_achievement.id ) ) RETURNING id INTO v_notification_id; -- Marcar notificación como enviada UPDATE gamification_system.user_achievements SET notified = true, metadata = metadata || jsonb_build_object('notification_id', v_notification_id) WHERE id = NEW.id; RAISE NOTICE 'Achievement unlocked (pending claim): user_id=%, achievement_id=%, pending_xp=%, pending_coins=%', NEW.user_id, v_achievement.id, v_xp_reward, v_coins_reward; END IF; END IF; RETURN NEW; END; $$; ``` --- ## 3. CAMBIO A.2: check_and_award_achievements.sql **Archivo:** `/apps/database/ddl/schemas/gamification_system/functions/check_and_award_achievements.sql` **Objetivo:** Remover distribución de XP y ML Coins, mantener solo INSERT en user_achievements ### Código ANTES (Líneas 102-132) ```sql -- Obtener balance actual ANTES de actualizar (con row lock) SELECT ml_coins INTO v_current_balance ... -- Calcular nuevo balance v_new_balance := COALESCE(v_current_balance, 0) + COALESCE(v_achievement.ml_coins_reward, 0); -- Actualizar estadisticas del usuario UPDATE gamification_system.user_stats SET total_xp = COALESCE(total_xp, 0) + v_xp_reward, ml_coins = v_new_balance, achievements_earned = COALESCE(achievements_earned, 0) + 1, updated_at = NOW() WHERE user_id = p_user_id; -- Registrar transaccion de coins IF COALESCE(v_achievement.ml_coins_reward, 0) > 0 THEN INSERT INTO gamification_system.ml_coins_transactions (...) ... END IF; ``` ### Código DESPUÉS ```sql -- ========== MODELO CLAIM-TO-EARN ========== -- NOTA: XP y ML Coins NO se otorgan aquí. -- Se otorgan ÚNICAMENTE al reclamar via claim_achievement_reward() -- Solo incrementar contador de achievements earned UPDATE gamification_system.user_stats SET achievements_earned = COALESCE(achievements_earned, 0) + 1, updated_at = NOW() WHERE user_id = p_user_id; -- NO registrar transaccion de coins aquí - se hace en claim_achievement_reward ``` --- ## 4. CAMBIO B.1: achievements.service.ts **Archivo:** `/apps/backend/src/modules/gamification/services/achievements.service.ts` **Objetivo:** `claimRewards()` debe llamar función SQL y retornar rewards ### Código ANTES (Líneas 745-759) ```typescript async claimRewards(userId: string, achievementId: string): Promise { const userAchievement = await this.checkProgress(userId, achievementId); if (!userAchievement.is_completed) { throw new BadRequestException(`Achievement ${achievementId} is not completed yet`); } if (userAchievement.rewards_claimed) { throw new BadRequestException(`Rewards already claimed for achievement ${achievementId}`); } userAchievement.rewards_claimed = true; return this.userAchievementRepo.save(userAchievement); } ``` ### Código DESPUÉS ```typescript /** * Reclama las recompensas de un achievement completado * Usa la función SQL claim_achievement_reward para distribución atómica * * @param userId - ID del usuario * @param achievementId - ID del achievement a reclamar * @returns UserAchievement actualizado con xp_granted y coins_granted */ async claimRewards(userId: string, achievementId: string): Promise<{ userAchievement: UserAchievement; xp_granted: number; coins_granted: number; }> { // Llamar función SQL que: // 1. Valida que el achievement esté completado // 2. Valida que no se haya reclamado antes // 3. Actualiza rewards_claimed = true // 4. Distribuye XP y ML Coins // 5. Registra transacción de coins const result = await this.dataSource.query( `SELECT * FROM gamification_system.claim_achievement_reward($1, $2)`, [userId, achievementId] ); const claimResult = result[0]; if (!claimResult.success) { throw new BadRequestException(claimResult.message); } // Obtener userAchievement actualizado const userAchievement = await this.checkProgress(userId, achievementId); this.logger.log( `Achievement ${achievementId} rewards claimed for user ${userId}: ` + `XP=${claimResult.xp_granted}, Coins=${claimResult.coins_granted}` ); return { userAchievement, xp_granted: claimResult.xp_granted, coins_granted: claimResult.coins_granted, }; } ``` --- ## 5. CAMBIO C.1: achievementsStore.ts **Archivo:** `/apps/frontend/src/features/gamification/social/store/achievementsStore.ts` **Objetivo:** Migrar de achievementsAPI a gamificationApi ### Código ANTES (Línea 16) ```typescript import { getUserAchievements } from '../api/achievementsAPI'; ``` ### Código DESPUÉS ```typescript import { gamificationApi } from '@/lib/api/gamification.api'; // En fetchAchievements (línea 162): // ANTES: const achievementsWithProgress = await getUserAchievements(userId); // DESPUÉS: const achievementsWithProgress = await gamificationApi.getUserAchievements(userId); ``` --- ## 6. CAMBIO C.2: /hooks/useAchievements.ts (Deprecate) **Archivo:** `/apps/frontend/src/hooks/useAchievements.ts` **Objetivo:** Agregar notice de deprecación ### Código a Agregar (Líneas 1-20) ```typescript /** * @deprecated Este hook está DEPRECADO desde 2026-01-10 * * RAZONES: * 1. Contiene 450+ líneas de achievement definitions hardcodeadas * 2. Las recompensas pueden no coincidir con la base de datos * 3. La detección de achievements se hace en el backend (detectAndGrantEarned) * * USA EN SU LUGAR: * - useAchievements de '@/features/gamification/social/hooks/useAchievements' * - gamificationApi de '@/lib/api/gamification.api' para API calls * * @see ANALISIS-DUPLICADOS-ACHIEVEMENTS-2026-01-10.md para detalles */ console.warn( '[DEPRECATED] useAchievements from /hooks is deprecated. ' + 'Use useAchievements from @/features/gamification/social/hooks instead.' ); ``` --- ## 7. ORDEN DE EJECUCIÓN FINAL ``` FASE A: SQL (Detener triple distribución) ├── A.1: Modificar fn_on_achievement_unlocked (solo notificación) └── A.2: Modificar check_and_award_achievements (solo contador) FASE B: Backend (Habilitar claim-to-earn) └── B.1: Modificar achievements.service.ts claimRewards() FASE C: Frontend (Cleanup) ├── C.1: Modificar achievementsStore.ts imports └── C.2: Deprecar /hooks/useAchievements.ts ``` --- ## 8. VALIDACIÓN POST-EJECUCIÓN ### Tests a Ejecutar ```bash # Backend tests cd apps/backend && npm run test -- --testPathPattern=achievements # Frontend tests cd apps/frontend && npm run test -- --testPathPattern=achievements ``` ### Test Manual E2E 1. Completar un achievement → Verificar NO recibe XP/Coins automático 2. Ver notificación de achievement desbloqueado 3. Click "Reclamar" → Verificar SÍ recibe XP/Coins 4. Intentar reclamar de nuevo → Verificar error "Ya reclamado" --- **Refinado por:** Claude (Arquitecto Técnico) **Fecha:** 2026-01-10 **Estado:** FASE 5 COMPLETADA - Listo para FASE 6 (Ejecución)