workspace-v1/orchestration/analisis/REFINAMIENTO-PLAN-DUPLICADOS-2026-01-10.md
rckrdmrd e56e927a4d [MAINT-001] docs(orchestration): Actualizacion directivas SIMCO, perfiles y documentacion
Cambios incluidos:
- INDICE-DIRECTIVAS-WORKSPACE.yml actualizado
- Perfiles de agentes: PERFIL-ML.md, PERFIL-SECURITY.md
- Directivas SIMCO actualizadas:
  - SIMCO-ASIGNACION-PERFILES.md
  - SIMCO-CCA-SUBAGENTE.md
  - SIMCO-CONTEXT-ENGINEERING.md
  - SIMCO-CONTEXT-RESOLUTION.md
  - SIMCO-DELEGACION-PARALELA.md
- Inventarios actualizados: DEVENV-MASTER, DEVENV-PORTS
- Documentos de analisis agregados:
  - Analisis y planes de fix student portal
  - Analisis scripts BD
  - Analisis achievements, duplicados, gamification
  - Auditoria documentacion gamilit
  - Backlog discrepancias NEXUS
  - Planes maestros de resolucion
- Reportes de ejecucion agregados
- Knowledge base gamilit README actualizado
- Referencia submodulo gamilit actualizada (commit beb94f7)

Validaciones:
- Plan validado contra directivas SIMCO-GIT
- Dependencias verificadas
- Build gamilit: EXITOSO
2026-01-10 04:51:28 -06:00

11 KiB

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)

-- ========== 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

-- ========== 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

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)

-- 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

-- ========== 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)

async claimRewards(userId: string, achievementId: string): Promise<UserAchievement> {
  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

/**
 * 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)

import { getUserAchievements } from '../api/achievementsAPI';

Código DESPUÉS

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)

/**
 * @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

# 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)