From 026e748bf8d47d8c9d9abc2a118472f4031081ba Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:53:41 -0500 Subject: [PATCH] handling closing posts --- backend/models/ForumPost.js | 12 + backend/models/index.js | 1 + backend/routes/forum.js | 336 +++++++++++++++++- .../services/email/core/TemplateManager.js | 1 + .../email/domain/ForumEmailService.js | 71 ++++ backend/templates/emails/forumPostClosed.html | 266 ++++++++++++++ frontend/src/pages/ForumPostDetail.tsx | 102 +++++- frontend/src/services/api.ts | 2 + frontend/src/types/index.ts | 3 + 9 files changed, 770 insertions(+), 24 deletions(-) create mode 100644 backend/templates/emails/forumPostClosed.html diff --git a/backend/models/ForumPost.js b/backend/models/ForumPost.js index 8890c86..5bbf877 100644 --- a/backend/models/ForumPost.js +++ b/backend/models/ForumPost.js @@ -72,6 +72,18 @@ const ForumPost = sequelize.define('ForumPost', { deletedAt: { type: DataTypes.DATE, allowNull: true + }, + closedBy: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'Users', + key: 'id' + } + }, + closedAt: { + type: DataTypes.DATE, + allowNull: true } }); diff --git a/backend/models/index.js b/backend/models/index.js index 1b8c965..b084485 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -35,6 +35,7 @@ Message.belongsTo(Message, { // Forum associations User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" }); ForumPost.belongsTo(User, { as: "author", foreignKey: "authorId" }); +ForumPost.belongsTo(User, { as: "closer", foreignKey: "closedBy" }); User.hasMany(ForumComment, { as: "forumComments", foreignKey: "authorId" }); ForumComment.belongsTo(User, { as: "author", foreignKey: "authorId" }); diff --git a/backend/routes/forum.js b/backend/routes/forum.js index 07a2e27..ac68bb2 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -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; diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index baa459f..737750e 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -62,6 +62,7 @@ class TemplateManager { "forumReplyToCommentAuthor.html", "forumAnswerAcceptedToCommentAuthor.html", "forumThreadActivityToParticipant.html", + "forumPostClosed.html", ]; for (const templateFile of templateFiles) { diff --git a/backend/services/email/domain/ForumEmailService.js b/backend/services/email/domain/ForumEmailService.js index c51232d..0c52c00 100644 --- a/backend/services/email/domain/ForumEmailService.js +++ b/backend/services/email/domain/ForumEmailService.js @@ -313,6 +313,77 @@ class ForumEmailService { return { success: false, error: error.message }; } } + + /** + * Send notification when a discussion is closed + * @param {Object} recipient - Recipient user object + * @param {string} recipient.firstName - Recipient's first name + * @param {string} recipient.email - Recipient's email + * @param {Object} closer - User who closed the discussion (can be admin or post author) + * @param {string} closer.firstName - Closer's first name + * @param {string} closer.lastName - Closer's last name + * @param {Object} post - Forum post object + * @param {number} post.id - Post ID + * @param {string} post.title - Post title + * @param {Date} closedAt - Timestamp when discussion was closed + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendForumPostClosedNotification( + recipient, + closer, + post, + closedAt + ) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const postUrl = `${frontendUrl}/forum/posts/${post.id}`; + + const timestamp = new Date(closedAt).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); + + const variables = { + recipientName: recipient.firstName || "there", + adminName: + `${closer.firstName} ${closer.lastName}`.trim() || "A user", + postTitle: post.title, + postUrl: postUrl, + timestamp: timestamp, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumPostClosed", + variables + ); + + const subject = `Discussion closed: ${post.title}`; + + const result = await this.emailClient.sendEmail( + recipient.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Forum post closed notification email sent to ${recipient.email}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send forum post closed notification email:", + error + ); + return { success: false, error: error.message }; + } + } } module.exports = ForumEmailService; diff --git a/backend/templates/emails/forumPostClosed.html b/backend/templates/emails/forumPostClosed.html new file mode 100644 index 0000000..b7f9946 --- /dev/null +++ b/backend/templates/emails/forumPostClosed.html @@ -0,0 +1,266 @@ + + + + + + + Discussion Closed + + + +
+
+ +
Forum Notification
+
+ +
+

Hi {{recipientName}},

+ +

A discussion has been closed

+ +

The following forum discussion has been closed:

+ +
+
{{postTitle}}
+
+ +
+
Closed by
+
{{adminName}}
+
{{timestamp}}
+
+ +
+

Note: This discussion is now closed and no new comments can be added. You can still view the existing discussion and all previous comments.

+
+ + View Discussion + +

If you have questions about this closure, you can reach out to the person who closed it or contact our support team.

+
+ + +
+ + diff --git a/frontend/src/pages/ForumPostDetail.tsx b/frontend/src/pages/ForumPostDetail.tsx index 7feb008..6e05a8c 100644 --- a/frontend/src/pages/ForumPostDetail.tsx +++ b/frontend/src/pages/ForumPostDetail.tsx @@ -20,7 +20,7 @@ const ForumPostDetail: React.FC = () => { const [actionLoading, setActionLoading] = useState(false); const [showAdminModal, setShowAdminModal] = useState(false); const [adminAction, setAdminAction] = useState<{ - type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment'; + type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment' | 'closePost' | 'reopenPost'; id?: string; } | null>(null); @@ -151,6 +151,16 @@ const ForumPostDetail: React.FC = () => { setShowAdminModal(true); }; + const handleAdminClosePost = async () => { + setAdminAction({ type: 'closePost' }); + setShowAdminModal(true); + }; + + const handleAdminReopenPost = async () => { + setAdminAction({ type: 'reopenPost' }); + setShowAdminModal(true); + }; + const handleAdminDeleteComment = async (commentId: string) => { setAdminAction({ type: 'deleteComment', id: commentId }); setShowAdminModal(true); @@ -175,6 +185,12 @@ const ForumPostDetail: React.FC = () => { case 'restorePost': await forumAPI.adminRestorePost(id!); break; + case 'closePost': + await forumAPI.adminClosePost(id!); + break; + case 'reopenPost': + await forumAPI.adminReopenPost(id!); + break; case 'deleteComment': await forumAPI.adminDeleteComment(adminAction.id!); break; @@ -199,7 +215,13 @@ const ForumPostDetail: React.FC = () => { const formatDate = (dateString: string) => { const date = new Date(dateString); - return date.toLocaleString(); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }); }; if (loading) { @@ -310,9 +332,16 @@ const ForumPostDetail: React.FC = () => { {isAuthor && (
+ + + Edit + {post.status !== 'closed' && ( )} - - - Edit - + ) : ( + + )} +
+ )} + + {post.status !== 'closed' && user ? (
Add a comment
{ buttonText="Post Comment" />
- ) : ( + ) : post.status !== 'closed' && !user ? (
Log in to join the discussion.
+ ) : null} + + {/* Show closed banner at the bottom for all users */} + {post.status === 'closed' && post.closedBy && ( +
+ + + Closed by {post.closer ? `${post.closer.firstName} ${post.closer.lastName}` : 'Unknown'} + + {post.closedAt && ` on ${formatDate(post.closedAt)}`} + {' - '}No new comments can be added +
)} @@ -466,6 +525,12 @@ const ForumPostDetail: React.FC = () => { {adminAction?.type === 'restorePost' && (

Are you sure you want to restore this post? It will become visible to all users again.

)} + {adminAction?.type === 'closePost' && ( +

Are you sure you want to close this discussion? No users will be able to add new comments. All participants will be notified by email.

+ )} + {adminAction?.type === 'reopenPost' && ( +

Are you sure you want to reopen this discussion? Users will be able to add comments again.

+ )} {adminAction?.type === 'deleteComment' && (

Are you sure you want to delete this comment? It will be deleted and hidden from regular users but can be restored later.

)} @@ -484,7 +549,11 @@ const ForumPostDetail: React.FC = () => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 07b169e..de41027 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -286,6 +286,8 @@ export const forumAPI = { adminRestorePost: (id: string) => api.patch(`/forum/admin/posts/${id}/restore`), adminDeleteComment: (id: string) => api.delete(`/forum/admin/comments/${id}`), adminRestoreComment: (id: string) => api.patch(`/forum/admin/comments/${id}/restore`), + adminClosePost: (id: string) => api.patch(`/forum/admin/posts/${id}/close`), + adminReopenPost: (id: string) => api.patch(`/forum/admin/posts/${id}/reopen`), }; export const stripeAPI = { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bba2ac9..58fa000 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -271,6 +271,9 @@ export interface ForumPost { isDeleted?: boolean; deletedBy?: string; deletedAt?: string; + closedBy?: string; + closedAt?: string; + closer?: User; hasDeletedComments?: boolean; author?: User; tags?: PostTag[];