const express = require('express'); const { Op } = require('sequelize'); const { ForumPost, ForumComment, PostTag, User } = require('../models'); const { authenticateToken } = require('../middleware/auth'); const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload'); const logger = require('../utils/logger'); const router = express.Router(); // Helper function to build nested comment tree const buildCommentTree = (comments) => { const commentMap = {}; const rootComments = []; // Create a map of all comments comments.forEach(comment => { commentMap[comment.id] = { ...comment.toJSON(), replies: [] }; }); // Build the tree structure comments.forEach(comment => { if (comment.parentCommentId && commentMap[comment.parentCommentId]) { commentMap[comment.parentCommentId].replies.push(commentMap[comment.id]); } else if (!comment.parentCommentId) { rootComments.push(commentMap[comment.id]); } }); return rootComments; }; // GET /api/forum/posts - Browse all posts router.get('/posts', async (req, res) => { try { const { search, category, tag, status, page = 1, limit = 20, sort = 'recent' } = req.query; const where = {}; if (category) { where.category = category; } if (status) { where.status = status; } if (search) { where[Op.or] = [ { title: { [Op.iLike]: `%${search}%` } }, { content: { [Op.iLike]: `%${search}%` } } ]; } const offset = (page - 1) * limit; // Determine sort order let order; switch (sort) { case 'comments': order = [['commentCount', 'DESC'], ['createdAt', 'DESC']]; break; case 'views': order = [['viewCount', 'DESC'], ['createdAt', 'DESC']]; break; case 'recent': default: order = [['isPinned', 'DESC'], ['updatedAt', 'DESC']]; break; } const include = [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] }, { model: PostTag, as: 'tags', attributes: ['id', 'tagName'] } ]; // Filter by tag if provided if (tag) { include[1].where = { tagName: tag }; include[1].required = true; } const { count, rows } = await ForumPost.findAndCountAll({ where, include, limit: parseInt(limit), offset: parseInt(offset), order, distinct: true }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum posts fetched", { search, category, tag, status, postsCount: count, page: parseInt(page), limit: parseInt(limit) }); res.json({ posts: rows, totalPages: Math.ceil(count / limit), currentPage: parseInt(page), totalPosts: count }); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum posts fetch failed", { error: error.message, stack: error.stack, query: req.query }); res.status(500).json({ error: error.message }); } }); // GET /api/forum/posts/:id - Get single post with all comments router.get('/posts/:id', async (req, res) => { try { const post = await ForumPost.findByPk(req.params.id, { include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] }, { model: PostTag, as: 'tags', attributes: ['id', 'tagName'] }, { model: ForumComment, as: 'comments', where: { isDeleted: false }, required: false, include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] } ] } ], order: [ [{ model: ForumComment, as: 'comments' }, 'createdAt', 'ASC'] ] }); if (!post) { return res.status(404).json({ error: 'Post not found' }); } // Increment view count await post.increment('viewCount'); // Build nested comment tree const postData = post.toJSON(); postData.comments = buildCommentTree(post.comments); postData.viewCount += 1; // Reflect the increment const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum post fetched", { postId: req.params.id, authorId: post.authorId }); res.json(postData); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum post fetch failed", { error: error.message, stack: error.stack, postId: req.params.id }); res.status(500).json({ error: error.message }); } }); // POST /api/forum/posts - Create new post router.post('/posts', authenticateToken, uploadForumPostImages, async (req, res) => { try { let { title, content, category, tags } = req.body; // Parse tags if they come as JSON string (from FormData) if (typeof tags === 'string') { try { tags = JSON.parse(tags); } catch (e) { tags = []; } } // Extract image filenames if uploaded const images = req.files ? req.files.map(file => file.filename) : []; const post = await ForumPost.create({ title, content, category, authorId: req.user.id, images }); // Create tags if provided if (tags && Array.isArray(tags) && tags.length > 0) { const tagPromises = tags.map(tagName => PostTag.create({ postId: post.id, tagName: tagName.toLowerCase().trim() }) ); await Promise.all(tagPromises); } const postWithDetails = await ForumPost.findByPk(post.id, { include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] }, { model: PostTag, as: 'tags', attributes: ['id', 'tagName'] } ] }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum post created", { postId: post.id, authorId: req.user.id, category, title }); res.status(201).json(postWithDetails); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum post creation failed", { error: error.message, stack: error.stack, authorId: req.user.id, postData: logger.sanitize(req.body) }); res.status(500).json({ error: error.message }); } }); // PUT /api/forum/posts/:id - Update post router.put('/posts/:id', authenticateToken, async (req, res) => { try { const post = await ForumPost.findByPk(req.params.id); if (!post) { return res.status(404).json({ error: 'Post not found' }); } if (post.authorId !== req.user.id) { return res.status(403).json({ error: 'Unauthorized' }); } const { title, content, category, tags } = req.body; await post.update({ title, content, category }); // Update tags if provided if (tags !== undefined) { // Delete existing tags await PostTag.destroy({ where: { postId: post.id } }); // Create new tags if (Array.isArray(tags) && tags.length > 0) { const tagPromises = tags.map(tagName => PostTag.create({ postId: post.id, tagName: tagName.toLowerCase().trim() }) ); await Promise.all(tagPromises); } } const updatedPost = await ForumPost.findByPk(post.id, { include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] }, { model: PostTag, as: 'tags', attributes: ['id', 'tagName'] } ] }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum post updated", { postId: req.params.id, authorId: req.user.id }); res.json(updatedPost); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum post update failed", { error: error.message, stack: error.stack, postId: req.params.id, authorId: req.user.id }); res.status(500).json({ error: error.message }); } }); // DELETE /api/forum/posts/:id - Delete post router.delete('/posts/:id', authenticateToken, async (req, res) => { try { const post = await ForumPost.findByPk(req.params.id); if (!post) { return res.status(404).json({ error: 'Post not found' }); } if (post.authorId !== req.user.id) { return res.status(403).json({ error: 'Unauthorized' }); } // Delete associated tags and comments await PostTag.destroy({ where: { postId: post.id } }); await ForumComment.destroy({ where: { postId: post.id } }); await post.destroy(); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum post deleted", { postId: req.params.id, authorId: req.user.id }); res.status(204).send(); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum post deletion failed", { error: error.message, stack: error.stack, postId: req.params.id, authorId: req.user.id }); res.status(500).json({ error: error.message }); } }); // PATCH /api/forum/posts/:id/status - Update post status router.patch('/posts/:id/status', authenticateToken, async (req, res) => { try { const { status } = req.body; const post = await ForumPost.findByPk(req.params.id); if (!post) { return res.status(404).json({ error: 'Post not found' }); } if (post.authorId !== req.user.id) { return res.status(403).json({ error: 'Only the author can update post status' }); } if (!['open', 'answered', 'closed'].includes(status)) { return res.status(400).json({ error: 'Invalid status value' }); } await post.update({ status }); const updatedPost = await ForumPost.findByPk(post.id, { include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] }, { model: PostTag, as: 'tags', attributes: ['id', 'tagName'] } ] }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum post status updated", { postId: req.params.id, newStatus: status, authorId: req.user.id }); res.json(updatedPost); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum post status update failed", { error: error.message, stack: error.stack, postId: req.params.id, authorId: req.user.id }); res.status(500).json({ error: error.message }); } }); // PATCH /api/forum/posts/:id/accept-answer - Mark/unmark comment as accepted answer router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) => { try { const { commentId } = req.body; const post = await ForumPost.findByPk(req.params.id); if (!post) { return res.status(404).json({ error: 'Post not found' }); } if (post.authorId !== req.user.id) { return res.status(403).json({ error: 'Only the post author can mark answers' }); } // If commentId is provided, validate it if (commentId) { const comment = await ForumComment.findByPk(commentId); if (!comment) { return res.status(404).json({ error: 'Comment not found' }); } if (comment.postId !== post.id) { return res.status(400).json({ error: 'Comment does not belong to this post' }); } if (comment.isDeleted) { return res.status(400).json({ error: 'Cannot mark deleted comment as answer' }); } if (comment.parentCommentId) { return res.status(400).json({ error: 'Only top-level comments can be marked as answers' }); } } // Update the post with accepted answer await post.update({ acceptedAnswerId: commentId || null, status: commentId ? 'answered' : 'open' }); const updatedPost = await ForumPost.findByPk(post.id, { include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] }, { model: PostTag, as: 'tags', attributes: ['id', 'tagName'] } ] }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Answer marked/unmarked", { postId: req.params.id, commentId: commentId || 'unmarked', authorId: req.user.id }); res.json(updatedPost); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Mark answer failed", { error: error.message, stack: error.stack, postId: req.params.id, authorId: req.user.id }); res.status(500).json({ error: error.message }); } }); // POST /api/forum/posts/:id/comments - Add comment/reply router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages, async (req, res) => { try { const { content, parentCommentId } = req.body; const post = await ForumPost.findByPk(req.params.id); if (!post) { return res.status(404).json({ error: 'Post not found' }); } // Validate parent comment if provided if (parentCommentId) { const parentComment = await ForumComment.findByPk(parentCommentId); if (!parentComment || parentComment.postId !== post.id) { return res.status(400).json({ error: 'Invalid parent comment' }); } } // Extract image filenames if uploaded const images = req.files ? req.files.map(file => file.filename) : []; const comment = await ForumComment.create({ postId: req.params.id, authorId: req.user.id, content, parentCommentId: parentCommentId || null, images }); // Increment comment count await post.increment('commentCount'); const commentWithDetails = await ForumComment.findByPk(comment.id, { include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] } ] }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum comment created", { postId: req.params.id, commentId: comment.id, authorId: req.user.id, isReply: !!parentCommentId }); res.status(201).json(commentWithDetails); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum comment creation failed", { error: error.message, stack: error.stack, postId: req.params.id, authorId: req.user.id }); res.status(500).json({ error: error.message }); } }); // PUT /api/forum/comments/:id - Edit comment router.put('/comments/:id', authenticateToken, async (req, res) => { try { const { content } = req.body; const comment = await ForumComment.findByPk(req.params.id); if (!comment) { return res.status(404).json({ error: 'Comment not found' }); } if (comment.authorId !== req.user.id) { return res.status(403).json({ error: 'Unauthorized' }); } if (comment.isDeleted) { return res.status(400).json({ error: 'Cannot edit deleted comment' }); } await comment.update({ content }); const updatedComment = await ForumComment.findByPk(comment.id, { include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] } ] }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum comment updated", { commentId: req.params.id, authorId: req.user.id }); res.json(updatedComment); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum comment update failed", { error: error.message, stack: error.stack, commentId: req.params.id, authorId: req.user.id }); res.status(500).json({ error: error.message }); } }); // DELETE /api/forum/comments/:id - Soft delete comment router.delete('/comments/:id', authenticateToken, async (req, res) => { try { const comment = await ForumComment.findByPk(req.params.id); if (!comment) { return res.status(404).json({ error: 'Comment not found' }); } if (comment.authorId !== req.user.id) { return res.status(403).json({ error: 'Unauthorized' }); } // Soft delete await comment.update({ isDeleted: true, content: '[deleted]' }); // Decrement comment count const post = await ForumPost.findByPk(comment.postId); if (post && post.commentCount > 0) { await post.decrement('commentCount'); } const reqLogger = logger.withRequestId(req.id); reqLogger.info("Forum comment deleted", { commentId: req.params.id, authorId: req.user.id, postId: comment.postId }); res.status(204).send(); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Forum comment deletion failed", { error: error.message, stack: error.stack, commentId: req.params.id, authorId: req.user.id }); res.status(500).json({ error: error.message }); } }); // GET /api/forum/my-posts - Get user's posts router.get('/my-posts', authenticateToken, async (req, res) => { try { const posts = await ForumPost.findAll({ where: { authorId: req.user.id }, include: [ { model: User, as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] }, { model: PostTag, as: 'tags', attributes: ['id', 'tagName'] } ], order: [['createdAt', 'DESC']] }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("User forum posts fetched", { userId: req.user.id, postsCount: posts.length }); res.json(posts); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("User forum posts fetch failed", { error: error.message, stack: error.stack, userId: req.user.id }); res.status(500).json({ error: error.message }); } }); // GET /api/forum/tags - Get all unique tags for autocomplete router.get('/tags', async (req, res) => { try { const { search } = req.query; const where = {}; if (search) { where.tagName = { [Op.iLike]: `%${search}%` }; } const tags = await PostTag.findAll({ where, attributes: [ 'tagName', [require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'count'] ], group: ['tagName'], order: [[require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'DESC']], limit: 50 }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Tags fetched", { search, tagsCount: tags.length }); res.json(tags); } catch (error) { const reqLogger = logger.withRequestId(req.id); reqLogger.error("Tags fetch failed", { error: error.message, stack: error.stack, query: req.query }); res.status(500).json({ error: error.message }); } }); module.exports = router;