feat: implement user profiles with achievements and primary badge system (#58)
Node.js CI / CI (push) Successful in 1m21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s

## 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>
This commit was merged in pull request #58.
This commit is contained in:
2026-02-19 22:21:17 -08:00
committed by Naomi Carrigan
parent 7579f1ec97
commit 86404497f0
58 changed files with 8325 additions and 449 deletions
+772
View File
@@ -0,0 +1,772 @@
/**
* @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(),
},
});
}
}
+27
View File
@@ -71,6 +71,15 @@ export class AuthService {
username: dbUser.username,
email: dbUser.email,
avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord,
@@ -167,6 +176,15 @@ export class AuthService {
username: dbUser.username,
email: dbUser.email,
avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord,
@@ -217,6 +235,15 @@ export class AuthService {
username: dbUser.username,
email: dbUser.email,
avatar: dbUser.avatar || undefined,
slug: dbUser.slug || undefined,
displayName: dbUser.displayName || undefined,
bio: dbUser.bio || undefined,
profilePublic: dbUser.profilePublic,
website: dbUser.website || undefined,
discordServer: dbUser.discordServer || undefined,
bluesky: dbUser.bluesky || undefined,
github: dbUser.github || undefined,
linkedin: dbUser.linkedin || undefined,
isAdmin: dbUser.isAdmin,
isBanned: dbUser.isBanned,
inDiscord: dbUser.inDiscord,
@@ -0,0 +1,369 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ReportStatus as PrismaReportStatus,
ReportReason as PrismaReportReason,
} from "@prisma/client";
import type {
CreateCommentReportDto,
CommentReportWithDetails,
ReportStatus,
UpdateCommentReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { prisma } from "../lib/prisma.js";
export class CommentReportService {
private prisma = prisma;
/**
* Convert Prisma ReportReason to shared-types ReportReason
*/
private toPrismaReportReason(reason: ReportReason): PrismaReportReason {
return reason as unknown as PrismaReportReason;
}
/**
* Convert Prisma ReportStatus to shared-types ReportStatus
*/
private toPrismaReportStatus(status: ReportStatus): PrismaReportStatus {
return status as unknown as PrismaReportStatus;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportReason(reason: PrismaReportReason): ReportReason {
return reason as unknown as ReportReason;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportStatus(status: PrismaReportStatus): ReportStatus {
return status as unknown as ReportStatus;
}
/**
* Create a new comment report.
*
* @param reporterId - The ID of the user making the report
* @param createDto - The report details
* @returns The created report
* @throws Error if user already has a pending report for this comment
*/
async createReport(
reporterId: string,
createDto: CreateCommentReportDto,
): Promise<CommentReportWithDetails> {
// Check if user already has a pending report for this comment
const existingReport = await this.prisma.commentReport.findFirst({
where: {
reporterId,
reportedCommentId: createDto.reportedCommentId,
status: PrismaReportStatus.PENDING,
},
});
if (existingReport) {
throw new Error(
"You already have a pending report for this comment. Please wait for it to be reviewed.",
);
}
// Check if user has reached the limit of pending reports (5 max)
const pendingReportsCount = await this.prisma.commentReport.count({
where: {
reporterId,
status: PrismaReportStatus.PENDING,
},
});
if (pendingReportsCount >= 5) {
throw new Error(
"You have reached the maximum number of pending reports (5). Please wait for your existing reports to be reviewed.",
);
}
const report = await this.prisma.commentReport.create({
data: {
reporterId,
reportedCommentId: createDto.reportedCommentId,
reason: this.toPrismaReportReason(createDto.reason),
details: createDto.details,
},
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
});
return {
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
};
}
/**
* Get all comment reports (admin only). Optionally filter by status.
*
* @param status - Optional status filter
* @returns All reports matching the filter
*/
async getAllReports(
status?: ReportStatus,
): Promise<CommentReportWithDetails[]> {
const reports = await this.prisma.commentReport.findMany({
where: status ? { status: this.toPrismaReportStatus(status) } : undefined,
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return reports.map((report) => ({
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
}));
}
/**
* Get a single comment report by ID (admin only).
*
* @param id - The report ID
* @returns The report or null
*/
async getReportById(id: string): Promise<CommentReportWithDetails | null> {
const report = await this.prisma.commentReport.findUnique({
where: { id },
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
if (!report) {
return null;
}
return {
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
/**
* Update a comment report's status and review notes (admin only).
*
* @param id - The report ID
* @param reviewerId - The ID of the admin reviewing the report
* @param updateDto - The update details
* @returns The updated report
*/
async updateReport(
id: string,
reviewerId: string,
updateDto: UpdateCommentReportDto,
): Promise<CommentReportWithDetails> {
const report = await this.prisma.commentReport.update({
where: { id },
data: {
status: this.toPrismaReportStatus(updateDto.status),
reviewNotes: updateDto.reviewNotes,
reviewedBy: reviewerId,
},
include: {
reportedComment: {
include: {
user: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
return {
id: report.id,
reportedCommentId: report.reportedCommentId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedComment: {
id: report.reportedComment.id,
content: report.reportedComment.content,
rawContent: report.reportedComment.rawContent ?? undefined,
userId: report.reportedComment.userId,
user: report.reportedComment.user,
},
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
/**
* Check if a comment has any pending reports.
*
* @param commentId - The comment ID
* @returns True if the comment has pending reports
*/
async hasPendingReports(commentId: string): Promise<boolean> {
const count = await this.prisma.commentReport.count({
where: {
reportedCommentId: commentId,
status: PrismaReportStatus.PENDING,
},
});
return count > 0;
}
}
+28 -21
View File
@@ -4,7 +4,7 @@
* @author Naomi Carrigan
*/
import { Comment, CreateCommentDto } from "@library/shared-types";
import { Comment, CreateCommentDto, PrimaryBadge } from "@library/shared-types";
import { prisma } from "../lib/prisma";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -50,7 +50,12 @@ export class CommentService {
});
}
private mapComment(comment: any): Comment {
private async mapComment(comment: any): Promise<Comment> {
// Check if comment has pending reports
const hasPendingReports = comment.reports
? comment.reports.some((report: any) => report.status === "PENDING")
: false;
return {
id: comment.id,
content: comment.content,
@@ -60,6 +65,7 @@ export class CommentService {
id: comment.user.id,
username: comment.user.username,
avatar: comment.user.avatar || undefined,
primaryBadge: (comment.user.primaryBadge as PrimaryBadge) || undefined,
inDiscord: comment.user.inDiscord,
isVip: comment.user.isVip,
isMod: comment.user.isMod,
@@ -71,6 +77,7 @@ export class CommentService {
artId: comment.artId || undefined,
showId: comment.showId || undefined,
mangaId: comment.mangaId || undefined,
hasPendingReports,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
};
@@ -79,28 +86,28 @@ export class CommentService {
async getCommentsForGame(gameId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { gameId },
include: { user: true },
include: { user: true, reports: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
return Promise.all(comments.map((c) => this.mapComment(c)));
}
async getCommentsForBook(bookId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { bookId },
include: { user: true },
include: { user: true, reports: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
return Promise.all(comments.map((c) => this.mapComment(c)));
}
async getCommentsForMusic(musicId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { musicId },
include: { user: true },
include: { user: true, reports: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
return Promise.all(comments.map((c) => this.mapComment(c)));
}
async createCommentForGame(
@@ -116,7 +123,7 @@ export class CommentService {
userId,
gameId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -134,7 +141,7 @@ export class CommentService {
userId,
bookId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -152,7 +159,7 @@ export class CommentService {
userId,
musicId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -160,10 +167,10 @@ export class CommentService {
async getCommentsForArt(artId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { artId },
include: { user: true },
include: { user: true, reports: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
return Promise.all(comments.map((c) => this.mapComment(c)));
}
async createCommentForArt(
@@ -179,7 +186,7 @@ export class CommentService {
userId,
artId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -187,10 +194,10 @@ export class CommentService {
async getCommentsForShow(showId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { showId },
include: { user: true },
include: { user: true, reports: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
return Promise.all(comments.map((c) => this.mapComment(c)));
}
async createCommentForShow(
@@ -206,7 +213,7 @@ export class CommentService {
userId,
showId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -214,10 +221,10 @@ export class CommentService {
async getCommentsForManga(mangaId: string): Promise<Comment[]> {
const comments = await this.prisma.comment.findMany({
where: { mangaId },
include: { user: true },
include: { user: true, reports: true },
orderBy: { createdAt: "desc" },
});
return comments.map((c) => this.mapComment(c));
return Promise.all(comments.map((c) => this.mapComment(c)));
}
async createCommentForManga(
@@ -233,7 +240,7 @@ export class CommentService {
userId,
mangaId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -256,7 +263,7 @@ export class CommentService {
content: sanitizedContent,
rawContent: content,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
+10 -1
View File
@@ -7,8 +7,9 @@
import type { FastifyRequest } from 'fastify';
import { prisma } from '../lib/prisma';
import { AuditService } from './audit.service';
import { AchievementService } from './achievement.service';
import type { Like, LikeCountDto, LikedItemDto, LikeResponse } from '@library/shared-types';
import { AuditAction, AuditCategory } from '@library/shared-types';
import { AuditAction, AuditCategory, AchievementCategory } from '@library/shared-types';
export class LikeService {
async toggleLike(userId: string, entityType: Like['entityType'], entityId: string, req: FastifyRequest): Promise<LikeResponse> {
@@ -59,6 +60,14 @@ export class LikeService {
details: `Liked ${entityType}`
});
// Check for like achievements
const achievementService = new AchievementService();
await achievementService.checkAchievements(
userId,
AchievementCategory.Like,
req
);
const count = await this.getLikeCount(entityType, entityId);
return { liked: true, count };
}
+312
View File
@@ -0,0 +1,312 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ReportStatus as PrismaReportStatus,
ReportReason as PrismaReportReason,
} from "@prisma/client";
import type {
CreateReportDto,
ProfileReportWithUsers,
ReportStatus,
UpdateReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { prisma } from "../lib/prisma.js";
export class ReportService {
private prisma = prisma;
/**
* Convert Prisma ReportReason to shared-types ReportReason
*/
private toPrismaReportReason(reason: ReportReason): PrismaReportReason {
return reason as unknown as PrismaReportReason;
}
/**
* Convert Prisma ReportStatus to shared-types ReportStatus
*/
private toPrismaReportStatus(status: ReportStatus): PrismaReportStatus {
return status as unknown as PrismaReportStatus;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportReason(reason: PrismaReportReason): ReportReason {
return reason as unknown as ReportReason;
}
/**
* Convert Prisma enum back to shared-types enum
*/
private fromPrismaReportStatus(status: PrismaReportStatus): ReportStatus {
return status as unknown as ReportStatus;
}
/**
* Create a new profile report.
*
* @param reporterId - The ID of the user making the report
* @param createDto - The report details
* @returns The created report
* @throws Error if user already has a pending report for this profile
*/
async createReport(
reporterId: string,
createDto: CreateReportDto,
): Promise<ProfileReportWithUsers> {
// Check if user already has a pending report for this profile
const existingReport = await this.prisma.profileReport.findFirst({
where: {
reporterId,
reportedUserId: createDto.reportedUserId,
status: PrismaReportStatus.PENDING,
},
});
if (existingReport) {
throw new Error(
"You already have a pending report for this profile. Please wait for it to be reviewed.",
);
}
// Check if user has reached the limit of pending reports (5 max)
const pendingReportsCount = await this.prisma.profileReport.count({
where: {
reporterId,
status: PrismaReportStatus.PENDING,
},
});
if (pendingReportsCount >= 5) {
throw new Error(
"You have reached the maximum number of pending reports (5). Please wait for your existing reports to be reviewed.",
);
}
const report = await this.prisma.profileReport.create({
data: {
reporterId,
reportedUserId: createDto.reportedUserId,
reason: this.toPrismaReportReason(createDto.reason),
details: createDto.details,
},
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
},
});
return {
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
};
}
/**
* Get all reports (admin only). Optionally filter by status.
*
* @param status - Optional status filter
* @returns All reports matching the filter
*/
async getAllReports(
status?: ReportStatus,
): Promise<ProfileReportWithUsers[]> {
const reports = await this.prisma.profileReport.findMany({
where: status ? { status: this.toPrismaReportStatus(status) } : undefined,
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return reports.map((report) => ({
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
}));
}
/**
* Get a single report by ID (admin only).
*
* @param id - The report ID
* @returns The report or null
*/
async getReportById(id: string): Promise<ProfileReportWithUsers | null> {
const report = await this.prisma.profileReport.findUnique({
where: { id },
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
if (!report) {
return null;
}
return {
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
/**
* Update a report's status and review notes (admin only).
*
* @param id - The report ID
* @param reviewerId - The ID of the admin reviewing the report
* @param updateDto - The update details
* @returns The updated report
*/
async updateReport(
id: string,
reviewerId: string,
updateDto: UpdateReportDto,
): Promise<ProfileReportWithUsers> {
const report = await this.prisma.profileReport.update({
where: { id },
data: {
status: this.toPrismaReportStatus(updateDto.status),
reviewNotes: updateDto.reviewNotes,
reviewedBy: reviewerId,
},
include: {
reportedUser: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reporter: {
select: {
id: true,
username: true,
displayName: true,
avatar: true,
},
},
reviewer: {
select: {
id: true,
username: true,
displayName: true,
},
},
},
});
return {
id: report.id,
reportedUserId: report.reportedUserId,
reporterId: report.reporterId,
reason: this.fromPrismaReportReason(report.reason),
details: report.details,
status: this.fromPrismaReportStatus(report.status),
reviewedBy: report.reviewedBy ?? undefined,
reviewNotes: report.reviewNotes ?? undefined,
createdAt: report.createdAt,
updatedAt: report.updatedAt,
reportedUser: report.reportedUser,
reporter: report.reporter,
reviewer: report.reviewer ?? undefined,
};
}
}
+225 -1
View File
@@ -4,8 +4,9 @@
* @author Naomi Carrigan
*/
import { User } from "@library/shared-types";
import { User, PrimaryBadge } from "@library/shared-types";
import { prisma } from "../lib/prisma";
import { SuggestionStatus } from "@prisma/client";
export class UserService {
private prisma = prisma;
@@ -21,6 +22,18 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -45,6 +58,18 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -66,6 +91,18 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -87,6 +124,18 @@ export class UserService {
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
@@ -104,4 +153,179 @@ export class UserService {
return user?.isBanned ?? false;
}
async getUserBySlug(slug: string): Promise<User | null> {
const user = await this.prisma.user.findFirst({
where: { slug },
});
if (!user) {
return null;
}
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
isVip: user.isVip,
isMod: user.isMod,
isStaff: user.isStaff,
};
}
async updateUserSettings(
id: string,
updates: {
slug?: string;
displayName?: string;
bio?: string;
profilePublic?: boolean;
primaryBadge?: PrimaryBadge;
website?: string;
discordServer?: string;
bluesky?: string;
github?: string;
linkedin?: string;
twitch?: string;
youtube?: string;
}
): Promise<User | null> {
const user = await this.prisma.user.update({
where: { id },
data: updates,
});
return {
id: user.id,
discordId: user.discordId,
username: user.username,
email: user.email,
avatar: user.avatar || undefined,
slug: user.slug || undefined,
displayName: user.displayName || undefined,
bio: user.bio || undefined,
profilePublic: user.profilePublic,
primaryBadge: (user.primaryBadge as PrimaryBadge) || undefined,
website: user.website || undefined,
discordServer: user.discordServer || undefined,
bluesky: user.bluesky || undefined,
github: user.github || undefined,
linkedin: user.linkedin || undefined,
twitch: user.twitch || undefined,
youtube: user.youtube || undefined,
isAdmin: user.isAdmin,
isBanned: user.isBanned,
inDiscord: user.inDiscord,
isVip: user.isVip,
isMod: user.isMod,
isStaff: user.isStaff,
};
}
async getUserProfile(identifier: string): Promise<{
id: string;
username: string;
displayName?: string | null;
avatar?: string | null;
bio?: string | null;
slug?: string | null;
primaryBadge?: PrimaryBadge | null;
website?: string | null;
discordServer?: string | null;
bluesky?: string | null;
github?: string | null;
linkedin?: string | null;
twitch?: string | null;
youtube?: string | null;
isStaff: boolean;
isMod: boolean;
isVip: boolean;
inDiscord: boolean;
profilePublic: boolean;
createdAt: Date;
achievementPoints: number;
stats: {
suggestionsCount: number;
suggestionsAcceptedCount: number;
likesCount: number;
commentsCount: number;
};
} | null> {
// Try to find by slug first, then by id if it's a valid ObjectId
const isValidObjectId = /^[0-9a-f]{24}$/i.test(identifier);
const whereConditions = isValidObjectId
? [{ slug: identifier }, { id: identifier }]
: [{ slug: identifier }];
const user = await this.prisma.user.findFirst({
where: {
OR: whereConditions,
},
include: {
suggestions: {
select: { id: true, status: true },
},
likes: {
select: { id: true },
},
comments: {
select: { id: true },
},
},
});
if (!user) {
return null;
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
avatar: user.avatar,
bio: user.bio,
slug: user.slug,
primaryBadge: user.primaryBadge as PrimaryBadge,
website: user.website,
discordServer: user.discordServer,
bluesky: user.bluesky,
github: user.github,
linkedin: user.linkedin,
twitch: user.twitch,
youtube: user.youtube,
isStaff: user.isStaff,
isMod: user.isMod,
isVip: user.isVip,
inDiscord: user.inDiscord,
profilePublic: user.profilePublic,
createdAt: user.createdAt,
achievementPoints: user.achievementPoints,
stats: {
suggestionsCount: user.suggestions.length,
suggestionsAcceptedCount: user.suggestions.filter(
(suggestion) => suggestion.status === SuggestionStatus.ACCEPTED
).length,
likesCount: user.likes.length,
commentsCount: user.comments.length,
},
};
}
}