trading-platform/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-006-gamification.md
rckrdmrd a7cca885f0 feat: Major platform documentation and architecture updates
Changes include:
- Updated architecture documentation
- Enhanced module definitions (OQI-001 to OQI-008)
- ML integration documentation updates
- Trading strategies documentation
- Orchestration and inventory updates
- Docker configuration updates

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:33:35 -06:00

34 KiB

id title type status rf_parent epic version created_date updated_date
ET-EDU-006 Gamification System Specification Done RF-EDU-006 OQI-002 1.0 2025-12-05 2026-01-04

ET-EDU-006: Sistema de Gamificación

Versión: 1.0.0 Fecha: 2025-12-05 Épica: OQI-002 - Módulo Educativo Componente: Backend/Frontend


Descripción

Define el sistema completo de gamificación del módulo educativo, incluyendo sistema de XP, niveles, badges/achievements, rachas (streaks), leaderboards y recompensas. El objetivo es aumentar el engagement y motivación del usuario mediante mecánicas de juego.


Arquitectura

┌─────────────────────────────────────────────────────────────────┐
│              Gamification System Architecture                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    User Actions                          │  │
│  │  - Complete lesson                                       │  │
│  │  - Pass quiz                                             │  │
│  │  │  - Complete course                                     │  │
│  │  - Daily login                                           │  │
│  │  - Perfect quiz score                                    │  │
│  └──────────────────┬───────────────────────────────────────┘  │
│                     │                                           │
│                     v                                           │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │              Gamification Engine                         │  │
│  │                                                          │  │
│  │  ┌─────────────┐  ┌──────────────┐  ┌──────────────┐   │  │
│  │  │ XP Manager  │  │Achievement   │  │Streak Manager│   │  │
│  │  │ - Calculate │  │Manager       │  │- Track daily │   │  │
│  │  │ - Award     │  │- Check rules │  │- Update      │   │  │
│  │  │ - Level up  │  │- Unlock      │  │- Rewards     │   │  │
│  │  └─────────────┘  └──────────────┘  └──────────────┘   │  │
│  │                                                          │  │
│  │  ┌─────────────┐  ┌──────────────┐                      │  │
│  │  │Leaderboard  │  │Notification  │                      │  │
│  │  │Manager      │  │Manager       │                      │  │
│  │  │- Rankings   │  │- Push alerts │                      │  │
│  │  │- Periods    │  │- Celebrations│                      │  │
│  │  └─────────────┘  └──────────────┘                      │  │
│  └──────────────────────┬───────────────────────────────────┘  │
│                         │                                       │
│                         v                                       │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                   Database                               │  │
│  │  - user_gamification_profile                             │  │
│  │  - user_achievements                                     │  │
│  │  - user_activity_log                                     │  │
│  │  - leaderboard_cache                                     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                Frontend Components                       │  │
│  │  - XPBar, LevelBadge                                     │  │
│  │  - AchievementCard, AchievementModal                     │  │
│  │  - StreakCounter, Leaderboard                            │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Especificación Detallada

1. SISTEMA DE XP (EXPERIENCE POINTS)

1.1 Fuentes de XP

// XP Sources Configuration
export const XP_SOURCES = {
  // Lessons
  LESSON_COMPLETE: 10,
  LESSON_FIRST_VIEW: 5,

  // Quizzes
  QUIZ_PASS: 50,
  QUIZ_PERFECT_SCORE: 100, // 50 base + 50 bonus
  QUIZ_RETRY_SUCCESS: 25,

  // Courses
  COURSE_COMPLETE: 500,
  COURSE_COMPLETE_FAST: 750, // Completar en menos de una semana

  // Daily Activity
  DAILY_LOGIN: 5,
  DAILY_LESSON: 15,
  DAILY_QUIZ: 30,

  // Streaks
  STREAK_7_DAYS: 100,
  STREAK_30_DAYS: 500,
  STREAK_100_DAYS: 2000,

  // Social
  COMMENT_POST: 2,
  HELPFUL_COMMENT: 10, // Cuando alguien marca como útil

  // Special Events
  WEEKEND_BONUS: 1.5, // Multiplicador
  CHALLENGE_WIN: 1000
} as const;

