🎓 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 ✅
This commit is contained in:
796
backend/src/modules/ranking/calculators/badge.awarder.ts
Normal file
796
backend/src/modules/ranking/calculators/badge.awarder.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user