feat: implement profile reporting system with admin review

Added comprehensive profile reporting system to allow users to report
inappropriate profiles and admins to review reports.

Features:
- User can report profiles with predefined reasons + custom details
- Duplicate prevention (one pending report per profile per user)
- Rate limiting (5 pending reports maximum per user)
- Admin dashboard to view and filter reports (All, Pending, Reviewed, etc.)
- Admin review modal to update status and add review notes
- Report button on profile page (only visible when viewing others)
- Font Awesome icons for better UI consistency

Database changes:
- New ProfileReport model with ReportReason/ReportStatus enums
- User relations for reports (reportsMade, reportsReceived, reportsReviewed)
- Indices for efficient querying
This commit is contained in:
2026-02-19 18:33:58 -08:00
committed by Naomi Carrigan
parent 5eec4c7640
commit d797d38ddd
9 changed files with 2056 additions and 6 deletions
+139
View File
@@ -0,0 +1,139 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { FastifyPluginAsync } from "fastify";
import type {
CreateReportDto,
ProfileReportWithUsers,
ReportStatus,
UpdateReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { ReportService } from "../../services/report.service.js";
import { adminGuard } from "../../middleware/admin-guard.js";
const reportsRoutes: FastifyPluginAsync = async (fastify) => {
const reportService = new ReportService();
// Create a new report (authenticated users)
fastify.post<{
Body: CreateReportDto;
Reply: ProfileReportWithUsers | { error: string };
}>(
"/",
{
preValidation: [fastify.authenticate],
schema: {
body: {
type: "object",
required: ["reportedUserId", "reason", "details"],
properties: {
reportedUserId: { type: "string" },
reason: {
type: "string",
enum: Object.values(ReportReason),
},
details: { type: "string", minLength: 10, maxLength: 1000 },
},
},
},
},
async (request, reply) => {
try {
const report = await reportService.createReport(
request.user.id,
request.body,
);
return reply.status(201).send(report);
} catch (error) {
if (
error instanceof Error &&
error.message.includes("already have a pending report")
) {
return reply.status(409).send({ error: error.message });
}
throw error;
}
},
);
// Get all reports (admin only)
fastify.get<{
Querystring: { status?: ReportStatus };
Reply: ProfileReportWithUsers[];
}>(
"/",
{
preValidation: [fastify.authenticate, adminGuard],
schema: {
querystring: {
type: "object",
properties: {
status: { type: "string" },
},
},
},
},
async (request, reply) => {
const reports = await reportService.getAllReports(
request.query.status,
);
return reply.send(reports);
},
);
// Get a single report by ID (admin only)
fastify.get<{
Params: { id: string };
Reply: ProfileReportWithUsers | { error: string };
}>(
"/:id",
{
preValidation: [fastify.authenticate, adminGuard],
},
async (request, reply) => {
const report = await reportService.getReportById(request.params.id);
if (!report) {
return reply.status(404).send({ error: "Report not found" });
}
return reply.send(report);
},
);
// Update a report (admin only)
fastify.put<{
Params: { id: string };
Body: UpdateReportDto;
Reply: ProfileReportWithUsers;
}>(
"/:id",
{
preValidation: [fastify.authenticate, adminGuard],
schema: {
body: {
type: "object",
required: ["status"],
properties: {
status: { type: "string" },
reviewNotes: { type: "string", maxLength: 1000 },
},
},
},
},
async (request, reply) => {
const report = await reportService.updateReport(
request.params.id,
request.user.id,
request.body,
);
return reply.send(report);
},
);
};
export default reportsRoutes;
+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,
};
}
}