--- id: "ET-EDU-006" title: "Gamification System" type: "Specification" status: "Done" rf_parent: "RF-EDU-006" epic: "OQI-002" version: "1.0" created_date: "2025-12-05" updated_date: "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 ```typescript // 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 ```typescript // services/gamification/xp-manager.service.ts export class XPManagerService { /** * Otorga XP al usuario */ async awardXP( userId: string, amount: number, source: string, metadata?: any ): Promise { // 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 { 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 { await db.user_activity_log.create({ user_id: userId, ...activity, timestamp: new Date() }); } private async getUserProfile(userId: string): Promise { 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 ```typescript // 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 ```typescript // 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 { 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 { 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 { 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 { return await db.user_achievements.findMany({ where: { user_id: userId }, orderBy: { earned_at: 'desc' } }); } /** * Obtiene achievements disponibles para desbloquear */ async getAvailableAchievements(userId: string): Promise { 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 { 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 { return await db.enrollments.count({ where: { user_id: userId, status: 'completed' } }); } private async getPerfectQuizzesCount(userId: string): Promise { return await db.quiz_attempts.count({ where: { user_id: userId, is_completed: true, score_percentage: 100 } }); } private async getUserProfile(userId: string): Promise { return await db.user_gamification_profile.findUniqueOrThrow({ where: { user_id: userId } }); } private async sendAchievementNotification( userId: string, achievement: Achievement ): Promise { // 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 ```typescript // services/gamification/streak-manager.service.ts export class StreakManagerService { /** * Actualiza la racha del usuario */ async updateStreak(userId: string): Promise { 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 { 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 { 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 { 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 { 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 ```typescript // 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 { // 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 { 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 { 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 { 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 { // TODO: Implementar con Redis return null; } private async setCached(key: string, value: any, ttl: number): Promise { // 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 ```bash # 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 ```json { "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 ```typescript 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**