handling closing posts
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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 [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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user