720 lines
18 KiB
JavaScript
720 lines
18 KiB
JavaScript
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: [
|
|
[{ 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, 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', '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: ['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: ['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, 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;
|