diff --git a/app/backend/src/routes/comments.ts b/app/backend/src/routes/comments.ts new file mode 100644 index 0000000..0062279 --- /dev/null +++ b/app/backend/src/routes/comments.ts @@ -0,0 +1,304 @@ +import type { FastifyInstance, FastifyReply } from 'fastify'; +import { Prisma } from '@prisma/client'; +import { z } from 'zod'; +import { prisma } from '../db.js'; +import { requireAuth, type SessionData } from '../plugins/session.js'; +import { createNotification, publishRealtime, recordCommentEvent } from '../lib/realtime.js'; + +const PUBLISHED = 'منتشرشده'; +const COMMENT_FLAG_THRESHOLD = 5; +const EDITOR_ROLES: SessionData['role'][] = ['admin', 'editor']; +const CONTENT_ROLES: SessionData['role'][] = ['admin', 'editor', 'writer']; + +const listSchema = z.object({ + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(50).default(20), + flagged: z.coerce.boolean().optional(), +}); + +const commentBodySchema = z.object({ + body: z.string().trim().min(1).max(1000), + replyToId: z.string().trim().optional(), +}); + +const bulkSchema = z.object({ + ids: z.array(z.string().trim().min(1)).min(1).max(100), + action: z.enum(['approve', 'reject', 'delete']), +}); + +function badInput(reply: FastifyReply) { + return reply.code(400).send({ error: 'invalid_input' }); +} + +function stripHtml(input: string) { + return input.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); +} + +function faDate(date: Date) { + return date.toLocaleDateString('fa-IR', { hour: '2-digit', minute: '2-digit' }); +} + +function publicComment(row: Prisma.CommentGetPayload<{ include: { author: true; likes: true; upvotes: true; replies: { include: { author: true; likes: true; upvotes: true } } } }>, viewerId?: string) { + return { + id: row.id, + body: row.body, + likeCount: row.likeCount, + likedByMe: viewerId ? row.likes.some((like) => like.userId === viewerId) : false, + upvoteCount: row.upvoteCount, + upvotedByMe: viewerId ? row.upvotes.some((uv) => uv.userId === viewerId) : false, + flagCount: row.flagCount, + flagged: row.flagged, + replyToId: row.replyToId, + createdAt: row.createdAt.toISOString(), + time: faDate(row.createdAt), + author: { + id: row.author.id, + name: row.author.name, + username: row.author.username, + avatarUrl: row.author.avatarUrl, + role: row.author.role, + }, + replies: row.replies.map((reply) => ({ + id: reply.id, + body: reply.body, + likeCount: reply.likeCount, + likedByMe: viewerId ? reply.likes.some((like) => like.userId === viewerId) : false, + upvoteCount: reply.upvoteCount, + upvotedByMe: viewerId ? reply.upvotes.some((uv) => uv.userId === viewerId) : false, + flagCount: reply.flagCount, + flagged: reply.flagged, + replyToId: reply.replyToId, + createdAt: reply.createdAt.toISOString(), + time: faDate(reply.createdAt), + author: { + id: reply.author.id, + name: reply.author.name, + username: reply.author.username, + avatarUrl: reply.author.avatarUrl, + role: reply.author.role, + }, + })), + }; +} + +async function canDeleteComment(session: SessionData, commentId: string) { + if (session.role === 'admin' || session.role === 'editor') return true; + if (session.role !== 'writer') return false; + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + select: { article: { select: { createdById: true, author: { select: { userId: true } } } } }, + }); + return comment?.article.createdById === session.userId || comment?.article.author.userId === session.userId; +} + +export default async function commentRoutes(app: FastifyInstance) { + app.get('/api/v1/articles/:slug/comments', async (req, reply) => { + const parsed = listSchema.safeParse(req.query); + if (!parsed.success) return badInput(reply); + const { slug } = req.params as { slug: string }; + const { page, limit } = parsed.data; + const article = await prisma.article.findFirst({ + where: { slug, status: PUBLISHED, publishedAt: { not: null }, deletedAt: null }, + select: { id: true }, + }); + if (!article) return reply.send({ items: [], total: 0, page, limit, pages: 0 }); + const where = { articleId: article.id, replyToId: null, deletedAt: null } satisfies Prisma.CommentWhereInput; + const [total, rows] = await Promise.all([ + prisma.comment.count({ where: { articleId: article.id, deletedAt: null } }), + prisma.comment.findMany({ + where, + include: { + author: true, + likes: true, + upvotes: true, + replies: { + where: { deletedAt: null }, + include: { author: true, likes: true, upvotes: true }, + orderBy: [{ upvoteCount: 'desc' }, { createdAt: 'asc' }], + }, + }, + orderBy: [{ upvoteCount: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + ]); + return { items: rows.map((row) => publicComment(row, req.session?.userId)), total, page, limit, pages: Math.ceil(total / limit) }; + }); + + app.post('/api/v1/articles/:slug/comments', { preHandler: requireAuth() }, async (req, reply) => { + const parsed = commentBodySchema.safeParse(req.body); + if (!parsed.success) return badInput(reply); + const { slug } = req.params as { slug: string }; + const body = stripHtml(parsed.data.body); + if (!body) return badInput(reply); + const article = await prisma.article.findFirst({ + where: { slug, status: PUBLISHED, publishedAt: { not: null }, deletedAt: null }, + include: { author: { select: { userId: true, name: true } } }, + }); + if (!article) return reply.code(404).send({ error: 'not_found' }); + const parent = parsed.data.replyToId + ? await prisma.comment.findFirst({ where: { id: parsed.data.replyToId, articleId: article.id, deletedAt: null }, include: { author: true } }) + : null; + if (parsed.data.replyToId && !parent) return reply.code(404).send({ error: 'reply_not_found' }); + + const row = await prisma.comment.create({ + data: { articleId: article.id, authorId: req.session!.userId, replyToId: parent?.id ?? null, body }, + include: { + author: true, + likes: true, + upvotes: true, + replies: { include: { author: true, likes: true, upvotes: true } }, + }, + }); + const actor = row.author; + const payload = { + articleId: article.id, + articleSlug: article.slug, + articleTitle: article.title, + actorName: actor.name, + actorUsername: actor.username, + commentId: row.id, + }; + if (parent && parent.authorId !== req.session!.userId) { + await createNotification(parent.authorId, 'comment_reply', payload); + } else if (article.author.userId && article.author.userId !== req.session!.userId) { + await createNotification(article.author.userId, 'comment', payload); + } + await prisma.activityLog.create({ + data: { userId: req.session!.userId, action: 'نظر ثبت شد', target: article.title, type: 'comment' }, + }); + await publishRealtime({ event: 'activity', data: { type: 'comment', actor: actor.username ?? actor.name, target: article.slug, ts: new Date().toISOString() } }); + await recordCommentEvent(); + return { comment: publicComment(row, req.session!.userId) }; + }); + + app.post('/api/v1/comments/:id/like', { preHandler: requireAuth() }, async (req, reply) => { + const { id } = req.params as { id: string }; + const comment = await prisma.comment.findUnique({ where: { id }, select: { id: true } }); + if (!comment) return reply.code(404).send({ error: 'not_found' }); + const key = { commentId_userId: { commentId: id, userId: req.session!.userId } }; + const existing = await prisma.commentLike.findUnique({ where: key }); + if (existing) await prisma.commentLike.delete({ where: key }); + else await prisma.commentLike.create({ data: { commentId: id, userId: req.session!.userId } }); + const likeCount = await prisma.commentLike.count({ where: { commentId: id } }); + await prisma.comment.update({ where: { id }, data: { likeCount } }); + return { ok: true, liked: !existing, likeCount }; + }); + + app.post('/api/v1/comments/:id/upvote', { preHandler: requireAuth() }, async (req, reply) => { + const { id } = req.params as { id: string }; + const comment = await prisma.comment.findUnique({ where: { id }, select: { id: true } }); + if (!comment) return reply.code(404).send({ error: 'not_found' }); + const key = { commentId_userId: { commentId: id, userId: req.session!.userId } }; + const existing = await prisma.commentUpvote.findUnique({ where: key }); + if (existing) await prisma.commentUpvote.delete({ where: key }); + else await prisma.commentUpvote.create({ data: { commentId: id, userId: req.session!.userId } }); + const upvoteCount = await prisma.commentUpvote.count({ where: { commentId: id } }); + await prisma.comment.update({ where: { id }, data: { upvoteCount } }); + return { ok: true, upvoted: !existing, upvoteCount }; + }); + + app.post('/api/v1/comments/:id/flag', { preHandler: requireAuth() }, async (req, reply) => { + const { id } = req.params as { id: string }; + const comment = await prisma.comment.findUnique({ where: { id } }); + if (!comment) return reply.code(404).send({ error: 'not_found' }); + const created = await prisma.commentFlag.create({ + data: { commentId: comment.id, userId: req.session!.userId }, + }).then(() => true).catch((e: any) => { + if (e?.code === 'P2002') return false; + throw e; + }); + if (!created) return { ok: true, alreadyFlagged: true, flagCount: comment.flagCount, flagged: comment.flagged }; + const flagCount = await prisma.commentFlag.count({ where: { commentId: comment.id } }); + const updated = await prisma.comment.update({ + where: { id: comment.id }, + data: { flagCount, flagged: flagCount >= COMMENT_FLAG_THRESHOLD }, + }); + return { ok: true, alreadyFlagged: false, flagCount: updated.flagCount, flagged: updated.flagged }; + }); + + app.get('/api/v1/admin/comments', { preHandler: requireAuth(CONTENT_ROLES) }, async (req, reply) => { + const parsed = listSchema.safeParse(req.query); + if (!parsed.success) return badInput(reply); + const { page, limit, flagged } = parsed.data; + const where: Prisma.CommentWhereInput = { + deletedAt: null, + ...(flagged !== undefined ? { flagged } : {}), + ...(req.session!.role === 'writer' ? { + article: { OR: [{ createdById: req.session!.userId }, { author: { userId: req.session!.userId } }] }, + } : {}), + }; + const [total, rows] = await Promise.all([ + prisma.comment.count({ where }), + prisma.comment.findMany({ + where, + include: { author: true, article: { select: { title: true, slug: true } } }, + orderBy: [{ flagged: 'desc' }, { createdAt: 'desc' }], + skip: (page - 1) * limit, + take: limit, + }), + ]); + return { + items: rows.map((row) => ({ + id: row.id, + body: row.body, + flagCount: row.flagCount, + flagged: row.flagged, + createdAt: row.createdAt.toISOString(), + time: faDate(row.createdAt), + articleTitle: row.article.title, + articleSlug: row.article.slug, + authorName: row.author.name, + authorUsername: row.author.username, + })), + total, + page, + limit, + pages: Math.ceil(total / limit), + }; + }); + + app.delete('/api/v1/admin/comments/:id', { preHandler: requireAuth(CONTENT_ROLES) }, async (req, reply) => { + const { id } = req.params as { id: string }; + if (!(await canDeleteComment(req.session!, id))) return reply.code(403).send({ error: 'forbidden' }); + const row = await prisma.comment.update({ where: { id }, data: { deletedAt: new Date() } }).catch(() => null); + if (!row) return reply.code(404).send({ error: 'not_found' }); + return { ok: true }; + }); + + app.post('/api/v1/admin/comments/bulk', { preHandler: requireAuth(CONTENT_ROLES) }, async (req, reply) => { + const parsed = bulkSchema.safeParse(req.body); + if (!parsed.success) return badInput(reply); + const ids = [...new Set(parsed.data.ids)]; + const allowed = []; + for (const id of ids) { + if (await canDeleteComment(req.session!, id)) allowed.push(id); + } + if (allowed.length === 0) return reply.code(403).send({ error: 'forbidden' }); + if (parsed.data.action === 'approve') { + const result = await prisma.comment.updateMany({ + where: { id: { in: allowed } }, + data: { flagged: false, flagCount: 0 }, + }); + await prisma.commentFlag.deleteMany({ where: { commentId: { in: allowed } } }); + return { ok: true, updated: result.count, deleted: 0 }; + } + const result = await prisma.comment.updateMany({ where: { id: { in: allowed } }, data: { deletedAt: new Date() } }); + return { ok: true, updated: 0, deleted: result.count }; + }); + + app.put('/api/v1/admin/comments/:id/unflag', { preHandler: requireAuth(EDITOR_ROLES) }, async (req, reply) => { + const { id } = req.params as { id: string }; + const row = await prisma.comment.update({ + where: { id }, + data: { flagged: false, flagCount: 0, flags: { deleteMany: {} } }, + }).catch(() => null); + if (!row) return reply.code(404).send({ error: 'not_found' }); + return { ok: true }; + }); + + app.delete('/api/v1/admin/comments', { preHandler: requireAuth(EDITOR_ROLES) }, async (_req, _reply) => { + const { count } = await prisma.comment.updateMany({ where: { flagged: true, deletedAt: null }, data: { deletedAt: new Date() } }); + return { ok: true, deleted: count }; + }); +}