handling closing posts
This commit is contained in:
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -62,6 +62,7 @@ class TemplateManager {
|
||||
"forumReplyToCommentAuthor.html",
|
||||
"forumAnswerAcceptedToCommentAuthor.html",
|
||||
"forumThreadActivityToParticipant.html",
|
||||
"forumPostClosed.html",
|
||||
];
|
||||
|
||||
for (const templateFile of templateFiles) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
266
backend/templates/emails/forumPostClosed.html
Normal file
266
backend/templates/emails/forumPostClosed.html
Normal 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>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 && (
|
||||
<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' && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => handleStatusChange('closed')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
@@ -322,7 +351,7 @@ const ForumPostDetail: React.FC = () => {
|
||||
)}
|
||||
{post.status === 'closed' && (
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => handleStatusChange('open')}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
@@ -330,13 +359,6 @@ const ForumPostDetail: React.FC = () => {
|
||||
Reopen Post
|
||||
</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
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={handleDeletePost}
|
||||
@@ -422,7 +444,32 @@ const ForumPostDetail: React.FC = () => {
|
||||
|
||||
<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>
|
||||
<h6>Add a comment</h6>
|
||||
<CommentForm
|
||||
@@ -431,11 +478,23 @@ const ForumPostDetail: React.FC = () => {
|
||||
buttonText="Post Comment"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
) : post.status !== 'closed' && !user ? (
|
||||
<div className="alert alert-info">
|
||||
<i className="bi bi-info-circle me-2"></i>
|
||||
<AuthButton mode="login" className="alert-link" asLink>Log in</AuthButton> to join the discussion.
|
||||
</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>
|
||||
@@ -466,6 +525,12 @@ const ForumPostDetail: React.FC = () => {
|
||||
{adminAction?.type === 'restorePost' && (
|
||||
<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' && (
|
||||
<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
|
||||
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}
|
||||
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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user