Files
math2-platform/backend/src/modules/ranking/calculators/badge.awarder.ts
Renato bc43c9e772
Some checks failed
Test Suite / test-backend (push) Has been cancelled
Test Suite / test-frontend (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / coverage-check (push) Has been cancelled
🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
 Características:
- 45 ejercicios universitarios (Basic → Advanced)
- Renderizado LaTeX profesional
- IA generativa (Z.ai/DashScope)
- Docker 9 servicios
- Tests 123/123 pasando
- Seguridad enterprise (JWT, XSS, Rate limiting)

🐳 Infraestructura:
- Next.js 14 + Node.js 20
- PostgreSQL 15 + Redis 7
- Docker Compose completo
- Nginx + SSL ready

📚 Documentación:
- 5 informes técnicos completos
- README profesional
- Scripts de deployment automatizados

Estado: Producción lista 
2026-03-31 11:27:11 -03:00

797 lines
22 KiB
TypeScript

/**
* 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<BadgeAwardResult> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<string, string> = {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge[]> {
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<AwardedBadge | null> {
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<UserBadgeProgress[]> {
// 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<number> {
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<UserBadgeProgress[]> {
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<UserBadgeProgress | null> {
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<void> {
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;