1.2 XP Manager Service

// services/gamification/xp-manager.service.ts

export class XPManagerService {
  /**
   * Otorga XP al usuario
   */
  async awardXP(
    userId: string,
    amount: number,
    source: string,
    metadata?: any
  ): Promise<XPAwardResult> {
    // 1. Obtener perfil actual
    const profile = await this.getUserProfile(userId);

    // 2. Calcular XP con multiplicadores
    const finalAmount = this.applyMultipliers(amount, source, profile);

    // 3. Actualizar XP total
    const newTotalXP = profile.total_xp + finalAmount;

    // 4. Calcular nivel actual y nuevo
    const currentLevel = this.calculateLevel(profile.total_xp);
    const newLevel = this.calculateLevel(newTotalXP);

    const leveledUp = newLevel > currentLevel;

    // 5. Actualizar en BD
    await db.user_gamification_profile.update(userId, {
      total_xp: newTotalXP,
      current_level: newLevel,
      updated_at: new Date()
    });

    // 6. Registrar en activity log
    await this.logActivity(userId, {
      type: 'xp_earned',
      source,
      amount: finalAmount,
      metadata
    });

    // 7. Si subió de nivel, crear logro
    if (leveledUp) {
      await this.createLevelUpAchievement(userId, newLevel);
    }

    return {
      xp_earned: finalAmount,
      total_xp: newTotalXP,
      previous_level: currentLevel,
      new_level: newLevel,
      leveled_up: leveledUp,
      multipliers_applied: this.getAppliedMultipliers(source, profile)
    };
  }

  /**
   * Calcula nivel basado en XP total
   * Fórmula: Level = floor(sqrt(totalXP / 100))
   *
   * Esto significa:
   * - Nivel 1: 100 XP
   * - Nivel 2: 400 XP (300 más)
   * - Nivel 3: 900 XP (500 más)
   * - Nivel 4: 1600 XP (700 más)
   * - Nivel 5: 2500 XP (900 más)
   * - Nivel 10: 10,000 XP
   * - Nivel 20: 40,000 XP
   * - Nivel 50: 250,000 XP
   * - Nivel 100: 1,000,000 XP
   */
  calculateLevel(totalXP: number): number {
    return Math.floor(Math.sqrt(totalXP / 100));
  }

  /**
   * Calcula XP necesario para siguiente nivel
   */
  calculateXPForNextLevel(currentLevel: number): number {
    const nextLevel = currentLevel + 1;
    return Math.pow(nextLevel, 2) * 100;
  }

  /**
   * Calcula XP para alcanzar un nivel específico
   */
  calculateXPForLevel(level: number): number {
    return Math.pow(level, 2) * 100;
  }

  /**
   * Calcula progreso hacia el siguiente nivel
   */
  calculateLevelProgress(totalXP: number): LevelProgress {
    const currentLevel = this.calculateLevel(totalXP);
    const currentLevelXP = this.calculateXPForLevel(currentLevel);
    const nextLevelXP = this.calculateXPForLevel(currentLevel + 1);

    const xpIntoCurrentLevel = totalXP - currentLevelXP;
    const xpNeededForNextLevel = nextLevelXP - currentLevelXP;

    const progressPercentage = (xpIntoCurrentLevel / xpNeededForNextLevel) * 100;

    return {
      current_level: currentLevel,
      total_xp: totalXP,
      xp_current_level: currentLevelXP,
      xp_next_level: nextLevelXP,
      xp_into_level: xpIntoCurrentLevel,
      xp_needed: xpNeededForNextLevel - xpIntoCurrentLevel,
      progress_percentage: progressPercentage
    };
  }

  /**
   * Aplica multiplicadores de XP
   */
  private applyMultipliers(
    baseAmount: number,
    source: string,
    profile: GamificationProfile
  ): number {
    let amount = baseAmount;

    // Multiplicador de fin de semana
    if (this.isWeekend() && source.includes('LESSON')) {
      amount *= XP_SOURCES.WEEKEND_BONUS;
    }

    // Multiplicador de streak
    if (profile.current_streak_days >= 7) {
      amount *= 1.1; // +10%
    }
    if (profile.current_streak_days >= 30) {
      amount *= 1.2; // +20%
    }

    // Multiplicador de premium (futuro)
    if (profile.is_premium) {
      amount *= 1.5; // +50%
    }

    return Math.floor(amount);
  }

