/** * Badge Awarder * * Checks and awards badges to users based on their achievements. * Evaluates all badge conditions after each significant action. */ import { prisma } from '../../../shared/database/prisma.client'; import { logger } from '../../../shared/utils/logger'; import { ScoreCalculator } from './score.calculator'; import { PositionCalculator } from './position.calculator'; import { BADGE_DEFINITIONS, getBadgeByCode, getBadgesByRequirementType, } from '../definitions/badge-definitions'; // ============================================ // TYPES // ============================================ export interface BadgeAwardResult { awarded: boolean; badges: AwardedBadge[]; } export interface AwardedBadge { code: string; name: string; icon: string; rarity: string; points: number; message: string; } export interface UserBadgeProgress { code: string; name: string; description: string; category: string; rarity: string; icon: string; requirementValue: number; currentProgress: number; unlocked: boolean; unlockedAt?: Date | undefined; } // ============================================ // BADGE AWARDER // ============================================ export class BadgeAwarder { /** * Check and award badges after an exercise attempt */ static async checkAndAwardBadgesAfterExercise( userId: string, exerciseId: string, isCorrect: boolean ): Promise { const awardedBadges: AwardedBadge[] = []; // Get exercise details const exercise = await prisma.exercise.findUnique({ where: { id: exerciseId }, select: { difficulty: true, moduleId: true }, }); if (!exercise) { logger.warn({ exerciseId }, 'Exercise not found for badge checking'); return { awarded: false, badges: [] }; } // 1. Check exercise completion badges const exerciseBadges = await this.checkExerciseCompletionBadges(userId); awardedBadges.push(...exerciseBadges); // 2. Check perfect score badges if (isCorrect) { const perfectBadges = await this.checkPerfectScoreBadges(userId); awardedBadges.push(...perfectBadges); } // 3. Check streak badges const streakBadges = await this.checkStreakBadges(userId); awardedBadges.push(...streakBadges); // 4. Check special time-based badges const timeBadges = await this.checkTimeBasedBadges(userId); awardedBadges.push(...timeBadges); // 5. Check module completion badges const moduleBadges = await this.checkModuleCompletionBadges(userId, exercise.moduleId); awardedBadges.push(...moduleBadges); // 6. Check autodidact badge (no hints) const autodidactBadges = await this.checkAutodidactBadges(userId); awardedBadges.push(...autodidactBadges); // 7. Check ranking badges (after position update) await PositionCalculator.updateUserGlobalRanking(userId); const rankingBadges = await this.checkRankingBadges(userId); awardedBadges.push(...rankingBadges); // 8. Check explorer badge (all difficulties) const explorerBadges = await this.checkExplorerBadge(userId); awardedBadges.push(...explorerBadges); return { awarded: awardedBadges.length > 0, badges: awardedBadges, }; } /** * Check exercise completion badges */ private static async checkExerciseCompletionBadges(userId: string): Promise { const awarded: AwardedBadge[] = []; const badges = getBadgesByRequirementType('EXERCISES_COMPLETED'); // Get completed exercises count const completedCount = await prisma.exerciseAttempt.groupBy({ by: ['exerciseId'], where: { userId, status: 'CORRECT', }, }); for (const badge of badges) { // Skip module-specific and special badges if (['ALGEBRA_MASTER', 'MODULE_FUNDAMENTOS', 'MODULE_SISTEMAS', 'MODULE_APLICACIONES', 'PERSISTENT', 'FIRST_TRY_WARRIOR', 'EXPLORER', 'SPEED_DEMON', 'NIGHT_OWL_EXERCISES'].includes(badge.code)) { continue; } if (completedCount.length >= badge.requirementValue) { const awarded_ = await this.awardBadge(userId, badge.code); if (awarded_) awarded.push(awarded_); } } return awarded; } /** * Check perfect score badges */ private static async checkPerfectScoreBadges(userId: string): Promise { const awarded: AwardedBadge[] = []; const badges = getBadgesByRequirementType('PERFECT_SCORES'); // Get perfect exercises count const perfectCount = await prisma.exerciseAttempt.count({ where: { userId, isPerfect: true, }, }); for (const badge of badges) { if (perfectCount >= badge.requirementValue) { const awarded_ = await this.awardBadge(userId, badge.code); if (awarded_) awarded.push(awarded_); } } return awarded; } /** * Check streak badges */ private static async checkStreakBadges(userId: string): Promise { const awarded: AwardedBadge[] = []; const badges = getBadgesByRequirementType('STREAK_DAYS'); const streakInfo = await ScoreCalculator.getUserStreak(userId); const currentStreak = streakInfo.currentStreak; for (const badge of badges) { if (currentStreak >= badge.requirementValue) { const awarded_ = await this.awardBadge(userId, badge.code); if (awarded_) awarded.push(awarded_); } } return awarded; } /** * Check module completion badges */ private static async checkModuleCompletionBadges( userId: string, moduleId: string ): Promise { const awarded: AwardedBadge[] = []; // Check if module is completed const progress = await prisma.progress.findUnique({ where: { userId_moduleId: { userId, moduleId, }, }, }); if (!progress || !progress.isCompleted) { return awarded; } // Award first module badge const firstModule = await this.awardBadge(userId, 'FIRST_MODULE'); if (firstModule) awarded.push(firstModule); // Check if perfect module (100% score, all perfect) if (progress.percentage >= 100 && progress.perfectExercises >= progress.totalExercises) { const perfectModule = await this.awardBadge(userId, 'PERFECT_MODULE'); if (perfectModule) awarded.push(perfectModule); } // Get module to check specific module badges const module = await prisma.modules.findUnique({ where: { id: moduleId }, select: { type: true }, }); if (module) { // Award specific module badges const moduleBadgeMap: Record = { FUNDAMENTOS: 'MODULE_FUNDAMENTOS', SISTEMAS_ESPACIOS: 'MODULE_SISTEMAS', APLICACIONES: 'MODULE_APLICACIONES', }; const badgeCode = moduleBadgeMap[module.type]; if (badgeCode) { const moduleBadge = await this.awardBadge(userId, badgeCode); if (moduleBadge) awarded.push(moduleBadge); } } // Check for Algebra Master (all modules completed) const completedModules = await prisma.progress.count({ where: { userId, isCompleted: true, }, }); if (completedModules >= 3) { const masterBadge = await this.awardBadge(userId, 'ALGEBRA_MASTER'); if (masterBadge) awarded.push(masterBadge); } return awarded; } /** * Check ranking badges */ private static async checkRankingBadges(userId: string): Promise { const awarded: AwardedBadge[] = []; const badges = getBadgesByRequirementType('RANKING_POSITION'); // Get user's ranking position (use findFirst for nullable moduleId) const ranking = await prisma.ranking.findFirst({ where: { userId, moduleId: null, }, select: { position: true }, }); if (!ranking) { return awarded; } for (const badge of badges) { // Position must be less than or equal to requirement (lower is better) if (ranking.position <= badge.requirementValue) { const awarded_ = await this.awardBadge(userId, badge.code); if (awarded_) awarded.push(awarded_); } } return awarded; } /** * Check time-based badges (Early Bird, Night Owl) */ private static async checkTimeBasedBadges(userId: string): Promise { const awarded: AwardedBadge[] = []; // Get recent attempts from today const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const recentAttempts = await prisma.exerciseAttempt.findMany({ where: { userId, status: 'CORRECT', createdAt: { gte: today, lt: tomorrow, }, }, select: { createdAt: true }, }); // Check for Early Bird (before 6 AM) const earlyHour = 6; for (const attempt of recentAttempts) { const hour = attempt.createdAt.getHours(); if (hour < earlyHour) { const earlyBird = await this.awardBadge(userId, 'EARLY_BIRD'); if (earlyBird) awarded.push(earlyBird); break; } } // Check for Night Owl (after midnight but before 4 AM - already counted in early bird) // Actually Night Owl is for exercises done late at night, say after 10 PM or before 4 AM const lateHour = 22; // 10 PM for (const attempt of recentAttempts) { const hour = attempt.createdAt.getHours(); if (hour >= lateHour || hour < 4) { // For NIGHT_OWL_EXERCISES badge (5 exercises after midnight) const nightOwlExercises = await prisma.exerciseAttempt.count({ where: { userId, status: 'CORRECT', createdAt: { gte: today, lt: tomorrow, }, }, }); const attemptHour = attempt.createdAt.getHours(); if (attemptHour >= 0 && attemptHour < 4) { if (nightOwlExercises >= 5) { const nightOwl = await this.awardBadge(userId, 'NIGHT_OWL_EXERCISES'); if (nightOwl) awarded.push(nightOwl); } break; } } } return awarded; } /** * Check autodidact badge (exercises without hints) */ private static async checkAutodidactBadges(userId: string): Promise { const awarded: AwardedBadge[] = []; // Count exercises completed without hints const noHintExercises = await prisma.exerciseAttempt.groupBy({ by: ['exerciseId'], where: { userId, status: 'CORRECT', hintsUsed: 0, }, }); // AUTODIDACT badge requires 25 exercises without hints const autodidactBadge = getBadgeByCode('AUTODIDACT'); if (autodidactBadge && noHintExercises.length >= autodidactBadge.requirementValue) { const awarded_ = await this.awardBadge(userId, 'AUTODIDACT'); if (awarded_) awarded.push(awarded_); } return awarded; } /** * Check explorer badge (all difficulty levels tried) */ private static async checkExplorerBadge(userId: string): Promise { const awarded: AwardedBadge[] = []; // Get unique difficulties attempted const attempts = await prisma.exerciseAttempt.findMany({ where: { userId, status: 'CORRECT', }, select: { exercises: { select: { difficulty: true, }, }, }, distinct: ['exerciseId'], }); const difficulties = new Set(attempts.map((a) => a.exercises.difficulty)); // If all 4 difficulties have been tried if (difficulties.size >= 4) { const explorer = await this.awardBadge(userId, 'EXPLORER'); if (explorer) awarded.push(explorer); } return awarded; } /** * Check persistent badge (completed after retries) */ static async checkPersistentBadge(userId: string): Promise { const awarded: AwardedBadge[] = []; // Find exercises completed on 3rd or later attempt const retries = await prisma.exerciseAttempt.findMany({ where: { userId, status: 'CORRECT', attemptNumber: { gte: 3, }, }, take: 1, }); if (retries.length > 0) { const persistent = await this.awardBadge(userId, 'PERSISTENT'); if (persistent) awarded.push(persistent); } return awarded; } /** * Check first try warrior badge */ static async checkFirstTryWarriorBadge(userId: string): Promise { const awarded: AwardedBadge[] = []; // Count exercises completed on first try const firstTries = await prisma.exerciseAttempt.count({ where: { userId, status: 'CORRECT', attemptNumber: 1, }, }); if (firstTries >= 50) { const warrior = await this.awardBadge(userId, 'FIRST_TRY_WARRIOR'); if (warrior) awarded.push(warrior); } return awarded; } /** * Check speed demon badge */ static async checkSpeedDemonBadge(userId: string): Promise { const awarded: AwardedBadge[] = []; // Count exercises completed in under 60 seconds const fastExercises = await prisma.exerciseAttempt.count({ where: { userId, status: 'CORRECT', timeSpentSeconds: { lt: 60, }, }, }); if (fastExercises >= 10) { const speedDemon = await this.awardBadge(userId, 'SPEED_DEMON'); if (speedDemon) awarded.push(speedDemon); } return awarded; } /** * Award a badge to a user (if not already awarded) */ private static async awardBadge( userId: string, badgeCode: string ): Promise { const badge = getBadgeByCode(badgeCode); if (!badge) { logger.warn({ badgeCode }, 'Badge not found'); return null; } // Check if already unlocked const existing = await prisma.userAchievement.findUnique({ where: { userId_achievementId: { userId, achievementId: badge.code, }, }, }); if (existing && existing.unlockedAt) { return null; // Already unlocked } // Get or create achievement let achievement = await prisma.achievement.findUnique({ where: { id: badge.code }, }); if (!achievement) { achievement = await prisma.achievement.create({ data: { id: badge.code, code: badge.code, name: badge.name, description: badge.description, category: badge.category, rarity: badge.rarity, icon: badge.icon, requirementType: badge.requirementType, requirementValue: badge.requirementValue, points: badge.points, ...(badge.metadata ? { metadata: badge.metadata } : {}), }, }); } // Unlock achievement await prisma.userAchievement.upsert({ where: { userId_achievementId: { userId, achievementId: badge.code, }, }, create: { userId, achievementId: badge.code, progress: badge.requirementValue, unlockedAt: new Date(), }, update: { progress: badge.requirementValue, unlockedAt: existing?.unlockedAt || new Date(), }, }); logger.info({ userId, badgeCode, badgeName: badge.name, }, 'Badge awarded'); return { code: badge.code, name: badge.name, icon: badge.icon, rarity: badge.rarity, points: badge.points, message: badge.metadata?.unlockMessage || `¡Has desbloqueado "${badge.name}"!`, }; } /** * Get all badges with user progress */ static async getUserBadges(userId: string): Promise { // Get all badge definitions const results: UserBadgeProgress[] = []; for (const badge of BADGE_DEFINITIONS) { // Get user achievement const userAchievement = await prisma.userAchievement.findUnique({ where: { userId_achievementId: { userId, achievementId: badge.code, }, }, }); // Calculate current progress const currentProgress = await this.calculateBadgeProgress(userId, badge); results.push({ code: badge.code, name: badge.name, description: badge.description, category: badge.category, rarity: badge.rarity, icon: badge.icon, requirementValue: badge.requirementValue, currentProgress, unlocked: userAchievement?.unlockedAt !== null || false, unlockedAt: userAchievement?.unlockedAt || undefined, }); } return results; } /** * Calculate current progress for a badge */ private static async calculateBadgeProgress( userId: string, badge: typeof BADGE_DEFINITIONS[0] ): Promise { switch (badge.requirementType) { case 'EXERCISES_COMPLETED': // General exercise count if (['PERSISTENT', 'FIRST_TRY_WARRIOR', 'EXPLORER', 'SPEED_DEMON', 'NIGHT_OWL_EXERCISES'].includes(badge.code)) { switch (badge.code) { case 'PERSISTENT': return await prisma.exerciseAttempt.count({ where: { userId, status: 'CORRECT', attemptNumber: { gte: 3 } }, }); case 'FIRST_TRY_WARRIOR': return await prisma.exerciseAttempt.count({ where: { userId, status: 'CORRECT', attemptNumber: 1 }, }); case 'SPEED_DEMON': return await prisma.exerciseAttempt.count({ where: { userId, status: 'CORRECT', timeSpentSeconds: { lt: 60 } }, }); case 'NIGHT_OWL_EXERCISES': const nightAttempts = await prisma.exerciseAttempt.findMany({ where: { userId, status: 'CORRECT' }, select: { createdAt: true }, }); return nightAttempts.filter((a) => a.createdAt.getHours() < 4).length; } } // Standard exercise count const completed = await prisma.exerciseAttempt.groupBy({ by: ['exerciseId'], where: { userId, status: 'CORRECT' }, }); return completed.length; case 'MODULES_COMPLETED': return await prisma.progress.count({ where: { userId, isCompleted: true }, }); case 'PERFECT_SCORES': return await prisma.exerciseAttempt.count({ where: { userId, isPerfect: true }, }); case 'STREAK_DAYS': const streakInfo = await ScoreCalculator.getUserStreak(userId); return streakInfo.currentStreak; case 'RANKING_POSITION': const ranking = await prisma.ranking.findFirst({ where: { userId, moduleId: null }, }); // For ranking, we invert: position 1 = 100% progress if (!ranking) return 0; // For top 100 badge, if position is 50, progress is 100 (50 <= 100) if (badge.requirementValue === 100) return ranking.position <= 100 ? 100 : 0; if (badge.requirementValue === 50) return ranking.position <= 50 ? 50 : 0; if (badge.requirementValue === 10) return ranking.position <= 10 ? 10 : 0; if (badge.requirementValue === 3) return ranking.position <= 3 ? 3 : 0; if (badge.requirementValue === 1) return ranking.position === 1 ? 1 : 0; return ranking.position <= badge.requirementValue ? 1 : 0; case 'EXERCISES_WITHOUT_HINTS': const noHints = await prisma.exerciseAttempt.groupBy({ by: ['exerciseId'], where: { userId, status: 'CORRECT', hintsUsed: 0 }, }); return noHints.length; case 'EARLY_BIRD': const earlyAttempts = await prisma.exerciseAttempt.findMany({ where: { userId, status: 'CORRECT' }, select: { createdAt: true }, }); for (const a of earlyAttempts) { if (a.createdAt.getHours() < 6) return 1; } return 0; case 'NIGHT_OWL': // Same as NIGHT_OWL_EXERCISES logic return 0; // Handled separately case 'PERFECT_MODULE': // Check if any module has 100% with all perfect const perfectModules = await prisma.progress.findMany({ where: { userId, isCompleted: true, percentage: { gte: 100 }, }, }); for (const p of perfectModules) { if (p.perfectExercises >= p.totalExercises && p.totalExercises > 0) { return 1; } } return 0; default: return 0; } } /** * Get unlocked badges for a user */ static async getUnlockedBadges(userId: string): Promise { const allBadges = await this.getUserBadges(userId); return allBadges.filter((b) => b.unlocked); } /** * Get badge progress for a specific badge */ static async getBadgeProgress(userId: string, badgeCode: string): Promise { const badge = getBadgeByCode(badgeCode); if (!badge) return null; const userAchievement = await prisma.userAchievement.findUnique({ where: { userId_achievementId: { userId, achievementId: badge.code, }, }, }); const currentProgress = await this.calculateBadgeProgress(userId, badge); return { code: badge.code, name: badge.name, description: badge.description, category: badge.category, rarity: badge.rarity, icon: badge.icon, requirementValue: badge.requirementValue, currentProgress, unlocked: userAchievement?.unlockedAt !== null || false, unlockedAt: userAchievement?.unlockedAt || undefined, }; } /** * Initialize all badges in the database */ static async initializeBadges(): Promise { logger.info('Initializing badges in database'); for (const badge of BADGE_DEFINITIONS) { await prisma.achievement.upsert({ where: { id: badge.code }, create: { id: badge.code, code: badge.code, name: badge.name, description: badge.description, category: badge.category, rarity: badge.rarity, icon: badge.icon, requirementType: badge.requirementType, requirementValue: badge.requirementValue, points: badge.points, ...(badge.metadata ? { metadata: badge.metadata } : {}), }, update: {}, }); } logger.info({ count: BADGE_DEFINITIONS.length }, 'Badges initialized'); } } export default BadgeAwarder;