handling closing posts

This commit is contained in:
jackiettran
2025-11-17 17:53:41 -05:00
parent e260992ef2
commit 026e748bf8
9 changed files with 770 additions and 24 deletions

View File

@@ -170,6 +170,12 @@ router.get('/posts/:id', optionalAuth, async (req, res) => {
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'role']
},
{
model: User,
as: 'closer',
attributes: ['id', 'username', 'firstName', 'lastName', 'role'],
required: false
},
{
model: PostTag,
as: 'tags',
@@ -202,8 +208,8 @@ router.get('/posts/:id', optionalAuth, async (req, res) => {
return res.status(404).json({ error: 'Post not found' });
}
// Increment view count
await post.increment('viewCount');
// 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';
@@ -424,7 +430,94 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
return res.status(400).json({ error: 'Invalid status value' });
}
await post.update({ status });
// 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: [
@@ -433,6 +526,12 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName']
},
{
model: User,
as: 'closer',
attributes: ['id', 'username', 'firstName', 'lastName'],
required: false
},
{
model: PostTag,
as: 'tags',
@@ -496,13 +595,15 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
}
}
// Update the post with accepted answer
// Update the post with accepted answer and close the discussion
await post.update({
acceptedAnswerId: commentId || null,
status: commentId ? 'answered' : 'open'
status: commentId ? 'closed' : 'open',
closedBy: commentId ? req.user.id : null,
closedAt: commentId ? new Date() : null
});
// Send email notification if marking an answer (not unmarking)
// Send email notifications if marking an answer (not unmarking)
if (commentId) {
(async () => {
try {
@@ -520,7 +621,17 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
attributes: ['id', 'username', 'firstName', 'lastName', 'email']
});
// Only send email if not marking your own comment as answer
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,
@@ -529,9 +640,55 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
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 answer accepted notification email", {
logger.error("Failed to send notification emails", {
error: emailError.message,
stack: emailError.stack,
commentId: commentId,
@@ -587,6 +744,11 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
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);
@@ -606,8 +768,9 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
images
});
// Increment comment count
// 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: [
@@ -1073,4 +1236,159 @@ router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, asy
}
});
// 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;