  private isWeekend(): boolean {
    const day = new Date().getDay();
    return day === 0 || day === 6; // Domingo o Sábado
  }

  private getAppliedMultipliers(
    source: string,
    profile: GamificationProfile
  ): string[] {
    const multipliers: string[] = [];

    if (this.isWeekend() && source.includes('LESSON')) {
      multipliers.push('weekend_bonus');
    }
    if (profile.current_streak_days >= 30) {
      multipliers.push('streak_30_days');
    } else if (profile.current_streak_days >= 7) {
      multipliers.push('streak_7_days');
    }
    if (profile.is_premium) {
      multipliers.push('premium');
    }

    return multipliers;
  }

  /**
   * Crea logro de subida de nivel
   */
  private async createLevelUpAchievement(
    userId: string,
    newLevel: number
  ): Promise<void> {
    await db.user_achievements.create({
      user_id: userId,
      achievement_type: 'level_up',
      title: `Nivel ${newLevel} Alcanzado`,
      description: `Has alcanzado el nivel ${newLevel}`,
      badge_icon_url: `/badges/level-${newLevel}.svg`,
      xp_bonus: newLevel * 10, // XP bonus por subir de nivel
      metadata: { level: newLevel },
      earned_at: new Date()
    });
  }

  private async logActivity(
    userId: string,
    activity: ActivityLog
  ): Promise<void> {
    await db.user_activity_log.create({
      user_id: userId,
      ...activity,
      timestamp: new Date()
    });
  }

  private async getUserProfile(userId: string): Promise<GamificationProfile> {
    let profile = await db.user_gamification_profile.findUnique({
      where: { user_id: userId }
    });

    // Crear perfil si no existe
    if (!profile) {
      profile = await db.user_gamification_profile.create({
        user_id: userId,
        total_xp: 0,
        current_level: 0,
        current_streak_days: 0,
        longest_streak_days: 0,
        last_activity_date: new Date(),
        is_premium: false
      });
    }

    return profile;
  }
}

interface XPAwardResult {
  xp_earned: number;
  total_xp: number;
  previous_level: number;
  new_level: number;
  leveled_up: boolean;
  multipliers_applied: string[];
}

interface LevelProgress {
  current_level: number;
  total_xp: number;
  xp_current_level: number;
  xp_next_level: number;
  xp_into_level: number;
  xp_needed: number;
  progress_percentage: number;
}

interface ActivityLog {
  type: string;
  source: string;
  amount: number;
  metadata?: any;
}

interface GamificationProfile {
  user_id: string;
  total_xp: number;
  current_level: number;
  current_streak_days: number;
  longest_streak_days: number;
  last_activity_date: Date;
  is_premium: boolean;
}

2. SISTEMA DE ACHIEVEMENTS (LOGROS/BADGES)

2.1 Tipos de Achievements

// config/achievements.config.ts

