Files
library/api/src/app/services/report.service.ts
T
naomi 86404497f0
Node.js CI / CI (push) Successful in 1m21s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m22s
feat: implement user profiles with achievements and primary badge system (#58)
## 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>
2026-02-19 22:21:17 -08:00

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,
};
}
}