/** * @copyright 2026 NHCarrigan * @license Naomi's Public License * @author Naomi Carrigan */ import type { FastifyRequest } from "fastify"; import { ACHIEVEMENTS, ACHIEVEMENT_LIST, AchievementCategory, AchievementDefinition, AchievementProgress, AuditAction, AuditCategory, UserAchievementSummary, } from "@library/shared-types"; import { prisma } from "../lib/prisma"; import { AuditService } from "./audit.service"; export class AchievementService { /** * Check and award achievements for a user after an action. * Returns list of newly earned achievements. */ async checkAchievements( userId: string, category: AchievementCategory, req: FastifyRequest, ): Promise { const relevantAchievements = ACHIEVEMENT_LIST.filter( (ach) => ach.category === category, ); const newlyEarned: AchievementDefinition[] = []; for (const achievement of relevantAchievements) { const userAchievement = await prisma.userAchievement.findUnique({ where: { userId_achievementKey: { userId, achievementKey: achievement.key, }, }, }); // Skip already earned achievements if (userAchievement?.earned) { continue; } // Check if achievement is now earned const earned = await this.checkAchievementCondition(userId, achievement); if (earned) { await this.awardAchievement(userId, achievement, req); newlyEarned.push(achievement); } } return newlyEarned; } /** * Award an achievement to a user. */ private async awardAchievement( userId: string, achievement: AchievementDefinition, req: FastifyRequest, ): Promise { await prisma.userAchievement.upsert({ where: { userId_achievementKey: { userId, achievementKey: achievement.key, }, }, create: { userId, achievementKey: achievement.key, progress: 100, earned: true, earnedAt: new Date(), }, update: { earned: true, earnedAt: new Date(), progress: 100, }, }); // Update user's achievement points await prisma.user.update({ where: { id: userId }, data: { achievementPoints: { increment: achievement.points, }, }, }); // Log the achievement unlock await AuditService.logFromRequest(req, { action: AuditAction.achievementUnlocked, category: AuditCategory.content, details: `Unlocked achievement: ${achievement.title}`, }); } /** * Check if a specific achievement condition is met. */ private async checkAchievementCondition( userId: string, achievement: AchievementDefinition, ): Promise { switch (achievement.category) { case AchievementCategory.Suggestion: return await this.checkSuggestionAchievement(userId, achievement); case AchievementCategory.Like: return await this.checkLikeAchievement(userId, achievement); case AchievementCategory.Comment: return await this.checkCommentAchievement(userId, achievement); case AchievementCategory.Engagement: return await this.checkEngagementAchievement(userId, achievement); case AchievementCategory.Report: return await this.checkReportAchievement(userId, achievement); default: return false; } } /** * Check suggestion-based achievements. */ private async checkSuggestionAchievement( userId: string, achievement: AchievementDefinition, ): Promise { const { requirements } = achievement; // Count-based achievements (total suggestions) if ( achievement.key.startsWith("suggestion_first_steps") || achievement.key.startsWith("suggestion_contributor") || achievement.key.startsWith("suggestion_dedicated") || achievement.key.startsWith("suggestion_master") || achievement.key.startsWith("suggestion_legend") ) { const count = await prisma.suggestion.count({ where: { userId }, }); return count >= (requirements.count ?? 0); } // Accepted suggestion achievements if (achievement.key.startsWith("suggestion_quality")) { const count = await prisma.suggestion.count({ where: { userId, status: "ACCEPTED", }, }); return count >= (requirements.count ?? 0); } // Approved achievement (first accepted) if (achievement.key === "suggestion_approved") { const count = await prisma.suggestion.count({ where: { userId, status: "ACCEPTED", }, }); return count >= 1; } // Acceptance rate achievements if (achievement.key.startsWith("suggestion_acceptance")) { const total = await prisma.suggestion.count({ where: { userId }, }); const accepted = await prisma.suggestion.count({ where: { userId, status: "ACCEPTED", }, }); if (total < (requirements.count ?? 0)) { return false; } const rate = accepted / total; return rate >= (requirements.rate ?? 0); } // Diversity achievement (all media types) if (achievement.key === "suggestion_renaissance") { const types = await prisma.suggestion.groupBy({ by: ["entityType"], where: { userId, status: "ACCEPTED", }, }); return types.length >= 6; } // Enthusiast (5 in one day) if (achievement.key === "suggestion_enthusiast") { const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const count = await prisma.suggestion.count({ where: { userId, createdAt: { gte: today, lt: tomorrow, }, }, }); return count >= 5; } return false; } /** * Check like-based achievements. */ private async checkLikeAchievement( userId: string, achievement: AchievementDefinition, ): Promise { const { requirements } = achievement; // Count-based achievements (total likes) if ( achievement.key.startsWith("like_first") || achievement.key.startsWith("like_enthusiast") || achievement.key.startsWith("like_fan") || achievement.key.startsWith("like_super") || achievement.key.startsWith("like_mega") || achievement.key.startsWith("like_legendary") ) { const count = await prisma.like.count({ where: { userId }, }); return count >= (requirements.count ?? 0); } // Media-specific achievements if (achievement.key === "like_book_lover") { const count = await prisma.like.count({ where: { userId, entityType: "book", }, }); return count >= 50; } if (achievement.key === "like_gamer") { const count = await prisma.like.count({ where: { userId, entityType: "game", }, }); return count >= 50; } if (achievement.key === "like_cinephile") { const count = await prisma.like.count({ where: { userId, entityType: "show", }, }); return count >= 50; } if (achievement.key === "like_music") { const count = await prisma.like.count({ where: { userId, entityType: "music", }, }); return count >= 50; } // Diversity achievement if (achievement.key === "like_diverse") { const types = await prisma.like.groupBy({ by: ["entityType"], where: { userId }, }); return types.length >= 6; } // Binge liker (20+ in one day) if (achievement.key === "like_binge") { const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const count = await prisma.like.count({ where: { userId, createdAt: { gte: today, lt: tomorrow, }, }, }); return count >= 20; } return false; } /** * Check comment-based achievements. */ private async checkCommentAchievement( userId: string, achievement: AchievementDefinition, ): Promise { const { requirements } = achievement; // Count-based achievements (total comments) if ( achievement.key.startsWith("comment_first") || achievement.key.startsWith("comment_reviewer") || achievement.key.startsWith("comment_critic") || achievement.key.startsWith("comment_expert") || achievement.key.startsWith("comment_master") || achievement.key.startsWith("comment_legend") ) { const count = await prisma.comment.count({ where: { userId }, }); return count >= (requirements.count ?? 0); } // Length-based achievements if (achievement.key === "comment_detailed") { const count = await prisma.comment.count({ where: { userId, content: { // MongoDB doesn't have a direct length check, so we'll fetch and check }, }, }); // Fetch all comments and check length const comments = await prisma.comment.findMany({ where: { userId }, select: { content: true }, }); const longComments = comments.filter((c) => c.content.length >= 500); return longComments.length >= 10; } if (achievement.key === "comment_essay") { const comments = await prisma.comment.findMany({ where: { userId }, select: { content: true }, }); const longComments = comments.filter((c) => c.content.length >= 1000); return longComments.length >= 5; } if (achievement.key === "comment_novel") { const comments = await prisma.comment.findMany({ where: { userId }, select: { content: true }, }); const longComments = comments.filter((c) => c.content.length >= 2000); return longComments.length >= 3; } // Thoughtful reviewer (50 different items) if (achievement.key === "comment_thoughtful") { // Count unique entity IDs const comments = await prisma.comment.findMany({ where: { userId }, select: { bookId: true, gameId: true, showId: true, mangaId: true, musicId: true, artId: true, }, }); const uniqueItems = new Set(); comments.forEach((c) => { const entityId = c.bookId ?? c.gameId ?? c.showId ?? c.mangaId ?? c.musicId ?? c.artId; if (entityId) { uniqueItems.add(entityId); } }); return uniqueItems.size >= 50; } // Diversity achievement if (achievement.key === "comment_diverse") { const comments = await prisma.comment.findMany({ where: { userId }, select: { bookId: true, gameId: true, showId: true, mangaId: true, musicId: true, artId: true, }, }); const types = new Set(); comments.forEach((c) => { if (c.bookId) { types.add("book"); } if (c.gameId) { types.add("game"); } if (c.showId) { types.add("show"); } if (c.mangaId) { types.add("manga"); } if (c.musicId) { types.add("music"); } if (c.artId) { types.add("art"); } }); return types.size >= 6; } return false; } /** * Check engagement-based achievements. */ private async checkEngagementAchievement( userId: string, achievement: AchievementDefinition, ): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, select: { currentStreak: true, createdAt: true, }, }); if (!user) { return false; } // Welcome achievement if (achievement.key === "engagement_welcome") { return true; // Awarded on first login } // Streak-based achievements if (achievement.key.startsWith("engagement_streak")) { return user.currentStreak >= (achievement.requirements.streak ?? 0); } // Triple threat (suggestion, like, comment in same day) if (achievement.key === "engagement_triple_threat") { const today = new Date(); today.setHours(0, 0, 0, 0); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const [hasSuggestion, hasLike, hasComment] = await Promise.all([ prisma.suggestion.count({ where: { userId, createdAt: { gte: today, lt: tomorrow }, }, }), prisma.like.count({ where: { userId, createdAt: { gte: today, lt: tomorrow }, }, }), prisma.comment.count({ where: { userId, createdAt: { gte: today, lt: tomorrow }, }, }), ]); return hasSuggestion > 0 && hasLike > 0 && hasComment > 0; } // Power user (triple threat 10 times) - would need special tracking // Early adopter achievements if ( achievement.key === "engagement_early_adopter" || achievement.key === "engagement_founding_100" || achievement.key === "engagement_founding_1000" ) { const userRank = await prisma.user.count({ where: { createdAt: { lt: user.createdAt, }, }, }); if (achievement.key === "engagement_early_adopter") { return userRank < 10; } if (achievement.key === "engagement_founding_100") { return userRank < 100; } if (achievement.key === "engagement_founding_1000") { return userRank < 1000; } } // Account age achievements if (achievement.key.startsWith("engagement_veteran")) { const daysSinceCreation = Math.floor( (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24), ); return daysSinceCreation >= (achievement.requirements.dayRange ?? 0); } return false; } /** * Check report-based achievements. */ private async checkReportAchievement( userId: string, achievement: AchievementDefinition, ): Promise { const { requirements } = achievement; // Count ACTION_TAKEN reports if ( achievement.key.startsWith("report_watchful") || achievement.key.startsWith("report_guardian") || achievement.key.startsWith("report_protector") || achievement.key.startsWith("report_vigilant") ) { const profileReports = await prisma.profileReport.count({ where: { reporterId: userId, status: "ACTION_TAKEN", }, }); const commentReports = await prisma.commentReport.count({ where: { reporterId: userId, status: "ACTION_TAKEN", }, }); return profileReports + commentReports >= (requirements.count ?? 0); } // Accuracy achievements if (achievement.key.startsWith("report_accuracy")) { const totalProfileReports = await prisma.profileReport.count({ where: { reporterId: userId, status: { not: "PENDING", }, }, }); const totalCommentReports = await prisma.commentReport.count({ where: { reporterId: userId, status: { not: "PENDING", }, }, }); const total = totalProfileReports + totalCommentReports; if (total < (requirements.count ?? 10)) { return false; } const actionTakenProfile = await prisma.profileReport.count({ where: { reporterId: userId, status: "ACTION_TAKEN", }, }); const actionTakenComment = await prisma.commentReport.count({ where: { reporterId: userId, status: "ACTION_TAKEN", }, }); const actionTaken = actionTakenProfile + actionTakenComment; const rate = actionTaken / total; return rate >= (requirements.rate ?? 0); } // Volume achievement (total reports) if (achievement.key === "report_volume") { const profileReports = await prisma.profileReport.count({ where: { reporterId: userId }, }); const commentReports = await prisma.commentReport.count({ where: { reporterId: userId }, }); return profileReports + commentReports >= 50; } return false; } /** * Get a user's achievement progress and summary. */ async getUserAchievementSummary( userId: string, ): Promise { const userAchievements = await prisma.userAchievement.findMany({ where: { userId }, orderBy: { earnedAt: "desc" }, }); const earnedAchievements = userAchievements.filter((ua) => ua.earned); const totalPoints = earnedAchievements.reduce((sum, ua) => { const definition = ACHIEVEMENTS[ua.achievementKey]; return sum + (definition?.points ?? 0); }, 0); const recentAchievements: AchievementProgress[] = earnedAchievements .slice(0, 5) .map((ua) => ({ definition: ACHIEVEMENTS[ua.achievementKey], progress: ua.progress, earned: ua.earned, earnedAt: ua.earnedAt ?? undefined, })); // Progress by category const progressByCategory = Object.values(AchievementCategory).map( (category) => { const categoryAchievements = ACHIEVEMENT_LIST.filter( (a) => a.category === category, ); const earned = earnedAchievements.filter( (ua) => ACHIEVEMENTS[ua.achievementKey]?.category === category, ).length; return { category, earned, total: categoryAchievements.length, }; }, ); return { totalPoints, totalEarned: earnedAchievements.length, recentAchievements, progressByCategory, }; } /** * Get all achievements with progress for a user. */ async getUserAchievementProgress( userId: string, ): Promise { const userAchievements = await prisma.userAchievement.findMany({ where: { userId }, }); const progressMap = new Map( userAchievements.map((ua) => [ua.achievementKey, ua]), ); return ACHIEVEMENT_LIST.map((definition) => { const userAchievement = progressMap.get(definition.key); return { definition, progress: userAchievement?.progress ?? 0, earned: userAchievement?.earned ?? false, earnedAt: userAchievement?.earnedAt ?? undefined, }; }); } /** * Update login streak for a user. */ async updateLoginStreak(userId: string): Promise { const user = await prisma.user.findUnique({ where: { id: userId }, select: { currentStreak: true, lastStreakCheck: true, }, }); if (!user) { return; } const now = new Date(); const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); yesterday.setHours(0, 0, 0, 0); const lastCheck = user.lastStreakCheck; const isConsecutive = lastCheck && lastCheck >= yesterday && lastCheck < new Date(now.setHours(0, 0, 0, 0)); await prisma.user.update({ where: { id: userId }, data: { currentStreak: isConsecutive ? user.currentStreak + 1 : 1, lastStreakCheck: new Date(), }, }); } }