export const ACHIEVEMENTS = {
  // Course Completion
  FIRST_COURSE: {
    id: 'first_course',
    title: 'Primer Paso',
    description: 'Completa tu primer curso',
    type: 'course_completion',
    badge_icon: '/badges/first-course.svg',
    xp_bonus: 100,
    rarity: 'common'
  },

  COMPLETE_5_COURSES: {
    id: 'complete_5_courses',
    title: 'Estudiante Dedicado',
    description: 'Completa 5 cursos',
    type: 'course_completion',
    badge_icon: '/badges/5-courses.svg',
    xp_bonus: 500,
    rarity: 'uncommon'
  },

  COMPLETE_ALL_BEGINNER: {
    id: 'complete_all_beginner',
    title: 'Maestro Principiante',
    description: 'Completa todos los cursos para principiantes',
    type: 'course_completion',
    badge_icon: '/badges/beginner-master.svg',
    xp_bonus: 1000,
    rarity: 'rare'
  },

  // Quiz Performance
  PERFECT_QUIZ: {
    id: 'perfect_quiz',
    title: 'Perfeccionista',
    description: 'Obtén 100% en un quiz',
    type: 'quiz_perfect_score',
    badge_icon: '/badges/perfect.svg',
    xp_bonus: 50,
    rarity: 'common'
  },

  PERFECT_10_QUIZZES: {
    id: 'perfect_10_quizzes',
    title: 'Genio',
    description: 'Obtén 100% en 10 quizzes',
    type: 'quiz_perfect_score',
    badge_icon: '/badges/genius.svg',
    xp_bonus: 500,
    rarity: 'epic'
  },

  // Streaks
  STREAK_7: {
    id: 'streak_7',
    title: 'Racha Semanal',
    description: 'Mantén una racha de 7 días',
    type: 'streak_milestone',
    badge_icon: '/badges/streak-7.svg',
    xp_bonus: 100,
    rarity: 'common'
  },

  STREAK_30: {
    id: 'streak_30',
    title: 'Racha Mensual',
    description: 'Mantén una racha de 30 días',
    type: 'streak_milestone',
    badge_icon: '/badges/streak-30.svg',
    xp_bonus: 500,
    rarity: 'rare'
  },

  STREAK_100: {
    id: 'streak_100',
    title: 'Imparable',
    description: 'Mantén una racha de 100 días',
    type: 'streak_milestone',
    badge_icon: '/badges/streak-100.svg',
    xp_bonus: 2000,
    rarity: 'legendary'
  },

  // Speed
  SPEED_DEMON: {
    id: 'speed_demon',
    title: 'Demonio de Velocidad',
    description: 'Completa un curso en menos de 24 horas',
    type: 'special_event',
    badge_icon: '/badges/speed.svg',
    xp_bonus: 300,
    rarity: 'rare'
  },

  // Level Milestones
  LEVEL_10: {
    id: 'level_10',
    title: 'Nivel 10',
    description: 'Alcanza el nivel 10',
    type: 'level_up',
    badge_icon: '/badges/level-10.svg',
    xp_bonus: 200,
    rarity: 'uncommon'
  },

  LEVEL_50: {
    id: 'level_50',
    title: 'Nivel 50',
    description: 'Alcanza el nivel 50',
    type: 'level_up',
    badge_icon: '/badges/level-50.svg',
    xp_bonus: 5000,
    rarity: 'legendary'
  }
} as const;

2.2 Achievement Manager

// services/gamification/achievement-manager.service.ts

export class AchievementManagerService {
  /**
   * Verifica y otorga achievements basados en una acción
   */
  async checkAndAwardAchievements(
    userId: string,
    event: AchievementEvent
  ): Promise<Achievement[]> {
    const earnedAchievements: Achievement[] = [];

    // Obtener achievements ya ganados por el usuario
    const existingAchievements = await this.getUserAchievements(userId);
    const existingIds = existingAchievements.map(a => a.achievement_id);

    // Verificar cada regla de achievement
    for (const [key, config] of Object.entries(ACHIEVEMENTS)) {
      // Skip si ya fue ganado
      if (existingIds.includes(config.id)) continue;

      // Verificar si cumple con los criterios
      const earned = await this.checkAchievementCriteria(
        userId,
        config,
        event
      );

      if (earned) {
        const achievement = await this.awardAchievement(userId, config);
        earnedAchievements.push(achievement);
      }
    }

    return earnedAchievements;
  }

