generated from nhcarrigan/template
86404497f0
## Summary This PR implements comprehensive user profile enhancements including: - User profile pages showing stats, badges, social links, and bio - Achievement system with 62 achievements across 5 categories - Primary badge selection allowing users to display their preferred badge - Admin profile editing capabilities ## Changes ### User Profiles (#45) - **Frontend**: User profile pages with stats display - Profile cards showing avatar, display name, username, and bio - Social links section (Website, GitHub, Bluesky, LinkedIn, Twitch, YouTube, Discord) - Stats display (suggestions, accepted suggestions, likes, comments) - Recent achievements section - Badge display - Report button for other users' profiles - **Backend**: Profile API endpoints - Get user profile by username or ID - Profile includes stats, badges, and achievement points ### Achievement System (#48) - **Database**: UserAchievement model for tracking progress - **62 Total Achievements** across 5 categories: - **Suggestions (15)**: First suggestion through ultimate curator - **Likes (12)**: First like through legendary fan - **Comments (12)**: First comment through review legend - **Engagement (15)**: Login streaks and activity milestones - **Reports (8)**: Valid reports and accuracy tracking - **Backend**: AchievementService with real-time checking - Integrated into all user interaction points - API endpoints for achievement data - Progress tracking to avoid recalculation - **Frontend**: Achievements page and profile integration - Full achievements page with category filtering - Tier-based styling (Bronze, Silver, Gold, Platinum, Diamond) - Progress indicators for in-progress achievements - Recent achievements on profile pages ### Primary Badge System (#49) - **Database**: Add primaryBadge field to User model - **Backend**: Update profile endpoints to include primary badge - **Frontend**: Primary badge selection in settings - Only shows badges the user has earned - Displayed on profile page - Displayed in comments (next to username) - Falls back to no badge if selection is invalid - **Admin Features**: Admin can edit any user's primary badge ### Admin Enhancements - Comprehensive profile editing modal for admins - Edit display name, bio, slug, social links - Set primary badge for users - Visual feedback for save/error states - Admin action buttons in report review modals - Ban user, delete comment, edit profile - Integrated with report workflow ### Quality Improvements - Improved dropdown option contrast for readability - Hide all badges when no primary badge is selected - "View All" achievements link only shown on own profile - Improved achievement text readability ## Testing - ✅ User profiles display correctly with stats and badges - ✅ Achievement checking works for all interaction types - ✅ Primary badge selection persists and displays correctly - ✅ Admin profile editing saves successfully - ✅ Report workflow integrated with admin actions - ✅ Achievements page shows all 62 achievements with filtering - ✅ Text readability improved across components Closes #45 Closes #48 Closes #49 Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #58 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
773 lines
20 KiB
TypeScript
773 lines
20 KiB
TypeScript
/**
|
|
* @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<AchievementDefinition[]> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<string>();
|
|
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<string>();
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<UserAchievementSummary> {
|
|
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<AchievementProgress[]> {
|
|
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<void> {
|
|
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(),
|
|
},
|
|
});
|
|
}
|
|
}
|