✨ 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 ✅
797 lines
22 KiB
TypeScript
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;
|