  /**
   * Verifica si se cumplen los criterios para un achievement
   */
  private async checkAchievementCriteria(
    userId: string,
    config: AchievementConfig,
    event: AchievementEvent
  ): Promise<boolean> {
    switch (config.id) {
      case 'first_course':
        return event.type === 'course_completed' &&
               await this.getCompletedCoursesCount(userId) === 1;

      case 'complete_5_courses':
        return event.type === 'course_completed' &&
               await this.getCompletedCoursesCount(userId) === 5;

      case 'perfect_quiz':
        return event.type === 'quiz_completed' &&
               event.metadata?.score_percentage === 100;

      case 'perfect_10_quizzes':
        return event.type === 'quiz_completed' &&
               event.metadata?.score_percentage === 100 &&
               await this.getPerfectQuizzesCount(userId) === 10;

      case 'streak_7':
        return event.type === 'daily_activity' &&
               event.metadata?.current_streak === 7;

      case 'streak_30':
        return event.type === 'daily_activity' &&
               event.metadata?.current_streak === 30;

      case 'streak_100':
        return event.type === 'daily_activity' &&
               event.metadata?.current_streak === 100;

      case 'speed_demon':
        if (event.type !== 'course_completed') return false;
        const enrollment = await this.getEnrollment(event.metadata?.enrollment_id);
        const duration = Date.now() - new Date(enrollment.enrolled_at).getTime();
        const hours = duration / (1000 * 60 * 60);
        return hours < 24;

      case 'level_10':
        return event.type === 'level_up' &&
               event.metadata?.new_level === 10;

      case 'level_50':
        return event.type === 'level_up' &&
               event.metadata?.new_level === 50;

      default:
        return false;
    }
  }

  /**
   * Otorga un achievement al usuario
   */
  private async awardAchievement(
    userId: string,
    config: AchievementConfig
  ): Promise<Achievement> {
    const achievement = await db.user_achievements.create({
      user_id: userId,
      achievement_id: config.id,
      achievement_type: config.type,
      title: config.title,
      description: config.description,
      badge_icon_url: config.badge_icon,
      xp_bonus: config.xp_bonus,
      rarity: config.rarity,
      earned_at: new Date()
    });

    // Otorgar XP bonus
    const xpManager = new XPManagerService();
    await xpManager.awardXP(
      userId,
      config.xp_bonus,
      `achievement_${config.id}`,
      { achievement_id: config.id }
    );

    // Enviar notificación
    await this.sendAchievementNotification(userId, achievement);

    return achievement;
  }

  /**
   * Obtiene achievements del usuario
   */
  async getUserAchievements(userId: string): Promise<Achievement[]> {
    return await db.user_achievements.findMany({
      where: { user_id: userId },
      orderBy: { earned_at: 'desc' }
    });
  }

  /**
   * Obtiene achievements disponibles para desbloquear
   */
  async getAvailableAchievements(userId: string): Promise<AvailableAchievement[]> {
    const earned = await this.getUserAchievements(userId);
    const earnedIds = earned.map(a => a.achievement_id);

    const available: AvailableAchievement[] = [];

    for (const [key, config] of Object.entries(ACHIEVEMENTS)) {
      if (earnedIds.includes(config.id)) continue;

      const progress = await this.calculateAchievementProgress(
        userId,
        config
      );

      available.push({
        ...config,
        progress_percentage: progress.percentage,
        progress_current: progress.current,
        progress_required: progress.required,
        progress_description: progress.description
      });
    }

    return available.sort((a, b) => b.progress_percentage - a.progress_percentage);
  }

  /**
   * Calcula progreso hacia un achievement
   */
  private async calculateAchievementProgress(
    userId: string,
    config: AchievementConfig
  ): Promise<AchievementProgress> {
    switch (config.id) {
      case 'first_course':
      case 'complete_5_courses': {
        const count = await this.getCompletedCoursesCount(userId);
        const required = config.id === 'first_course' ? 1 : 5;
        return {
          current: count,
          required: required,
          percentage: Math.min((count / required) * 100, 100),
          description: `${count}/${required} cursos completados`
        };
      }

      case 'perfect_10_quizzes': {
        const count = await this.getPerfectQuizzesCount(userId);
        return {
          current: count,
          required: 10,
          percentage: Math.min((count / 10) * 100, 100),
          description: `${count}/10 quizzes perfectos`
        };
      }

      case 'streak_7':
      case 'streak_30':
      case 'streak_100': {
        const profile = await this.getUserProfile(userId);
        const required = config.id === 'streak_7' ? 7 :
                        config.id === 'streak_30' ? 30 : 100;
        return {
          current: profile.current_streak_days,
          required: required,
          percentage: Math.min((profile.current_streak_days / required) * 100, 100),
          description: `${profile.current_streak_days}/${required} días de racha`
        };
      }

      default:
        return {
          current: 0,
          required: 1,
          percentage: 0,
          description: 'Progreso no disponible'
        };
    }
  }

