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>
313 lines
8.2 KiB
TypeScript
313 lines
8.2 KiB
TypeScript
/**
|
|
* @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,
|
|
};
|
|
}
|
|
}
|