feat: implement comment reporting system

Added comprehensive comment reporting infrastructure similar to profile reporting.

API Changes:
- New CommentReport model in Prisma schema with relations to User and Comment
- CommentReportService with CRUD operations, duplicate prevention, and rate limiting
- API routes at /comment-reports for creating and managing comment reports
- Updated CommentService to include hasPendingReports flag for all comments

Frontend Changes:
- Created shared CommentDisplayComponent for reusable comment display with report button
- Updated ReportModalComponent to handle both profile and comment reports
- CommentReportService for API communication
- Integrated CommentDisplayComponent into games-list component
- Comments with pending reports show "[comment pending admin review]" message

Features:
- Users can report comments they didn't write
- Duplicate prevention (one pending report per comment per user)
- Rate limiting (5 pending reports maximum per user)
- Admins can review and action comment reports
- Comments are hidden during review to prevent abuse

Remaining Work:
- Need to integrate CommentDisplayComponent into remaining media components (books, music, art, shows, manga)
- Need to extend admin-reports page to display comment reports alongside profile reports
This commit is contained in:
2026-02-19 19:06:49 -08:00
committed by Naomi Carrigan
parent 8f569e0bb4
commit c514849f12
11 changed files with 1068 additions and 114 deletions
+43 -20
View File
@@ -205,33 +205,36 @@ model User {
suggestions Suggestion[]
likes Like[]
refreshTokens RefreshToken[]
reportsMade ProfileReport[] @relation("Reporter")
reportsReceived ProfileReport[] @relation("ReportedUser")
reportsReviewed ProfileReport[] @relation("Reviewer")
reportsMade ProfileReport[] @relation("Reporter")
reportsReceived ProfileReport[] @relation("ReportedUser")
reportsReviewed ProfileReport[] @relation("Reviewer")
commentReportsMade CommentReport[] @relation("CommentReporter")
commentReportsReviewed CommentReport[] @relation("CommentReviewer")
@@index([slug], map: "User_slug_key")
}
model Comment {
id String @id @default(auto()) @map("_id") @db.ObjectId
id String @id @default(auto()) @map("_id") @db.ObjectId
content String
rawContent String?
userId String @db.ObjectId
user User @relation(fields: [userId], references: [id])
gameId String? @db.ObjectId
game Game? @relation(fields: [gameId], references: [id])
bookId String? @db.ObjectId
book Book? @relation(fields: [bookId], references: [id])
musicId String? @db.ObjectId
music Music? @relation(fields: [musicId], references: [id])
artId String? @db.ObjectId
art Art? @relation(fields: [artId], references: [id])
showId String? @db.ObjectId
show Show? @relation(fields: [showId], references: [id])
mangaId String? @db.ObjectId
manga Manga? @relation(fields: [mangaId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String @db.ObjectId
user User @relation(fields: [userId], references: [id])
gameId String? @db.ObjectId
game Game? @relation(fields: [gameId], references: [id])
bookId String? @db.ObjectId
book Book? @relation(fields: [bookId], references: [id])
musicId String? @db.ObjectId
music Music? @relation(fields: [musicId], references: [id])
artId String? @db.ObjectId
art Art? @relation(fields: [artId], references: [id])
showId String? @db.ObjectId
show Show? @relation(fields: [showId], references: [id])
mangaId String? @db.ObjectId
manga Manga? @relation(fields: [mangaId], references: [id])
reports CommentReport[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AuditLog {
@@ -369,3 +372,23 @@ model ProfileReport {
@@index([reporterId])
@@index([status])
}
model CommentReport {
id String @id @default(auto()) @map("_id") @db.ObjectId
reportedCommentId String @db.ObjectId
reportedComment Comment @relation(fields: [reportedCommentId], references: [id])
reporterId String @db.ObjectId
reporter User @relation("CommentReporter", fields: [reporterId], references: [id])
reason ReportReason
details String
status ReportStatus @default(PENDING)
reviewedBy String? @db.ObjectId
reviewer User? @relation("CommentReviewer", fields: [reviewedBy], references: [id])
reviewNotes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([reportedCommentId])
@@index([reporterId])
@@index([status])
}
+140
View File
@@ -0,0 +1,140 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { FastifyPluginAsync } from "fastify";
import type {
CreateCommentReportDto,
CommentReportWithDetails,
ReportStatus,
UpdateCommentReportDto,
} from "@library/shared-types";
import { ReportReason } from "@library/shared-types";
import { CommentReportService } from "../../services/comment-report.service.js";
import { adminGuard } from "../../middleware/admin-guard.js";
const commentReportsRoutes: FastifyPluginAsync = async (fastify) => {
const commentReportService = new CommentReportService();
// Create a new comment report (authenticated users)
fastify.post<{
Body: CreateCommentReportDto;
Reply: CommentReportWithDetails | { error: string };
}>(
"/",
{
preValidation: [fastify.authenticate],
schema: {
body: {
type: "object",
required: ["reportedCommentId", "reason", "details"],
properties: {
reportedCommentId: { type: "string" },
reason: {
type: "string",
enum: Object.values(ReportReason),
},
details: { type: "string", minLength: 10, maxLength: 1000 },
},
},
},
},
async (request, reply) => {
try {
const report = await commentReportService.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") ||
error.message.includes("maximum number of pending reports"))
) {
return reply.status(409).send({ error: error.message });
}
throw error;
}
},
);
// Get all comment reports (admin only)
fastify.get<{
Querystring: { status?: ReportStatus };
Reply: CommentReportWithDetails[];
}>(
"/",
{
preValidation: [fastify.authenticate, adminGuard],
schema: {
querystring: {
type: "object",
properties: {
status: { type: "string" },
},
},
},
},
async (request, reply) => {
const reports = await commentReportService.getAllReports(
request.query.status,
);
return reply.send(reports);
},
);
// Get a single comment report by ID (admin only)
fastify.get<{
Params: { id: string };
Reply: CommentReportWithDetails | { error: string };
}>(
"/:id",
{
preValidation: [fastify.authenticate, adminGuard],
},
async (request, reply) => {
const report = await commentReportService.getReportById(request.params.id);
if (!report) {
return reply.status(404).send({ error: "Report not found" });
}
return reply.send(report);
},
);
// Update a comment report (admin only)
fastify.put<{
Params: { id: string };
Body: UpdateCommentReportDto;
Reply: CommentReportWithDetails;
}>(
"/: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 commentReportService.updateReport(
request.params.id,
request.user.id,
request.body,
);
return reply.send(report);
},
);
};
export default commentReportsRoutes;
@@ -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;
}
}
+26 -20
View File
@@ -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,
@@ -71,6 +76,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 +85,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 +122,7 @@ export class CommentService {
userId,
gameId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -134,7 +140,7 @@ export class CommentService {
userId,
bookId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -152,7 +158,7 @@ export class CommentService {
userId,
musicId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -160,10 +166,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 +185,7 @@ export class CommentService {
userId,
artId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -187,10 +193,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 +212,7 @@ export class CommentService {
userId,
showId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -214,10 +220,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 +239,7 @@ export class CommentService {
userId,
mangaId,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}
@@ -256,7 +262,7 @@ export class CommentService {
content: sanitizedContent,
rawContent: content,
},
include: { user: true },
include: { user: true, reports: true },
});
return this.mapComment(comment);
}