  private async getCompletedCoursesCount(userId: string): Promise<number> {
    return await db.enrollments.count({
      where: {
        user_id: userId,
        status: 'completed'
      }
    });
  }

  private async getPerfectQuizzesCount(userId: string): Promise<number> {
    return await db.quiz_attempts.count({
      where: {
        user_id: userId,
        is_completed: true,
        score_percentage: 100
      }
    });
  }

  private async getUserProfile(userId: string): Promise<GamificationProfile> {
    return await db.user_gamification_profile.findUniqueOrThrow({
      where: { user_id: userId }
    });
  }

  private async sendAchievementNotification(
    userId: string,
    achievement: Achievement
  ): Promise<void> {
    // TODO: Implementar sistema de notificaciones
  }
}

interface AchievementEvent {
  type: 'course_completed' | 'quiz_completed' | 'daily_activity' | 'level_up';
  metadata?: any;
}

interface AchievementConfig {
  id: string;
  title: string;
  description: string;
  type: string;
  badge_icon: string;
  xp_bonus: number;
  rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
}

interface Achievement {
  id: string;
  user_id: string;
  achievement_id: string;
  title: string;
  description: string;
  badge_icon_url: string;
  xp_bonus: number;
  rarity: string;
  earned_at: Date;
}

interface AvailableAchievement extends AchievementConfig {
  progress_percentage: number;
  progress_current: number;
  progress_required: number;
  progress_description: string;
}

interface AchievementProgress {
  current: number;
  required: number;
  percentage: number;
  description: string;
}

3. SISTEMA DE RACHAS (STREAKS)

Streak Manager

// services/gamification/streak-manager.service.ts

export class StreakManagerService {
  /**
   * Actualiza la racha del usuario
   */
  async updateStreak(userId: string): Promise<StreakUpdate> {
    const profile = await this.getUserProfile(userId);
    const today = this.getToday();
    const lastActivity = this.getDay(profile.last_activity_date);

    let currentStreak = profile.current_streak_days;
    let longestStreak = profile.longest_streak_days;
    let streakBroken = false;
    let streakExtended = false;

    // Calcular diferencia en días
    const daysDiff = this.getDaysDifference(lastActivity, today);

    if (daysDiff === 0) {
      // Mismo día, no cambios en racha
      return {
        current_streak: currentStreak,
        longest_streak: longestStreak,
        streak_extended: false,
        streak_broken: false,
        xp_earned: 0
      };
    } else if (daysDiff === 1) {
      // Día consecutivo, extender racha
      currentStreak += 1;
      streakExtended = true;

      if (currentStreak > longestStreak) {
        longestStreak = currentStreak;
      }
    } else {
      // Racha rota
      streakBroken = true;
      currentStreak = 1; // Reiniciar racha
    }

    // Actualizar perfil
    await db.user_gamification_profile.update(userId, {
      current_streak_days: currentStreak,
      longest_streak_days: longestStreak,
      last_activity_date: new Date()
    });

    // Otorgar XP por racha
    let xpEarned = 0;
    if (streakExtended) {
      xpEarned = await this.awardStreakXP(userId, currentStreak);
    }

    // Verificar logros de racha
    await this.checkStreakAchievements(userId, currentStreak, streakExtended);

    return {
      current_streak: currentStreak,
      longest_streak: longestStreak,
      streak_extended: streakExtended,
      streak_broken: streakBroken,
      xp_earned: xpEarned
    };
  }

