generated from nhcarrigan/template
feat: implement admin action APIs for comment and profile moderation
Added full API implementation for admin action buttons in the report management interface: Backend Changes: - Created new /api/comments routes for admin-only comment operations - Added PUT /api/comments/:id for admin comment editing - Added DELETE /api/comments/:id for admin comment deletion - Added POST /api/users/:id/make-private for admin profile privacy control - All endpoints protected with adminGuard and csrfProtection - Proper audit logging for all admin actions - Added onDelete: Cascade to CommentReport relation for safe comment deletion Frontend Changes: - Added adminUpdateComment() and adminDeleteComment() to CommentsService - Added makeProfilePrivate() to UserService - Integrated API calls into admin-reports component methods - editComment() now updates comment via API and refreshes report list - deleteComment() now deletes comment via API and refreshes report list - makeProfilePrivate() now updates profile privacy via API - editProfile() navigates using ObjectId instead of Discord username - All actions show success/error toasts and close modals on completion The admin interface now has full working moderation capabilities for both comment and profile reports.
This commit is contained in:
@@ -376,7 +376,7 @@ model ProfileReport {
|
|||||||
model CommentReport {
|
model CommentReport {
|
||||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||||
reportedCommentId String @db.ObjectId
|
reportedCommentId String @db.ObjectId
|
||||||
reportedComment Comment @relation(fields: [reportedCommentId], references: [id])
|
reportedComment Comment @relation(fields: [reportedCommentId], references: [id], onDelete: Cascade)
|
||||||
reporterId String @db.ObjectId
|
reporterId String @db.ObjectId
|
||||||
reporter User @relation("CommentReporter", fields: [reporterId], references: [id])
|
reporter User @relation("CommentReporter", fields: [reporterId], references: [id])
|
||||||
reason ReportReason
|
reason ReportReason
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* @copyright 2026 NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyPluginAsync } from "fastify";
|
||||||
|
import { Comment, AuditAction, AuditCategory } from "@library/shared-types";
|
||||||
|
import { CommentService } from "../../services/comment.service";
|
||||||
|
import { AuditService } from "../../services/audit.service";
|
||||||
|
import { adminGuard } from "../../middleware/admin-guard";
|
||||||
|
|
||||||
|
interface UpdateCommentBody {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const commentService = new CommentService();
|
||||||
|
|
||||||
|
// Admin: Update any comment by ID
|
||||||
|
app.put<{ Params: { id: string }; Body: UpdateCommentBody; Reply: Comment | { error: string } }>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const { content } = request.body;
|
||||||
|
|
||||||
|
const existingComment = await commentService.getCommentById(id);
|
||||||
|
if (!existingComment) {
|
||||||
|
return reply.code(404).send({ error: "Comment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await commentService.updateComment(id, content);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.commentUpdate,
|
||||||
|
category: AuditCategory.admin,
|
||||||
|
details: `Admin updated comment ${id}`,
|
||||||
|
});
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin: Delete any comment by ID
|
||||||
|
app.delete<{ Params: { id: string }; Reply: { success: boolean } | { error: string } }>(
|
||||||
|
"/:id",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
const existingComment = await commentService.getCommentById(id);
|
||||||
|
if (!existingComment) {
|
||||||
|
return reply.code(404).send({ error: "Comment not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await commentService.deleteComment(id);
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.commentDelete,
|
||||||
|
category: AuditCategory.admin,
|
||||||
|
details: `Admin deleted comment ${id}`,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default commentsRoutes;
|
||||||
@@ -231,6 +231,30 @@ const usersRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Reply: User | { error: string } }>(
|
||||||
|
"/:id/make-private",
|
||||||
|
{
|
||||||
|
preValidation: [app.authenticate, adminGuard],
|
||||||
|
preHandler: [app.csrfProtection],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const user = await userService.updateUserSettings(id, { profilePublic: false });
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(404).send({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuditService.logFromRequest(request, {
|
||||||
|
action: AuditAction.entryUpdate,
|
||||||
|
category: AuditCategory.admin,
|
||||||
|
targetUserId: id,
|
||||||
|
details: `Admin made profile private for user: ${user.username}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default usersRoutes;
|
export default usersRoutes;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { ReportService } from '../../services/report.service';
|
import { ReportService } from '../../services/report.service';
|
||||||
import { CommentReportService } from '../../services/comment-report.service';
|
import { CommentReportService } from '../../services/comment-report.service';
|
||||||
|
import { CommentsService } from '../../services/comments.service';
|
||||||
|
import { UserService } from '../../services/user.service';
|
||||||
import { AuthService } from '../../services/auth.service';
|
import { AuthService } from '../../services/auth.service';
|
||||||
import { ToastService } from '../../services/toast.service';
|
import { ToastService } from '../../services/toast.service';
|
||||||
import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportReason } from '@library/shared-types';
|
import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportReason } from '@library/shared-types';
|
||||||
@@ -1236,6 +1238,8 @@ import { ProfileReportWithUsers, CommentReportWithDetails, ReportStatus, ReportR
|
|||||||
export class AdminReportsComponent implements OnInit {
|
export class AdminReportsComponent implements OnInit {
|
||||||
private reportService = inject(ReportService);
|
private reportService = inject(ReportService);
|
||||||
private commentReportService = inject(CommentReportService);
|
private commentReportService = inject(CommentReportService);
|
||||||
|
private commentsService = inject(CommentsService);
|
||||||
|
private userService = inject(UserService);
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private toastService = inject(ToastService);
|
private toastService = inject(ToastService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -1487,40 +1491,66 @@ export class AdminReportsComponent implements OnInit {
|
|||||||
|
|
||||||
editComment(report: CommentReportWithDetails): void {
|
editComment(report: CommentReportWithDetails): void {
|
||||||
const commentId = report.reportedComment.id;
|
const commentId = report.reportedComment.id;
|
||||||
const newContent = prompt('Edit comment content:', report.reportedComment.content);
|
const currentContent = report.reportedComment.rawContent || report.reportedComment.content;
|
||||||
|
const newContent = prompt('Edit comment content:', currentContent);
|
||||||
|
|
||||||
if (newContent !== null && newContent.trim() !== '') {
|
if (newContent !== null && newContent.trim() !== '') {
|
||||||
// TODO: Implement comment editing API call
|
this.commentsService.adminUpdateComment(commentId, newContent).subscribe({
|
||||||
this.toastService.success('Comment edit functionality coming soon');
|
next: () => {
|
||||||
|
this.toastService.success('Comment updated successfully');
|
||||||
|
this.loadReports();
|
||||||
|
this.closeCommentReviewModal();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.error(err.message ?? 'Failed to update comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteComment(report: CommentReportWithDetails): void {
|
deleteComment(report: CommentReportWithDetails): void {
|
||||||
|
const commentId = report.reportedComment.id;
|
||||||
const commentAuthor = report.reportedComment.user.username;
|
const commentAuthor = report.reportedComment.user.username;
|
||||||
const confirmed = confirm(`Are you sure you want to delete this comment by ${commentAuthor}?`);
|
const confirmed = confirm(`Are you sure you want to delete this comment by ${commentAuthor}?`);
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
// TODO: Implement comment deletion API call
|
this.commentsService.adminDeleteComment(commentId).subscribe({
|
||||||
this.toastService.success('Comment delete functionality coming soon');
|
next: () => {
|
||||||
|
this.toastService.success('Comment deleted successfully');
|
||||||
|
this.loadReports();
|
||||||
|
this.closeCommentReviewModal();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.error(err.message ?? 'Failed to delete comment');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editProfile(report: ProfileReportWithUsers): void {
|
editProfile(report: ProfileReportWithUsers): void {
|
||||||
const userId = report.reportedUser.id;
|
const userId = report.reportedUser.id;
|
||||||
const username = report.reportedUser.username;
|
|
||||||
|
|
||||||
// Navigate to profile edit page or open edit modal
|
// Navigate to profile page using ObjectId
|
||||||
this.router.navigate(['/profile', username]);
|
this.router.navigate(['/profile', userId]);
|
||||||
this.toastService.success('Navigate to profile to edit');
|
this.toastService.success('Navigate to profile to edit');
|
||||||
}
|
}
|
||||||
|
|
||||||
makeProfilePrivate(report: ProfileReportWithUsers): void {
|
makeProfilePrivate(report: ProfileReportWithUsers): void {
|
||||||
|
const userId = report.reportedUser.id;
|
||||||
const username = report.reportedUser.username;
|
const username = report.reportedUser.username;
|
||||||
const confirmed = confirm(`Are you sure you want to make ${username}'s profile private?`);
|
const confirmed = confirm(`Are you sure you want to make ${username}'s profile private?`);
|
||||||
|
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
// TODO: Implement make profile private API call
|
this.userService.makeProfilePrivate(userId).subscribe({
|
||||||
this.toastService.success('Profile privacy functionality coming soon');
|
next: () => {
|
||||||
|
this.toastService.success(`${username}'s profile has been made private`);
|
||||||
|
this.loadReports();
|
||||||
|
this.closeProfileReviewModal();
|
||||||
|
},
|
||||||
|
error: (err) => {
|
||||||
|
this.toastService.error(err.message ?? 'Failed to update profile privacy');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,4 +111,13 @@ export class CommentsService {
|
|||||||
updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable<Comment> {
|
updateCommentOnManga(mangaId: string, commentId: string, content: string): Observable<Comment> {
|
||||||
return this.api.put<Comment>(`/manga/${mangaId}/comments/${commentId}`, { content });
|
return this.api.put<Comment>(`/manga/${mangaId}/comments/${commentId}`, { content });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin methods - work with comment ID directly
|
||||||
|
adminUpdateComment(commentId: string, content: string): Observable<Comment> {
|
||||||
|
return this.api.put<Comment>(`/comments/${commentId}`, { content });
|
||||||
|
}
|
||||||
|
|
||||||
|
adminDeleteComment(commentId: string): Observable<{ success: boolean }> {
|
||||||
|
return this.api.delete<{ success: boolean }>(`/comments/${commentId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,4 +81,8 @@ export class UserService {
|
|||||||
getProfile(identifier: string): Observable<UserProfileResponse> {
|
getProfile(identifier: string): Observable<UserProfileResponse> {
|
||||||
return this.api.get<UserProfileResponse>(`/users/profile/${identifier}`);
|
return this.api.get<UserProfileResponse>(`/users/profile/${identifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
makeProfilePrivate(userId: string): Observable<User> {
|
||||||
|
return this.api.post<User>(`/users/${userId}/make-private`, {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user