diff --git a/backend/models/ForumComment.js b/backend/models/ForumComment.js index 1495ae9..99a951f 100644 --- a/backend/models/ForumComment.js +++ b/backend/models/ForumComment.js @@ -55,6 +55,10 @@ const ForumComment = sequelize.define('ForumComment', { deletedAt: { type: DataTypes.DATE, allowNull: true + }, + deletionReason: { + type: DataTypes.TEXT, + allowNull: true } }); diff --git a/backend/models/ForumPost.js b/backend/models/ForumPost.js index a612b89..096583d 100644 --- a/backend/models/ForumPost.js +++ b/backend/models/ForumPost.js @@ -85,6 +85,10 @@ const ForumPost = sequelize.define('ForumPost', { type: DataTypes.DATE, allowNull: true }, + deletionReason: { + type: DataTypes.TEXT, + allowNull: true + }, closedBy: { type: DataTypes.UUID, allowNull: true, diff --git a/backend/routes/forum.js b/backend/routes/forum.js index 51ea2a4..f77f198 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -1250,26 +1250,68 @@ router.get('/tags', async (req, res) => { // DELETE /api/forum/admin/posts/:id - Admin soft-delete post router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, res) => { try { - const post = await ForumPost.findByPk(req.params.id); + const { reason } = req.body; + + if (!reason || !reason.trim()) { + return res.status(400).json({ error: "Deletion reason is required" }); + } + + 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.isDeleted) { + return res.status(400).json({ error: 'Post is already deleted' }); + } + // Soft delete the post await post.update({ isDeleted: true, deletedBy: req.user.id, - deletedAt: new Date() + deletedAt: new Date(), + deletionReason: reason.trim() }); const reqLogger = logger.withRequestId(req.id); reqLogger.info("Admin deleted post", { postId: req.params.id, adminId: req.user.id, - originalAuthorId: post.authorId + originalAuthorId: post.authorId, + reason: reason.trim() }); + // Send email notification to post author (non-blocking) + (async () => { + try { + const admin = await User.findByPk(req.user.id, { + attributes: ['id', 'username', 'firstName', 'lastName'] + }); + + if (post.author && admin) { + await emailServices.forum.sendForumPostDeletionNotification( + post.author, + admin, + post, + reason.trim() + ); + console.log(`Forum post deletion notification email sent to author ${post.authorId}`); + } + } catch (emailError) { + // Log but don't fail the deletion + console.error('Failed to send forum post deletion notification email:', emailError.message); + } + })(); + res.status(200).json({ message: 'Post deleted successfully', post }); } catch (error) { const reqLogger = logger.withRequestId(req.id); @@ -1292,11 +1334,16 @@ router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async return res.status(404).json({ error: 'Post not found' }); } + if (!post.isDeleted) { + return res.status(400).json({ error: 'Post is not deleted' }); + } + // Restore the post await post.update({ isDeleted: false, deletedBy: null, - deletedAt: null + deletedAt: null, + deletionReason: null }); const reqLogger = logger.withRequestId(req.id); @@ -1322,25 +1369,44 @@ router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async // DELETE /api/forum/admin/comments/:id - Admin soft-delete comment router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req, res) => { try { - const comment = await ForumComment.findByPk(req.params.id); + const { reason } = req.body; + + if (!reason || !reason.trim()) { + return res.status(400).json({ error: "Deletion reason is required" }); + } + + const comment = await ForumComment.findByPk(req.params.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName', 'email'] + } + ] + }); if (!comment) { return res.status(404).json({ error: 'Comment not found' }); } + if (comment.isDeleted) { + return res.status(400).json({ error: 'Comment is already deleted' }); + } + // Soft delete the comment (preserve original content for potential restoration) await comment.update({ isDeleted: true, deletedBy: req.user.id, - deletedAt: new Date() + deletedAt: new Date(), + deletionReason: reason.trim() }); - // Decrement comment count if not already deleted - if (!comment.isDeleted) { - const post = await ForumPost.findByPk(comment.postId); - if (post && post.commentCount > 0) { - await post.decrement('commentCount'); - } + // Decrement comment count + const post = await ForumPost.findByPk(comment.postId, { + attributes: ['id', 'title'] + }); + if (post && post.commentCount > 0) { + await post.decrement('commentCount'); } const reqLogger = logger.withRequestId(req.id); @@ -1348,9 +1414,32 @@ router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req commentId: req.params.id, adminId: req.user.id, originalAuthorId: comment.authorId, - postId: comment.postId + postId: comment.postId, + reason: reason.trim() }); + // Send email notification to comment author (non-blocking) + (async () => { + try { + const admin = await User.findByPk(req.user.id, { + attributes: ['id', 'username', 'firstName', 'lastName'] + }); + + if (comment.author && admin && post) { + await emailServices.forum.sendForumCommentDeletionNotification( + comment.author, + admin, + post, + reason.trim() + ); + console.log(`Forum comment deletion notification email sent to author ${comment.authorId}`); + } + } catch (emailError) { + // Log but don't fail the deletion + console.error('Failed to send forum comment deletion notification email:', emailError.message); + } + })(); + res.status(200).json({ message: 'Comment deleted successfully', comment }); } catch (error) { const reqLogger = logger.withRequestId(req.id); @@ -1381,7 +1470,8 @@ router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, asy await comment.update({ isDeleted: false, deletedBy: null, - deletedAt: null + deletedAt: null, + deletionReason: null }); // Increment comment count diff --git a/backend/routes/items.js b/backend/routes/items.js index bb2a1d9..32f2a98 100644 --- a/backend/routes/items.js +++ b/backend/routes/items.js @@ -483,7 +483,8 @@ router.patch("/admin/:id/restore", authenticateToken, requireAdmin, async (req, await item.update({ isDeleted: false, deletedBy: null, - deletedAt: null + deletedAt: null, + deletionReason: null }); const updatedItem = await Item.findByPk(item.id, { diff --git a/backend/services/email/core/TemplateManager.js b/backend/services/email/core/TemplateManager.js index e6a1083..d4f7ba5 100644 --- a/backend/services/email/core/TemplateManager.js +++ b/backend/services/email/core/TemplateManager.js @@ -65,6 +65,8 @@ class TemplateManager { "forumThreadActivityToParticipant.html", "forumPostClosed.html", "forumItemRequestNotification.html", + "forumPostDeletionToAuthor.html", + "forumCommentDeletionToAuthor.html", ]; for (const templateFile of templateFiles) { diff --git a/backend/services/email/domain/ForumEmailService.js b/backend/services/email/domain/ForumEmailService.js index 4db01cf..0b791be 100644 --- a/backend/services/email/domain/ForumEmailService.js +++ b/backend/services/email/domain/ForumEmailService.js @@ -9,6 +9,7 @@ const TemplateManager = require("../core/TemplateManager"); * - Sending answer accepted notifications * - Sending thread activity notifications to participants * - Sending location-based item request notifications to nearby users + * - Sending post/comment deletion notifications to authors */ class ForumEmailService { constructor() { @@ -386,6 +387,128 @@ class ForumEmailService { } } + /** + * Send notification when a forum post is deleted by admin + * @param {Object} postAuthor - Post author user object + * @param {string} postAuthor.firstName - Post author's first name + * @param {string} postAuthor.email - Post author's email + * @param {Object} admin - Admin user object who deleted the post + * @param {string} admin.firstName - Admin's first name + * @param {string} admin.lastName - Admin's last name + * @param {Object} post - Forum post object + * @param {string} post.title - Post title + * @param {string} deletionReason - Reason for deletion + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendForumPostDeletionNotification(postAuthor, admin, post, deletionReason) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const supportEmail = process.env.SUPPORT_EMAIL; + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const variables = { + postAuthorName: postAuthor.firstName || "there", + adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator", + postTitle: post.title, + deletionReason, + supportEmail, + forumUrl: `${frontendUrl}/forum`, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumPostDeletionToAuthor", + variables + ); + + const subject = `Important: Your forum post "${post.title}" has been removed`; + + const result = await this.emailClient.sendEmail( + postAuthor.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Forum post deletion notification email sent to ${postAuthor.email}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send forum post deletion notification email:", + error + ); + return { success: false, error: error.message }; + } + } + + /** + * Send notification when a forum comment is deleted by admin + * @param {Object} commentAuthor - Comment author user object + * @param {string} commentAuthor.firstName - Comment author's first name + * @param {string} commentAuthor.email - Comment author's email + * @param {Object} admin - Admin user object who deleted the comment + * @param {string} admin.firstName - Admin's first name + * @param {string} admin.lastName - Admin's last name + * @param {Object} post - Forum post object the comment belongs to + * @param {string} post.title - Post title + * @param {string} post.id - Post ID + * @param {string} deletionReason - Reason for deletion + * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} + */ + async sendForumCommentDeletionNotification(commentAuthor, admin, post, deletionReason) { + if (!this.initialized) { + await this.initialize(); + } + + try { + const supportEmail = process.env.SUPPORT_EMAIL; + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + const postUrl = `${frontendUrl}/forum/posts/${post.id}`; + + const variables = { + commentAuthorName: commentAuthor.firstName || "there", + adminName: `${admin.firstName} ${admin.lastName}`.trim() || "An administrator", + postTitle: post.title, + postUrl, + deletionReason, + supportEmail, + }; + + const htmlContent = await this.templateManager.renderTemplate( + "forumCommentDeletionToAuthor", + variables + ); + + const subject = `Your comment on "${post.title}" has been removed`; + + const result = await this.emailClient.sendEmail( + commentAuthor.email, + subject, + htmlContent + ); + + if (result.success) { + console.log( + `Forum comment deletion notification email sent to ${commentAuthor.email}` + ); + } + + return result; + } catch (error) { + console.error( + "Failed to send forum comment deletion notification email:", + error + ); + return { success: false, error: error.message }; + } + } + /** * Send notification to nearby users about an item request * @param {Object} recipient - Recipient user object diff --git a/backend/templates/emails/forumCommentDeletionToAuthor.html b/backend/templates/emails/forumCommentDeletionToAuthor.html new file mode 100644 index 0000000..6d09ddc --- /dev/null +++ b/backend/templates/emails/forumCommentDeletionToAuthor.html @@ -0,0 +1,314 @@ + + + + + + + Your Forum Comment Has Been Removed + + + +
+
+ +
⚠️ Important: Comment Removal Notice
+
+ +
+

Hi {{commentAuthorName}},

+ +

Your Comment Has Been Removed

+ +

We're writing to inform you that your comment has been removed from a forum discussion by {{adminName}}.

+ +
+

Comment on:

+
{{postTitle}}
+ View Discussion → +
+ +
+

Reason for Removal:

+

{{deletionReason}}

+
+ +
+

What this means:

+
    +
  • Your comment is no longer visible to other community members
  • +
  • The comment content has been preserved in case of appeal
  • +
  • The discussion thread remains active for other participants
  • +
  • You can still participate in other forum discussions
  • +
+
+ +

Need Help or Have Questions?

+

If you believe this removal was made in error or if you have questions about our community guidelines, please don't hesitate to contact our support team:

+ +

+ Contact Support +

+ +
+

Review Our Community Guidelines:

+

To ensure a positive experience for all members, please review our community guidelines. We appreciate respectful, constructive contributions that help build a supportive community.

+
+ +

Thank you for your understanding, and we look forward to your continued participation in our community.

+ +

Best regards,
+ The RentAll Team

+
+ + +
+ + diff --git a/backend/templates/emails/forumPostDeletionToAuthor.html b/backend/templates/emails/forumPostDeletionToAuthor.html new file mode 100644 index 0000000..d23cc0f --- /dev/null +++ b/backend/templates/emails/forumPostDeletionToAuthor.html @@ -0,0 +1,305 @@ + + + + + + + Your Forum Post Has Been Removed + + + +
+
+ +
⚠️ Important: Forum Post Removal Notice
+
+ +
+

Hi {{postAuthorName}},

+ +

Your Forum Post Has Been Removed

+ +

We're writing to inform you that your forum post has been removed from RentAll by {{adminName}}.

+ +
+
{{postTitle}}
+
+ +
+

Reason for Removal:

+

{{deletionReason}}

+
+ +
+

What this means:

+
    +
  • Your post is no longer visible to other community members
  • +
  • All comments on this post are also hidden
  • +
  • The post cannot receive new comments or activity
  • +
  • You may still see it in your dashboard if viewing as an admin
  • +
+
+ +

Need Help or Have Questions?

+

If you believe this removal was made in error or if you have questions about our community guidelines, please don't hesitate to contact our support team:

+ +

+ Contact Support +

+ +
+

Review Our Community Guidelines:

+

To prevent future removals, please familiarize yourself with our community guidelines and forum standards. Our team is happy to help you understand how to contribute positively to the RentAll community.

+
+ +

You can continue participating in the forum by visiting our community forum.

+ +

Thank you for your understanding.

+ +

Best regards,
+ The RentAll Team

+
+ + +
+ + diff --git a/frontend/src/pages/ForumPostDetail.tsx b/frontend/src/pages/ForumPostDetail.tsx index 6e05a8c..ae5b1c5 100644 --- a/frontend/src/pages/ForumPostDetail.tsx +++ b/frontend/src/pages/ForumPostDetail.tsx @@ -23,6 +23,7 @@ const ForumPostDetail: React.FC = () => { type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment' | 'closePost' | 'reopenPost'; id?: string; } | null>(null); + const [deletionReason, setDeletionReason] = useState(''); // Read filter from URL query param const filter = searchParams.get('filter') || 'active'; @@ -174,13 +175,19 @@ const ForumPostDetail: React.FC = () => { const confirmAdminAction = async () => { if (!adminAction) return; + // Validate deletion reason for delete actions + if ((adminAction.type === 'deletePost' || adminAction.type === 'deleteComment') && !deletionReason.trim()) { + alert('Please provide a reason for deletion'); + return; + } + try { setActionLoading(true); setShowAdminModal(false); switch (adminAction.type) { case 'deletePost': - await forumAPI.adminDeletePost(id!); + await forumAPI.adminDeletePost(id!, deletionReason.trim()); break; case 'restorePost': await forumAPI.adminRestorePost(id!); @@ -192,7 +199,7 @@ const ForumPostDetail: React.FC = () => { await forumAPI.adminReopenPost(id!); break; case 'deleteComment': - await forumAPI.adminDeleteComment(adminAction.id!); + await forumAPI.adminDeleteComment(adminAction.id!, deletionReason.trim()); break; case 'restoreComment': await forumAPI.adminRestoreComment(adminAction.id!); @@ -205,12 +212,14 @@ const ForumPostDetail: React.FC = () => { } finally { setActionLoading(false); setAdminAction(null); + setDeletionReason(''); } }; const cancelAdminAction = () => { setShowAdminModal(false); setAdminAction(null); + setDeletionReason(''); }; const formatDate = (dateString: string) => { @@ -520,7 +529,26 @@ const ForumPostDetail: React.FC = () => {
{adminAction?.type === 'deletePost' && ( -

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

+ <> +

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

+
+ +