  /**
   * Otorga XP por mantener racha
   */
  private async awardStreakXP(
    userId: string,
    streakDays: number
  ): Promise<number> {
    const xpManager = new XPManagerService();

    // XP base por día
    let xp = XP_SOURCES.DAILY_LOGIN;

    // Bonus por milestones de racha
    if (streakDays === 7) {
      xp += XP_SOURCES.STREAK_7_DAYS;
    } else if (streakDays === 30) {
      xp += XP_SOURCES.STREAK_30_DAYS;
    } else if (streakDays === 100) {
      xp += XP_SOURCES.STREAK_100_DAYS;
    }

    await xpManager.awardXP(
      userId,
      xp,
      'daily_streak',
      { streak_days: streakDays }
    );

    return xp;
  }

  /**
   * Verifica achievements de racha
   */
  private async checkStreakAchievements(
    userId: string,
    streakDays: number,
    streakExtended: boolean
  ): Promise<void> {
    if (!streakExtended) return;

    const achievementManager = new AchievementManagerService();

    await achievementManager.checkAndAwardAchievements(userId, {
      type: 'daily_activity',
      metadata: { current_streak: streakDays }
    });
  }

  /**
   * Obtiene estadísticas de racha del usuario
   */
  async getStreakStats(userId: string): Promise<StreakStats> {
    const profile = await this.getUserProfile(userId);

    return {
      current_streak: profile.current_streak_days,
      longest_streak: profile.longest_streak_days,
      last_activity: profile.last_activity_date,
      next_milestone: this.getNextMilestone(profile.current_streak_days),
      days_to_milestone: this.getDaysToMilestone(profile.current_streak_days)
    };
  }

  private getNextMilestone(currentStreak: number): number {
    const milestones = [7, 30, 100, 365];
    return milestones.find(m => m > currentStreak) || milestones[milestones.length - 1];
  }

  private getDaysToMilestone(currentStreak: number): number {
    const nextMilestone = this.getNextMilestone(currentStreak);
    return nextMilestone - currentStreak;
  }

  private getToday(): string {
    return new Date().toISOString().split('T')[0];
  }

  private getDay(date: Date): string {
    return new Date(date).toISOString().split('T')[0];
  }

  private getDaysDifference(date1: string, date2: string): number {
    const d1 = new Date(date1);
    const d2 = new Date(date2);
    const diffTime = Math.abs(d2.getTime() - d1.getTime());
    const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
    return diffDays;
  }

  private async getUserProfile(userId: string): Promise<GamificationProfile> {
    return await db.user_gamification_profile.findUniqueOrThrow({
      where: { user_id: userId }
    });
  }
}

interface StreakUpdate {
  current_streak: number;
  longest_streak: number;
  streak_extended: boolean;
  streak_broken: boolean;
  xp_earned: number;
}

interface StreakStats {
  current_streak: number;
  longest_streak: number;
  last_activity: Date;
  next_milestone: number;
  days_to_milestone: number;
}

4. LEADERBOARD (TABLA DE CLASIFICACIÓN)

Leaderboard Manager

// services/gamification/leaderboard-manager.service.ts

export class LeaderboardManagerService {
  /**
   * Obtiene el leaderboard global
   */
  async getGlobalLeaderboard(
    period: 'all_time' | 'month' | 'week' = 'all_time',
    limit: number = 100
  ): Promise<LeaderboardEntry[]> {
    // Usar cache de Redis si está disponible
    const cacheKey = `leaderboard:${period}:${limit}`;
    const cached = await this.getCached(cacheKey);

    if (cached) {
      return cached;
    }

    // Calcular leaderboard
    const leaderboard = await this.calculateLeaderboard(period, limit);

    // Cachear por 5 minutos
    await this.setCached(cacheKey, leaderboard, 300);

    return leaderboard;
  }

  /**
   * Obtiene posición del usuario en el leaderboard
   */
  async getUserPosition(
    userId: string,
    period: 'all_time' | 'month' | 'week' = 'all_time'
  ): Promise<UserLeaderboardPosition> {
    const leaderboard = await this.getGlobalLeaderboard(period, 10000);
    const position = leaderboard.findIndex(entry => entry.user_id === userId);

    if (position === -1) {
      return {
        rank: null,
        total_users: leaderboard.length,
        percentile: 100
      };
    }

    const percentile = ((leaderboard.length - position) / leaderboard.length) * 100;

    return {
      rank: position + 1,
      total_users: leaderboard.length,
      percentile: Math.round(percentile)
    };
  }

