essential forum code
This commit is contained in:
641
backend/routes/forum.js
Normal file
641
backend/routes/forum.js
Normal file
@@ -0,0 +1,641 @@
|
||||
const express = require('express');
|
||||
const { Op } = require('sequelize');
|
||||
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
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: ['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: ['tagName']
|
||||
},
|
||||
{
|
||||
model: ForumComment,
|
||||
as: 'comments',
|
||||
where: { isDeleted: false },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'author',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
}
|
||||
],
|
||||
order: [['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, async (req, res) => {
|
||||
try {
|
||||
const { title, content, category, tags } = req.body;
|
||||
|
||||
const post = await ForumPost.create({
|
||||
title,
|
||||
content,
|
||||
category,
|
||||
authorId: req.user.id
|
||||
});
|
||||
|
||||
// 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: ['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: ['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', 'solved', '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: ['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 });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/forum/posts/:id/comments - Add comment/reply
|
||||
router.post('/posts/:id/comments', authenticateToken, 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' });
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await ForumComment.create({
|
||||
postId: req.params.id,
|
||||
authorId: req.user.id,
|
||||
content,
|
||||
parentCommentId: parentCommentId || null
|
||||
});
|
||||
|
||||
// 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: ['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;
|
||||
Reference in New Issue
Block a user