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

@@ -72,6 +72,18 @@ const ForumPost = sequelize.define('ForumPost', {
deletedAt: { deletedAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: true allowNull: true
},
closedBy: {
type: DataTypes.UUID,
allowNull: true,
references: {
model: 'Users',
key: 'id'
}
},
closedAt: {
type: DataTypes.DATE,
allowNull: true
} }
}); });

View File

@@ -35,6 +35,7 @@ Message.belongsTo(Message, {
// Forum associations // Forum associations
User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" }); User.hasMany(ForumPost, { as: "forumPosts", foreignKey: "authorId" });
ForumPost.belongsTo(User, { as: "author", foreignKey: "authorId" }); ForumPost.belongsTo(User, { as: "author", foreignKey: "authorId" });
ForumPost.belongsTo(User, { as: "closer", foreignKey: "closedBy" });
User.hasMany(ForumComment, { as: "forumComments", foreignKey: "authorId" }); User.hasMany(ForumComment, { as: "forumComments", foreignKey: "authorId" });
ForumComment.belongsTo(User, { as: "author", foreignKey: "authorId" }); ForumComment.belongsTo(User, { as: "author", foreignKey: "authorId" });

View File

@@ -170,6 +170,12 @@ router.get('/posts/:id', optionalAuth, async (req, res) => {
as: 'author', as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName', 'role'] attributes: ['id', 'username', 'firstName', 'lastName', 'role']
}, },
{
model: User,
as: 'closer',
attributes: ['id', 'username', 'firstName', 'lastName', 'role'],
required: false
},
{ {
model: PostTag, model: PostTag,
as: 'tags', as: 'tags',
@@ -202,8 +208,8 @@ router.get('/posts/:id', optionalAuth, async (req, res) => {
return res.status(404).json({ error: 'Post not found' }); return res.status(404).json({ error: 'Post not found' });
} }
// Increment view count // Increment view count without updating the updatedAt timestamp
await post.increment('viewCount'); await post.increment('viewCount', { silent: true });
// Build nested comment tree // Build nested comment tree
const isAdmin = req.user && req.user.role === 'admin'; 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' }); 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, { const updatedPost = await ForumPost.findByPk(post.id, {
include: [ include: [
@@ -433,6 +526,12 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => {
as: 'author', as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName'] attributes: ['id', 'username', 'firstName', 'lastName']
}, },
{
model: User,
as: 'closer',
attributes: ['id', 'username', 'firstName', 'lastName'],
required: false
},
{ {
model: PostTag, model: PostTag,
as: 'tags', 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({ await post.update({
acceptedAnswerId: commentId || null, 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) { if (commentId) {
(async () => { (async () => {
try { try {
@@ -520,7 +621,17 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
attributes: ['id', 'username', 'firstName', 'lastName', 'email'] 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) { if (comment && comment.authorId !== req.user.id) {
await emailServices.forum.sendForumAnswerAcceptedNotification( await emailServices.forum.sendForumAnswerAcceptedNotification(
comment.author, comment.author,
@@ -529,9 +640,55 @@ router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) =>
comment 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) { } catch (emailError) {
// Email errors don't block answer marking // 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, error: emailError.message,
stack: emailError.stack, stack: emailError.stack,
commentId: commentId, commentId: commentId,
@@ -587,6 +744,11 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
return res.status(404).json({ error: 'Post not found' }); 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 // Validate parent comment if provided
if (parentCommentId) { if (parentCommentId) {
const parentComment = await ForumComment.findByPk(parentCommentId); const parentComment = await ForumComment.findByPk(parentCommentId);
@@ -606,8 +768,9 @@ router.post('/posts/:id/comments', authenticateToken, uploadForumCommentImages,
images images
}); });
// Increment comment count // Increment comment count and update post's updatedAt to reflect activity
await post.increment('commentCount'); await post.increment('commentCount');
await post.update({ updatedAt: new Date() });
const commentWithDetails = await ForumComment.findByPk(comment.id, { const commentWithDetails = await ForumComment.findByPk(comment.id, {
include: [ 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; module.exports = router;

View File

@@ -62,6 +62,7 @@ class TemplateManager {
"forumReplyToCommentAuthor.html", "forumReplyToCommentAuthor.html",
"forumAnswerAcceptedToCommentAuthor.html", "forumAnswerAcceptedToCommentAuthor.html",
"forumThreadActivityToParticipant.html", "forumThreadActivityToParticipant.html",
"forumPostClosed.html",
]; ];
for (const templateFile of templateFiles) { for (const templateFile of templateFiles) {

View File

@@ -313,6 +313,77 @@ class ForumEmailService {
return { success: false, error: error.message }; 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; module.exports = ForumEmailService;

View File

@@ -0,0 +1,266 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Discussion Closed</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e9ecef;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content h2 {
font-size: 20px;
font-weight: 600;
margin: 30px 0 15px 0;
color: #495057;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #6c757d 0%, #495057 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 117, 125, 0.4);
}
/* Warning box */
.warning-box {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.warning-box p {
margin: 0;
color: #856404;
}
/* Post title box */
.post-title-box {
background-color: #f8f9fa;
border-left: 4px solid #6c757d;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.post-title-box .title {
font-size: 16px;
font-weight: 600;
color: #495057;
margin: 0;
}
/* Info box */
.info-box {
background-color: #e9ecef;
border: 1px solid #dee2e6;
padding: 20px;
margin: 20px 0;
border-radius: 6px;
}
.info-box .label {
font-size: 14px;
font-weight: 600;
color: #495057;
margin: 0 0 5px 0;
}
.info-box .value {
color: #212529;
margin: 0;
}
.info-box .timestamp {
font-size: 12px;
color: #6c757d;
margin-top: 10px;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
.info-box {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Forum Notification</div>
</div>
<div class="content">
<p>Hi {{recipientName}},</p>
<h1>A discussion has been closed</h1>
<p>The following forum discussion has been closed:</p>
<div class="post-title-box">
<div class="title">{{postTitle}}</div>
</div>
<div class="info-box">
<div class="label">Closed by</div>
<div class="value">{{adminName}}</div>
<div class="timestamp">{{timestamp}}</div>
</div>
<div class="warning-box">
<p><strong>Note:</strong> This discussion is now closed and no new comments can be added. You can still view the existing discussion and all previous comments.</p>
</div>
<a href="{{postUrl}}" class="button">View Discussion</a>
<p>If you have questions about this closure, you can reach out to the person who closed it or contact our support team.</p>
</div>
<div class="footer">
<p><strong>RentAll</strong></p>
<p>You received this email because you participated in or authored this forum discussion.</p>
<p>If you have any questions, please contact our support team.</p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -20,7 +20,7 @@ const ForumPostDetail: React.FC = () => {
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [showAdminModal, setShowAdminModal] = useState(false); const [showAdminModal, setShowAdminModal] = useState(false);
const [adminAction, setAdminAction] = useState<{ const [adminAction, setAdminAction] = useState<{
type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment'; type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment' | 'closePost' | 'reopenPost';
id?: string; id?: string;
} | null>(null); } | null>(null);
@@ -151,6 +151,16 @@ const ForumPostDetail: React.FC = () => {
setShowAdminModal(true); setShowAdminModal(true);
}; };
const handleAdminClosePost = async () => {
setAdminAction({ type: 'closePost' });
setShowAdminModal(true);
};
const handleAdminReopenPost = async () => {
setAdminAction({ type: 'reopenPost' });
setShowAdminModal(true);
};
const handleAdminDeleteComment = async (commentId: string) => { const handleAdminDeleteComment = async (commentId: string) => {
setAdminAction({ type: 'deleteComment', id: commentId }); setAdminAction({ type: 'deleteComment', id: commentId });
setShowAdminModal(true); setShowAdminModal(true);
@@ -175,6 +185,12 @@ const ForumPostDetail: React.FC = () => {
case 'restorePost': case 'restorePost':
await forumAPI.adminRestorePost(id!); await forumAPI.adminRestorePost(id!);
break; break;
case 'closePost':
await forumAPI.adminClosePost(id!);
break;
case 'reopenPost':
await forumAPI.adminReopenPost(id!);
break;
case 'deleteComment': case 'deleteComment':
await forumAPI.adminDeleteComment(adminAction.id!); await forumAPI.adminDeleteComment(adminAction.id!);
break; break;
@@ -199,7 +215,13 @@ const ForumPostDetail: React.FC = () => {
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); 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) { if (loading) {
@@ -310,9 +332,16 @@ const ForumPostDetail: React.FC = () => {
{isAuthor && ( {isAuthor && (
<div className="d-flex gap-2 flex-wrap mb-3"> <div className="d-flex gap-2 flex-wrap mb-3">
<Link
to={`/forum/${post.id}/edit`}
className="btn btn-sm btn-outline-primary"
>
<i className="bi bi-pencil me-1"></i>
Edit
</Link>
{post.status !== 'closed' && ( {post.status !== 'closed' && (
<button <button
className="btn btn-sm btn-secondary" className="btn btn-sm btn-outline-secondary"
onClick={() => handleStatusChange('closed')} onClick={() => handleStatusChange('closed')}
disabled={actionLoading} disabled={actionLoading}
> >
@@ -322,7 +351,7 @@ const ForumPostDetail: React.FC = () => {
)} )}
{post.status === 'closed' && ( {post.status === 'closed' && (
<button <button
className="btn btn-sm btn-success" className="btn btn-sm btn-outline-secondary"
onClick={() => handleStatusChange('open')} onClick={() => handleStatusChange('open')}
disabled={actionLoading} disabled={actionLoading}
> >
@@ -330,13 +359,6 @@ const ForumPostDetail: React.FC = () => {
Reopen Post Reopen Post
</button> </button>
)} )}
<Link
to={`/forum/${post.id}/edit`}
className="btn btn-sm btn-outline-primary"
>
<i className="bi bi-pencil me-1"></i>
Edit
</Link>
<button <button
className="btn btn-sm btn-outline-danger" className="btn btn-sm btn-outline-danger"
onClick={handleDeletePost} onClick={handleDeletePost}
@@ -422,7 +444,32 @@ const ForumPostDetail: React.FC = () => {
<hr /> <hr />
{user ? ( {/* Admin Close/Reopen Controls */}
{isAdmin && (
<div className="d-flex gap-2 mb-3">
{post.status === 'closed' ? (
<button
className="btn btn-success"
onClick={handleAdminReopenPost}
disabled={actionLoading}
>
<i className="bi bi-unlock me-1"></i>
Reopen Discussion (Admin)
</button>
) : (
<button
className="btn btn-warning"
onClick={handleAdminClosePost}
disabled={actionLoading}
>
<i className="bi bi-lock me-1"></i>
Close Discussion (Admin)
</button>
)}
</div>
)}
{post.status !== 'closed' && user ? (
<div> <div>
<h6>Add a comment</h6> <h6>Add a comment</h6>
<CommentForm <CommentForm
@@ -431,11 +478,23 @@ const ForumPostDetail: React.FC = () => {
buttonText="Post Comment" buttonText="Post Comment"
/> />
</div> </div>
) : ( ) : post.status !== 'closed' && !user ? (
<div className="alert alert-info"> <div className="alert alert-info">
<i className="bi bi-info-circle me-2"></i> <i className="bi bi-info-circle me-2"></i>
<AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to join the discussion. <AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to join the discussion.
</div> </div>
) : null}
{/* Show closed banner at the bottom for all users */}
{post.status === 'closed' && post.closedBy && (
<div className="text-muted mt-3">
<i className="bi bi-lock me-2"></i>
<strong>
Closed by {post.closer ? `${post.closer.firstName} ${post.closer.lastName}` : 'Unknown'}
</strong>
{post.closedAt && ` on ${formatDate(post.closedAt)}`}
{' - '}No new comments can be added
</div>
)} )}
</div> </div>
</div> </div>
@@ -466,6 +525,12 @@ const ForumPostDetail: React.FC = () => {
{adminAction?.type === 'restorePost' && ( {adminAction?.type === 'restorePost' && (
<p>Are you sure you want to restore this post? It will become visible to all users again.</p> <p>Are you sure you want to restore this post? It will become visible to all users again.</p>
)} )}
{adminAction?.type === 'closePost' && (
<p>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.</p>
)}
{adminAction?.type === 'reopenPost' && (
<p>Are you sure you want to reopen this discussion? Users will be able to add comments again.</p>
)}
{adminAction?.type === 'deleteComment' && ( {adminAction?.type === 'deleteComment' && (
<p>Are you sure you want to delete this comment? It will be deleted and hidden from regular users but can be restored later.</p> <p>Are you sure you want to delete this comment? It will be deleted and hidden from regular users but can be restored later.</p>
)} )}
@@ -484,7 +549,11 @@ const ForumPostDetail: React.FC = () => {
</button> </button>
<button <button
type="button" type="button"
className={`btn ${adminAction?.type.includes('delete') ? 'btn-danger' : 'btn-success'}`} className={`btn ${
adminAction?.type.includes('delete') ? 'btn-danger' :
adminAction?.type === 'closePost' ? 'btn-warning' :
'btn-success'
}`}
onClick={confirmAdminAction} onClick={confirmAdminAction}
disabled={actionLoading} disabled={actionLoading}
> >
@@ -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'}
</> </>
)} )}
</button> </button>

View File

@@ -286,6 +286,8 @@ export const forumAPI = {
adminRestorePost: (id: string) => api.patch(`/forum/admin/posts/${id}/restore`), adminRestorePost: (id: string) => api.patch(`/forum/admin/posts/${id}/restore`),
adminDeleteComment: (id: string) => api.delete(`/forum/admin/comments/${id}`), adminDeleteComment: (id: string) => api.delete(`/forum/admin/comments/${id}`),
adminRestoreComment: (id: string) => api.patch(`/forum/admin/comments/${id}/restore`), 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 = { export const stripeAPI = {

View File

@@ -271,6 +271,9 @@ export interface ForumPost {
isDeleted?: boolean; isDeleted?: boolean;
deletedBy?: string; deletedBy?: string;
deletedAt?: string; deletedAt?: string;
closedBy?: string;
closedAt?: string;
closer?: User;
hasDeletedComments?: boolean; hasDeletedComments?: boolean;
author?: User; author?: User;
tags?: PostTag[]; tags?: PostTag[];