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
+
+
+
+
+
+
+
+
Hi {{recipientName}},
+
+
A discussion has been closed
+
+
The following forum discussion has been closed:
+
+
+
+
+
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' && (
@@ -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 = () => {
@@ -495,7 +564,10 @@ const ForumPostDetail: React.FC = () => {
>
) : (
<>
- {adminAction?.type.includes('delete') ? 'Delete' : 'Restore'}
+ {adminAction?.type.includes('delete') ? 'Delete' :
+ adminAction?.type === 'closePost' ? 'Close' :
+ adminAction?.type === 'reopenPost' ? 'Reopen' :
+ 'Restore'}
>
)}
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[];