Files
rentall-app/backend/routes/forum.js
2025-11-17 17:53:41 -05:00

1395 lines
40 KiB
JavaScript

const express = require('express');
const { Op } = require('sequelize');
const { ForumPost, ForumComment, PostTag, User } = require('../models');
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
const logger = require('../utils/logger');
const emailServices = require('../services/email');
const router = express.Router();
// Helper function to build nested comment tree
const buildCommentTree = (comments, isAdmin = false) => {
const commentMap = {};
const rootComments = [];
// Create a map of all comments
comments.forEach(comment => {
const commentJson = comment.toJSON();
// Sanitize deleted comments for non-admin users
if (commentJson.isDeleted && !isAdmin) {
commentJson.content = '';
commentJson.images = [];
}
commentMap[comment.id] = { ...commentJson, 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', optionalAuth, async (req, res) => {
try {
const {
search,
category,
tag,
status,
page = 1,
limit = 20,
sort = 'recent'
} = req.query;
const where = {};
// Filter out deleted posts unless user is admin
if (!req.user || req.user.role !== 'admin') {
where.isDeleted = false;
}
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']
},
{
model: ForumComment,
as: 'comments',
attributes: ['id', 'isDeleted'],
required: false
}
];
// 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
});
// Add hasDeletedComments flag to each post
const postsWithFlags = rows.map(post => {
const postJson = post.toJSON();
postJson.hasDeletedComments = postJson.comments?.some(c => c.isDeleted) || false;
// Remove comments array to reduce response size (only needed the flag)
delete postJson.comments;
return postJson;
});
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: postsWithFlags,
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', optionalAuth, async (req, res) => {
try {
const post = await ForumPost.findByPk(req.params.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'role']
},
{
model: User,
as: 'closer',
attributes: ['id', 'username', 'firstName', 'lastName', 'role'],
required: false
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
},
{
model: ForumComment,
as: 'comments',
required: false,
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'role']
}
]
}
],
order: [
[{ model: ForumComment, as: 'comments' }, 'createdAt', 'ASC']
]
});
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
// Hide deleted posts from non-admins
if (post.isDeleted && (!req.user || req.user.role !== 'admin')) {
return res.status(404).json({ error: 'Post not found' });
}
// Increment view count without updating the updatedAt timestamp
await post.increment('viewCount', { silent: true });
// Build nested comment tree
const isAdmin = req.user && req.user.role === 'admin';
const postData = post.toJSON();
postData.comments = buildCommentTree(post.comments, isAdmin);
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' });
}
// Set closedBy and closedAt when closing, clear them when reopening
const updateData = {
status,
closedBy: status === 'closed' ? req.user.id : null,
closedAt: status === 'closed' ? new Date() : null
};
await post.update(updateData);
// Send email notifications when closing (not when reopening)
if (status === 'closed') {
(async () => {
try {
const closerUser = await User.findByPk(req.user.id, {
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
});
const postWithAuthor = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
]
});
// Get all unique participants (author + commenters)
const comments = await ForumComment.findAll({
where: {
postId: post.id,
isDeleted: false
},
include: [
{
model: User,
as: 'author',
attributes: ['id', 'email', 'firstName', 'lastName']
}
]
});
// Collect unique recipients (excluding the closer)
const recipientMap = new Map();
// Add post author if not the closer
if (postWithAuthor.author && postWithAuthor.author.id !== req.user.id) {
recipientMap.set(postWithAuthor.author.id, postWithAuthor.author);
}
// Add all comment authors (excluding closer)
comments.forEach(comment => {
if (comment.author && comment.author.id !== req.user.id && !recipientMap.has(comment.author.id)) {
recipientMap.set(comment.author.id, comment.author);
}
});
// Send emails to all recipients
const recipients = Array.from(recipientMap.values());
const emailPromises = recipients.map(recipient =>
emailServices.forum.sendForumPostClosedNotification(
recipient,
closerUser,
postWithAuthor,
updateData.closedAt
)
);
await Promise.allSettled(emailPromises);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Post closure notification emails sent", {
postId: post.id,
recipientCount: recipients.length,
closedBy: req.user.id
});
} catch (emailError) {
// Email errors don't block the closure
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Failed to send post closure notification emails", {
error: emailError.message,
stack: emailError.stack,
postId: req.params.id
});
console.error("Email notification error:", emailError);
}
})();
}
const updatedPost = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName']
},
{
model: User,
as: 'closer',
attributes: ['id', 'username', 'firstName', 'lastName'],
required: false
},
{
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 and close the discussion
await post.update({
acceptedAnswerId: commentId || null,
status: commentId ? 'closed' : 'open',
closedBy: commentId ? req.user.id : null,
closedAt: commentId ? new Date() : null
});
// Send email notifications if marking an answer (not unmarking)
if (commentId) {
(async () => {
try {
const comment = await ForumComment.findByPk(commentId, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
]
});
const postAuthor = await User.findByPk(req.user.id, {
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
});
const postWithAuthor = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
]
});
// Send answer accepted email if not marking your own comment as answer
if (comment && comment.authorId !== req.user.id) {
await emailServices.forum.sendForumAnswerAcceptedNotification(
comment.author,
postAuthor,
post,
comment
);
}
// Send post closure notifications to all participants
// Get all unique participants (author + commenters)
const comments = await ForumComment.findAll({
where: {
postId: post.id,
isDeleted: false
},
include: [
{
model: User,
as: 'author',
attributes: ['id', 'email', 'firstName', 'lastName']
}
]
});
// Collect unique recipients (excluding the post author who marked the answer)
const recipientMap = new Map();
// Don't send to post author since they closed it
// Add all comment authors (excluding post author)
comments.forEach(c => {
if (c.author && c.author.id !== req.user.id && !recipientMap.has(c.author.id)) {
recipientMap.set(c.author.id, c.author);
}
});
// Send closure notification emails to all recipients
const recipients = Array.from(recipientMap.values());
const closureEmailPromises = recipients.map(recipient =>
emailServices.forum.sendForumPostClosedNotification(
recipient,
postAuthor,
postWithAuthor,
new Date()
)
);
await Promise.allSettled(closureEmailPromises);
logger.info("Answer marked and closure notifications sent", {
postId: post.id,
commentId: commentId,
closureNotificationCount: recipients.length
});
} catch (emailError) {
// Email errors don't block answer marking
logger.error("Failed to send notification emails", {
error: emailError.message,
stack: emailError.stack,
commentId: commentId,
postId: req.params.id
});
console.error("Email notification error:", emailError);
}
})();
}
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' });
}
// Prevent comments on closed posts
if (post.status === 'closed') {
return res.status(403).json({ error: 'Cannot comment on a closed discussion' });
}
// 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 and update post's updatedAt to reflect activity
await post.increment('commentCount');
await post.update({ updatedAt: new Date() });
const commentWithDetails = await ForumComment.findByPk(comment.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
]
});
// Send email notifications (non-blocking)
(async () => {
try {
const commenter = commentWithDetails.author;
const notifiedUserIds = new Set();
// Reload post with author details for email
const postWithAuthor = await ForumPost.findByPk(req.params.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
]
});
// If this is a reply, send reply notification to parent comment author
if (parentCommentId) {
const parentComment = await ForumComment.findByPk(parentCommentId, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
]
});
// Send reply notification if not replying to yourself
if (parentComment && parentComment.authorId !== req.user.id) {
await emailServices.forum.sendForumReplyNotification(
parentComment.author,
commenter,
postWithAuthor,
commentWithDetails,
parentComment
);
notifiedUserIds.add(parentComment.authorId);
}
} else {
// Send comment notification to post author if not commenting on your own post
if (postWithAuthor.authorId !== req.user.id) {
await emailServices.forum.sendForumCommentNotification(
postWithAuthor.author,
commenter,
postWithAuthor,
commentWithDetails
);
notifiedUserIds.add(postWithAuthor.authorId);
}
}
// Get all unique participants who have commented on this post (excluding commenter and already notified)
const participants = await ForumComment.findAll({
where: {
postId: req.params.id,
authorId: {
[Op.notIn]: [req.user.id, ...Array.from(notifiedUserIds)]
},
isDeleted: false
},
attributes: ['authorId'],
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
],
group: ['ForumComment.authorId', 'author.id']
});
// Send thread activity notifications to all unique participants
for (const participant of participants) {
if (participant.author) {
await emailServices.forum.sendForumThreadActivityNotification(
participant.author,
commenter,
postWithAuthor,
commentWithDetails
);
}
}
} catch (emailError) {
// Email errors don't block comment creation
logger.error("Failed to send forum comment notification emails", {
error: emailError.message,
stack: emailError.stack,
commentId: comment.id,
postId: req.params.id
});
console.error("Email notification error:", emailError);
}
})();
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 (preserve content for potential restoration)
await comment.update({ isDeleted: true });
// 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 });
}
});
// ============ ADMIN ROUTES ============
// DELETE /api/forum/admin/posts/:id - Admin soft-delete post
router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
// Soft delete the post
await post.update({
isDeleted: true,
deletedBy: req.user.id,
deletedAt: new Date()
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin deleted post", {
postId: req.params.id,
adminId: req.user.id,
originalAuthorId: post.authorId
});
res.status(200).json({ message: 'Post deleted successfully', post });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin post deletion failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
// PATCH /api/forum/admin/posts/:id/restore - Admin restore deleted post
router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
try {
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
// Restore the post
await post.update({
isDeleted: false,
deletedBy: null,
deletedAt: null
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin restored post", {
postId: req.params.id,
adminId: req.user.id,
originalAuthorId: post.authorId
});
res.status(200).json({ message: 'Post restored successfully', post });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin post restoration failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
// DELETE /api/forum/admin/comments/:id - Admin soft-delete comment
router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const comment = await ForumComment.findByPk(req.params.id);
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
// Soft delete the comment (preserve original content for potential restoration)
await comment.update({
isDeleted: true,
deletedBy: req.user.id,
deletedAt: new Date()
});
// Decrement comment count if not already deleted
if (!comment.isDeleted) {
const post = await ForumPost.findByPk(comment.postId);
if (post && post.commentCount > 0) {
await post.decrement('commentCount');
}
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin deleted comment", {
commentId: req.params.id,
adminId: req.user.id,
originalAuthorId: comment.authorId,
postId: comment.postId
});
res.status(200).json({ message: 'Comment deleted successfully', comment });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin comment deletion failed", {
error: error.message,
stack: error.stack,
commentId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
// PATCH /api/forum/admin/comments/:id/restore - Admin restore deleted comment
router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, 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.isDeleted) {
return res.status(400).json({ error: 'Comment is not deleted' });
}
// Restore the comment with its original content
await comment.update({
isDeleted: false,
deletedBy: null,
deletedAt: null
});
// Increment comment count
const post = await ForumPost.findByPk(comment.postId);
if (post) {
await post.increment('commentCount');
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin restored comment", {
commentId: req.params.id,
adminId: req.user.id,
originalAuthorId: comment.authorId,
postId: comment.postId
});
res.status(200).json({
message: 'Comment restored successfully',
comment
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin comment restoration failed", {
error: error.message,
stack: error.stack,
commentId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
// PATCH /api/forum/admin/posts/:id/close - Admin close discussion
router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (req, res) => {
try {
const post = await ForumPost.findByPk(req.params.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
}
]
});
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
if (post.status === 'closed') {
return res.status(400).json({ error: 'Post is already closed' });
}
const closedAt = new Date();
// Close the post
await post.update({
status: 'closed',
closedBy: req.user.id,
closedAt: closedAt
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin closed post", {
postId: req.params.id,
adminId: req.user.id,
originalAuthorId: post.authorId
});
// Send email notifications asynchronously
(async () => {
try {
const admin = await User.findByPk(req.user.id, {
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
});
// Get all unique participants (author + commenters)
const comments = await ForumComment.findAll({
where: {
postId: post.id,
isDeleted: false
},
include: [
{
model: User,
as: 'author',
attributes: ['id', 'email', 'firstName', 'lastName']
}
]
});
// Collect unique recipients (author + all comment authors)
const recipientMap = new Map();
// Add post author if not the admin
if (post.author && post.author.id !== req.user.id) {
recipientMap.set(post.author.id, post.author);
}
// Add all comment authors (excluding admin)
comments.forEach(comment => {
if (comment.author && comment.author.id !== req.user.id && !recipientMap.has(comment.author.id)) {
recipientMap.set(comment.author.id, comment.author);
}
});
// Send emails to all recipients
const recipients = Array.from(recipientMap.values());
const emailPromises = recipients.map(recipient =>
emailServices.forum.sendForumPostClosedNotification(
recipient,
admin,
post,
closedAt
)
);
await Promise.allSettled(emailPromises);
reqLogger.info("Post closure notification emails sent", {
postId: post.id,
recipientCount: recipients.length
});
} catch (emailError) {
// Email errors don't block the closure
reqLogger.error("Failed to send post closure notification emails", {
error: emailError.message,
stack: emailError.stack,
postId: req.params.id
});
console.error("Email notification error:", emailError);
}
})();
res.status(200).json({ message: 'Post closed successfully', post });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin post closure failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
// PATCH /api/forum/admin/posts/:id/reopen - Admin reopen discussion
router.patch('/admin/posts/:id/reopen', authenticateToken, requireAdmin, 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.status !== 'closed') {
return res.status(400).json({ error: 'Post is not closed' });
}
// Reopen the post
await post.update({
status: 'open',
closedBy: null,
closedAt: null
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin reopened post", {
postId: req.params.id,
adminId: req.user.id,
originalAuthorId: post.authorId
});
res.status(200).json({ message: 'Post reopened successfully', post });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin post reopen failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
adminId: req.user.id
});
res.status(500).json({ error: error.message });
}
});
module.exports = router;