  /**
   * Obtiene usuarios cercanos en el ranking
   */
  async getNearbyUsers(
    userId: string,
    radius: number = 5
  ): Promise<LeaderboardEntry[]> {
    const leaderboard = await this.getGlobalLeaderboard('all_time', 10000);
    const userIndex = leaderboard.findIndex(entry => entry.user_id === userId);

    if (userIndex === -1) return [];

    const start = Math.max(0, userIndex - radius);
    const end = Math.min(leaderboard.length, userIndex + radius + 1);

    return leaderboard.slice(start, end);
  }

  /**
   * Calcula el leaderboard
   */
  private async calculateLeaderboard(
    period: string,
    limit: number
  ): Promise<LeaderboardEntry[]> {
    let dateFilter = {};

    if (period === 'week') {
      const weekAgo = new Date();
      weekAgo.setDate(weekAgo.getDate() - 7);
      dateFilter = { gte: weekAgo };
    } else if (period === 'month') {
      const monthAgo = new Date();
      monthAgo.setMonth(monthAgo.getMonth() - 1);
      dateFilter = { gte: monthAgo };
    }

    // Query optimizado con aggregation
    const results = await db.user_gamification_profile.findMany({
      where: period !== 'all_time' ? {
        last_activity_date: dateFilter
      } : undefined,
      orderBy: {
        total_xp: 'desc'
      },
      take: limit,
      include: {
        user: {
          select: {
            id: true,
            name: true,
            avatar_url: true
          }
        },
        _count: {
          select: {
            enrollments: {
              where: { status: 'completed' }
            },
            achievements: true
          }
        }
      }
    });

    return results.map((result, index) => ({
      rank: index + 1,
      user_id: result.user_id,
      user_name: result.user.name,
      avatar_url: result.user.avatar_url,
      total_xp: result.total_xp,
      current_level: result.current_level,
      courses_completed: result._count.enrollments,
      achievements_count: result._count.achievements,
      current_streak: result.current_streak_days
    }));
  }

  private async getCached(key: string): Promise<any> {
    // TODO: Implementar con Redis
    return null;
  }

  private async setCached(key: string, value: any, ttl: number): Promise<void> {
    // TODO: Implementar con Redis
  }
}

interface LeaderboardEntry {
  rank: number;
  user_id: string;
  user_name: string;
  avatar_url: string | null;
  total_xp: number;
  current_level: number;
  courses_completed: number;
  achievements_count: number;
  current_streak: number;
}

interface UserLeaderboardPosition {
  rank: number | null;
  total_users: number;
  percentile: number;
}

Interfaces/Tipos

Ver secciones anteriores (incluidas en el código)


Configuración

# Gamification
GAMIFICATION_ENABLED=true
GAMIFICATION_XP_MULTIPLIER=1.0
GAMIFICATION_WEEKEND_BONUS=1.5
GAMIFICATION_PREMIUM_BONUS=1.5

# Leaderboard
LEADERBOARD_CACHE_TTL=300
LEADERBOARD_MAX_SIZE=1000

# Notifications
NOTIFY_LEVEL_UP=true
NOTIFY_ACHIEVEMENT=true
NOTIFY_STREAK_MILESTONE=true

Dependencias

{
  "dependencies": {
    "redis": "^4.6.12"
  }
}

Consideraciones de Seguridad

  1. Validar todas las fuentes de XP en el backend
  2. Prevenir gaming del sistema (anti-cheat)
  3. Rate limiting en endpoints de gamificación
  4. Cachear leaderboards para prevenir queries costosos

Testing

describe('XPManagerService', () => {
  it('should calculate correct level from XP', () => {
    const xpManager = new XPManagerService();
    expect(xpManager.calculateLevel(100)).toBe(1);
    expect(xpManager.calculateLevel(400)).toBe(2);
    expect(xpManager.calculateLevel(10000)).toBe(10);
  });

  it('should apply weekend multiplier', async () => {
    // Mock isWeekend to return true
    // Test XP award
  });
});

Fin de Especificación ET-EDU-006