feat: ability to edit and delete comments

This commit is contained in:
2026-02-04 17:33:34 -08:00
parent 0a654f423a
commit e20be5f4e8
18 changed files with 922 additions and 41 deletions
+2
View File
@@ -161,6 +161,7 @@ model User {
model Comment {
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
@@ -198,6 +199,7 @@ enum AuditAction {
LOGOUT
LOGIN_FAILED
COMMENT_CREATE
COMMENT_UPDATE
COMMENT_DELETE
ENTRY_CREATE
ENTRY_UPDATE
+54 -5
View File
@@ -144,20 +144,69 @@ const artRoutes: FastifyPluginAsync = async (app) => {
);
/**
* Delete comment (admin only).
* Update comment (owner or admin).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "art", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only edit your own comments" });
}
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Updated comment ${commentId} on art`,
});
return comment;
}
);
/**
* Delete comment (owner or admin).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "art", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only delete your own comments" });
}
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "art",
resourceId: id,
details: `Deleted comment ${commentId} from art`,
+54 -5
View File
@@ -144,20 +144,69 @@ const booksRoutes: FastifyPluginAsync = async (app) => {
);
/**
* Delete comment (admin only).
* Update comment (owner or admin).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "book", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only edit your own comments" });
}
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Updated comment ${commentId} on book`,
});
return comment;
}
);
/**
* Delete comment (owner or admin).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "book", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only delete your own comments" });
}
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "book",
resourceId: id,
details: `Deleted comment ${commentId} from book`,
+52 -5
View File
@@ -129,19 +129,66 @@ const gamesRoutes: FastifyPluginAsync = async (app) => {
}
);
// Delete comment (admin only)
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
// Update comment (owner or admin)
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "game", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only edit your own comments" });
}
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Updated comment ${commentId} on game`,
});
return comment;
}
);
// Delete comment (owner or admin)
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "game", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only delete your own comments" });
}
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "game",
resourceId: id,
details: `Deleted comment ${commentId} from game`,
+50 -4
View File
@@ -122,18 +122,64 @@ const mangaRoutes: FastifyPluginAsync = async (app) => {
}
);
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "manga", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only edit your own comments" });
}
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Updated comment ${commentId} on manga`,
});
return comment;
}
);
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "manga", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only delete your own comments" });
}
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "manga",
resourceId: id,
details: `Deleted comment ${commentId} from manga`,
+54 -5
View File
@@ -144,20 +144,69 @@ const musicRoutes: FastifyPluginAsync = async (app) => {
);
/**
* Delete comment (admin only).
* Update comment (owner or admin).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "music", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only edit your own comments" });
}
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Updated comment ${commentId} on music`,
});
return comment;
}
);
/**
* Delete comment (owner or admin).
*/
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "music", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only delete your own comments" });
}
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "music",
resourceId: id,
details: `Deleted comment ${commentId} from music`,
+50 -4
View File
@@ -122,18 +122,64 @@ const showsRoutes: FastifyPluginAsync = async (app) => {
}
);
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } }>(
app.put<{ Params: { id: string; commentId: string }; Body: CreateCommentDto; Reply: Comment | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate, adminGuard],
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request) => {
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "show", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only edit your own comments" });
}
const comment = await commentService.updateComment(commentId, request.body.content);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_UPDATE,
category: AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Updated comment ${commentId} on show`,
});
return comment;
}
);
app.delete<{ Params: { id: string; commentId: string }; Reply: { success: boolean } | { error: string } }>(
"/:id/comments/:commentId",
{
preValidation: [app.authenticate],
preHandler: [app.csrfProtection],
},
async (request, reply) => {
const { id, commentId } = request.params;
const userId = request.user.id;
const isAdmin = request.user.isAdmin;
const verification = await commentService.verifyCommentOwnership(commentId, "show", id);
if (!verification.exists) {
return reply.code(404).send({ error: "Comment not found" });
}
if (verification.comment?.userId !== userId && !isAdmin) {
return reply.code(403).send({ error: "You can only delete your own comments" });
}
await commentService.deleteComment(commentId);
await AuditService.logFromRequest(request, {
action: AuditAction.COMMENT_DELETE,
category: AuditCategory.ADMIN,
category: isAdmin && verification.comment?.userId !== userId ? AuditCategory.ADMIN : AuditCategory.CONTENT,
resourceType: "show",
resourceId: id,
details: `Deleted comment ${commentId} from show`,
+55
View File
@@ -54,6 +54,7 @@ export class CommentService {
return {
id: comment.id,
content: comment.content,
rawContent: comment.rawContent || undefined,
userId: comment.userId,
user: {
id: comment.user.id,
@@ -107,6 +108,7 @@ export class CommentService {
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
rawContent: data.content,
userId,
gameId,
},
@@ -124,6 +126,7 @@ export class CommentService {
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
rawContent: data.content,
userId,
bookId,
},
@@ -141,6 +144,7 @@ export class CommentService {
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
rawContent: data.content,
userId,
musicId,
},
@@ -167,6 +171,7 @@ export class CommentService {
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
rawContent: data.content,
userId,
artId,
},
@@ -193,6 +198,7 @@ export class CommentService {
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
rawContent: data.content,
userId,
showId,
},
@@ -219,6 +225,7 @@ export class CommentService {
const comment = await this.prisma.comment.create({
data: {
content: sanitizedContent,
rawContent: data.content,
userId,
mangaId,
},
@@ -227,9 +234,57 @@ export class CommentService {
return this.mapComment(comment);
}
async getCommentById(commentId: string) {
return this.prisma.comment.findUnique({
where: { id: commentId },
include: { user: true },
});
}
async updateComment(
commentId: string,
content: string
): Promise<Comment> {
const sanitizedContent = this.sanitizeMarkdown(content);
const comment = await this.prisma.comment.update({
where: { id: commentId },
data: {
content: sanitizedContent,
rawContent: content,
},
include: { user: true },
});
return this.mapComment(comment);
}
async deleteComment(commentId: string): Promise<void> {
await this.prisma.comment.delete({
where: { id: commentId },
});
}
async verifyCommentOwnership(
commentId: string,
resourceType: "game" | "book" | "music" | "art" | "show" | "manga",
resourceId: string
): Promise<{ exists: boolean; comment?: { userId: string } }> {
const fieldMap = {
game: "gameId",
book: "bookId",
music: "musicId",
art: "artId",
show: "showId",
manga: "mangaId",
};
const comment = await this.prisma.comment.findFirst({
where: {
id: commentId,
[fieldMap[resourceType]]: resourceId,
},
select: { userId: true },
});
return comment ? { exists: true, comment } : { exists: false };
}
}