Files
rentall-app/backend/routes/forum.js
2025-12-15 22:45:55 -05:00

1738 lines
50 KiB
JavaScript

const express = require('express');
const { Op } = require('sequelize');
const { ForumPost, ForumComment, PostTag, User } = require('../models');
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
const logger = require('../utils/logger');
const emailServices = require('../services/email');
const googleMapsService = require('../services/googleMapsService');
const locationService = require('../services/locationService');
const { validateS3Keys } = require('../utils/s3KeyValidator');
const { IMAGE_LIMITS } = require('../config/imageLimits');
const router = express.Router();
// Helper function to build nested comment tree
const buildCommentTree = (comments, isAdmin = false) => {
const commentMap = {};
const rootComments = [];
// Create a map of all comments
comments.forEach(comment => {
const commentJson = comment.toJSON();
// Sanitize deleted comments for non-admin users
if (commentJson.isDeleted && !isAdmin) {
commentJson.content = '';
commentJson.imageFilenames = [];
}
commentMap[comment.id] = { ...commentJson, replies: [] };
});
// Build the tree structure
comments.forEach(comment => {
if (comment.parentCommentId && commentMap[comment.parentCommentId]) {
commentMap[comment.parentCommentId].replies.push(commentMap[comment.id]);
} else if (!comment.parentCommentId) {
rootComments.push(commentMap[comment.id]);
}
});
return rootComments;
};
// GET /api/forum/posts - Browse all posts
router.get('/posts', optionalAuth, async (req, res, next) => {
try {
const {
search,
category,
tag,
status,
page = 1,
limit = 20,
sort = 'recent'
} = req.query;
const where = {};
// Filter out deleted posts unless user is admin
if (!req.user || req.user.role !== 'admin') {
where.isDeleted = false;
}
if (category) {
where.category = category;
}
if (status) {
where.status = status;
}
if (search) {
where[Op.or] = [
{ title: { [Op.iLike]: `%${search}%` } },
{ content: { [Op.iLike]: `%${search}%` } }
];
}
const offset = (page - 1) * limit;
// Determine sort order
let order;
switch (sort) {
case 'comments':
order = [['commentCount', 'DESC'], ['createdAt', 'DESC']];
break;
case 'views':
order = [['viewCount', 'DESC'], ['createdAt', 'DESC']];
break;
case 'recent':
default:
order = [['isPinned', 'DESC'], ['updatedAt', 'DESC']];
break;
}
const include = [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName']
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
},
{
model: ForumComment,
as: 'comments',
attributes: ['id', 'isDeleted'],
required: false
}
];
// Filter by tag if provided
if (tag) {
include[1].where = { tagName: tag };
include[1].required = true;
}
const { count, rows } = await ForumPost.findAndCountAll({
where,
include,
limit: parseInt(limit),
offset: parseInt(offset),
order,
distinct: true
});
// Add hasDeletedComments flag to each post
const postsWithFlags = rows.map(post => {
const postJson = post.toJSON();
postJson.hasDeletedComments = postJson.comments?.some(c => c.isDeleted) || false;
// Remove comments array to reduce response size (only needed the flag)
delete postJson.comments;
return postJson;
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum posts fetched", {
search,
category,
tag,
status,
postsCount: count,
page: parseInt(page),
limit: parseInt(limit)
});
res.json({
posts: postsWithFlags,
totalPages: Math.ceil(count / limit),
currentPage: parseInt(page),
totalPosts: count
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum posts fetch failed", {
error: error.message,
stack: error.stack,
query: req.query
});
next(error);
}
});
// GET /api/forum/posts/:id - Get single post with all comments
router.get('/posts/:id', optionalAuth, async (req, res, next) => {
try {
const post = await ForumPost.findByPk(req.params.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName', 'role']
},
{
model: User,
as: 'closer',
attributes: ['id', 'firstName', 'lastName', 'role'],
required: false
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
},
{
model: ForumComment,
as: 'comments',
required: false,
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName', 'role']
}
]
}
],
order: [
[{ model: ForumComment, as: 'comments' }, 'createdAt', 'ASC']
]
});
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
// Hide deleted posts from non-admins
if (post.isDeleted && (!req.user || req.user.role !== 'admin')) {
return res.status(404).json({ error: 'Post not found' });
}
// 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';
const postData = post.toJSON();
postData.comments = buildCommentTree(post.comments, isAdmin);
postData.viewCount += 1; // Reflect the increment
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum post fetched", {
postId: req.params.id,
authorId: post.authorId
});
res.json(postData);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum post fetch failed", {
error: error.message,
stack: error.stack,
postId: req.params.id
});
next(error);
}
});
// POST /api/forum/posts - Create new post
router.post('/posts', authenticateToken, async (req, res, next) => {
try {
// Require email verification
if (!req.user.isVerified) {
return res.status(403).json({
error: "Please verify your email address before creating forum posts.",
code: "EMAIL_NOT_VERIFIED"
});
}
let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng, imageFilenames: rawImageFilenames } = req.body;
// Ensure imageFilenames is an array and validate S3 keys
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
details: keyValidation.invalidKeys
});
}
const imageFilenames = imageFilenamesArray;
// Initialize location fields
let latitude = null;
let longitude = null;
// Use provided coordinates if available, otherwise geocode zip code
if (category === 'item_request' && zipCode) {
// If coordinates were provided from a saved address, use them directly
if (providedLat && providedLng) {
latitude = parseFloat(providedLat);
longitude = parseFloat(providedLng);
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Using provided coordinates for item request", {
zipCode,
latitude,
longitude,
source: 'saved_address'
});
} else {
// Otherwise, geocode the zip code
try {
const geocodeResult = await googleMapsService.geocodeAddress(zipCode);
// Check if geocoding was successful
if (geocodeResult.error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Geocoding failed for item request", {
error: geocodeResult.error,
status: geocodeResult.status,
zipCode
});
} else if (geocodeResult.latitude && geocodeResult.longitude) {
latitude = geocodeResult.latitude;
longitude = geocodeResult.longitude;
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Geocoded zip code for item request", {
zipCode,
latitude,
longitude,
source: 'geocoded'
});
}
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Geocoding failed for item request", {
error: error.message,
zipCode
});
// Continue without coordinates - post will still be created
}
}
}
const post = await ForumPost.create({
title,
content,
category,
authorId: req.user.id,
imageFilenames,
zipCode: zipCode || null,
latitude,
longitude
});
// Create tags if provided
if (tags && Array.isArray(tags) && tags.length > 0) {
const tagPromises = tags.map(tagName =>
PostTag.create({
postId: post.id,
tagName: tagName.toLowerCase().trim()
})
);
await Promise.all(tagPromises);
}
const postWithDetails = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName']
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
}
]
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum post created", {
postId: post.id,
authorId: req.user.id,
category,
title
});
res.status(201).json(postWithDetails);
// Send location-based notifications for item requests (asynchronously)
if (category === 'item_request' && latitude && longitude) {
(async () => {
try {
logger.info("Starting item request notifications", {
postId: post.id,
latitude,
longitude,
zipCode
});
// Find all users within maximum radius (100 miles)
const nearbyUsers = await locationService.findUsersInRadius(
latitude,
longitude,
100
);
logger.info("Found nearby users", {
postId: post.id,
count: nearbyUsers.length,
users: nearbyUsers.map(u => ({ id: u.id, distance: u.distance }))
});
const postAuthor = await User.findByPk(req.user.id);
let notificationsSent = 0;
let usersChecked = 0;
let usersSkipped = 0;
for (const user of nearbyUsers) {
// Don't notify the requester
if (user.id !== req.user.id) {
usersChecked++;
// Get user's notification preference
const userProfile = await User.findByPk(user.id, {
attributes: ['itemRequestNotificationRadius']
});
const userPreferredRadius = userProfile?.itemRequestNotificationRadius;
// Skip if user has disabled notifications (null)
if (userPreferredRadius === null || userPreferredRadius === undefined) {
logger.info("User has disabled item request notifications", {
postId: post.id,
userId: user.id,
userDistance: user.distance
});
usersSkipped++;
continue;
}
// Default to 10 miles if somehow not set
const effectiveRadius = userPreferredRadius || 10;
logger.info("Checking user notification eligibility", {
postId: post.id,
userId: user.id,
userEmail: user.email,
userCoordinates: { lat: user.latitude, lng: user.longitude },
postCoordinates: { lat: latitude, lng: longitude },
userDistance: user.distance,
userPreferredRadius: effectiveRadius,
willNotify: parseFloat(user.distance) <= effectiveRadius
});
// Only notify if within user's preferred radius
if (parseFloat(user.distance) <= effectiveRadius) {
try {
await emailServices.forum.sendItemRequestNotification(
user,
postAuthor,
post,
user.distance
);
notificationsSent++;
logger.info("Sent notification to user", {
postId: post.id,
userId: user.id,
distance: user.distance
});
} catch (emailError) {
logger.error("Failed to send item request notification", {
error: emailError.message,
recipientId: user.id,
postId: post.id
});
}
} else {
usersSkipped++;
}
}
}
logger.info("Item request notifications complete", {
postId: post.id,
totalNearbyUsers: nearbyUsers.length,
usersChecked,
usersSkipped,
notificationsSent
});
} catch (error) {
logger.error("Failed to process item request notifications", {
error: error.message,
stack: error.stack,
postId: post.id
});
}
})();
} else if (category === 'item_request') {
logger.warn("Item request created without location", {
postId: post.id,
zipCode,
hasLatitude: !!latitude,
hasLongitude: !!longitude
});
}
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum post creation failed", {
error: error.message,
stack: error.stack,
authorId: req.user.id,
postData: logger.sanitize(req.body)
});
next(error);
}
});
// PUT /api/forum/posts/:id - Update post
router.put('/posts/:id', authenticateToken, async (req, res, next) => {
try {
// Require email verification
if (!req.user.isVerified) {
return res.status(403).json({
error: "Please verify your email address before editing forum posts.",
code: "EMAIL_NOT_VERIFIED"
});
}
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
if (post.authorId !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
const { title, content, category, tags, imageFilenames: rawImageFilenames } = req.body;
// Build update object
const updateData = { title, content, category };
// Handle imageFilenames if provided
if (rawImageFilenames !== undefined) {
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
details: keyValidation.invalidKeys
});
}
updateData.imageFilenames = imageFilenamesArray;
}
await post.update(updateData);
// Update tags if provided
if (tags !== undefined) {
// Delete existing tags
await PostTag.destroy({ where: { postId: post.id } });
// Create new tags
if (Array.isArray(tags) && tags.length > 0) {
const tagPromises = tags.map(tagName =>
PostTag.create({
postId: post.id,
tagName: tagName.toLowerCase().trim()
})
);
await Promise.all(tagPromises);
}
}
const updatedPost = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName']
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
}
]
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum post updated", {
postId: req.params.id,
authorId: req.user.id
});
res.json(updatedPost);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum post update failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
authorId: req.user.id
});
next(error);
}
});
// DELETE /api/forum/posts/:id - Delete post
router.delete('/posts/:id', authenticateToken, async (req, res, next) => {
try {
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
if (post.authorId !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
// Delete associated tags and comments
await PostTag.destroy({ where: { postId: post.id } });
await ForumComment.destroy({ where: { postId: post.id } });
await post.destroy();
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum post deleted", {
postId: req.params.id,
authorId: req.user.id
});
res.status(204).send();
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum post deletion failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
authorId: req.user.id
});
next(error);
}
});
// PATCH /api/forum/posts/:id/status - Update post status
router.patch('/posts/:id/status', authenticateToken, async (req, res, next) => {
try {
const { status } = req.body;
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
if (post.authorId !== req.user.id) {
return res.status(403).json({ error: 'Only the author can update post status' });
}
if (!['open', 'answered', 'closed'].includes(status)) {
return res.status(400).json({ error: 'Invalid status value' });
}
// 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', 'firstName', 'lastName', 'email']
});
const postWithAuthor = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', '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: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName']
},
{
model: User,
as: 'closer',
attributes: ['id', 'firstName', 'lastName'],
required: false
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
}
]
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum post status updated", {
postId: req.params.id,
newStatus: status,
authorId: req.user.id
});
res.json(updatedPost);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum post status update failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
authorId: req.user.id
});
next(error);
}
});
// PATCH /api/forum/posts/:id/accept-answer - Mark/unmark comment as accepted answer
router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res, next) => {
try {
const { commentId } = req.body;
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Post not found' });
}
if (post.authorId !== req.user.id) {
return res.status(403).json({ error: 'Only the post author can mark answers' });
}
// If commentId is provided, validate it
if (commentId) {
const comment = await ForumComment.findByPk(commentId);
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
if (comment.postId !== post.id) {
return res.status(400).json({ error: 'Comment does not belong to this post' });
}
if (comment.isDeleted) {
return res.status(400).json({ error: 'Cannot mark deleted comment as answer' });
}
if (comment.parentCommentId) {
return res.status(400).json({ error: 'Only top-level comments can be marked as answers' });
}
}
// Update the post with accepted answer and close the discussion
await post.update({
acceptedAnswerId: commentId || null,
status: commentId ? 'closed' : 'open',
closedBy: commentId ? req.user.id : null,
closedAt: commentId ? new Date() : null
});
// Send email notifications if marking an answer (not unmarking)
if (commentId) {
(async () => {
try {
const comment = await ForumComment.findByPk(commentId, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
const postAuthor = await User.findByPk(req.user.id, {
attributes: ['id', 'firstName', 'lastName', 'email']
});
const postWithAuthor = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', '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,
postAuthor,
post,
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 notification emails", {
error: emailError.message,
stack: emailError.stack,
commentId: commentId,
postId: req.params.id
});
console.error("Email notification error:", emailError);
}
})();
}
const updatedPost = await ForumPost.findByPk(post.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName']
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
}
]
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Answer marked/unmarked", {
postId: req.params.id,
commentId: commentId || 'unmarked',
authorId: req.user.id
});
res.json(updatedPost);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Mark answer failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
authorId: req.user.id
});
next(error);
}
});
// POST /api/forum/posts/:id/comments - Add comment/reply
router.post('/posts/:id/comments', authenticateToken, async (req, res, next) => {
try {
// Require email verification
if (!req.user.isVerified) {
return res.status(403).json({
error: "Please verify your email address before commenting.",
code: "EMAIL_NOT_VERIFIED"
});
}
// Support both parentId (new) and parentCommentId (legacy) for backwards compatibility
const { content, parentId, parentCommentId, imageFilenames: rawImageFilenames } = req.body;
const parentIdResolved = parentId || parentCommentId;
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
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 (parentIdResolved) {
const parentComment = await ForumComment.findByPk(parentIdResolved);
if (!parentComment || parentComment.postId !== post.id) {
return res.status(400).json({ error: 'Invalid parent comment' });
}
}
// Ensure imageFilenames is an array and validate S3 keys
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
if (!keyValidation.valid) {
return res.status(400).json({
error: keyValidation.error,
details: keyValidation.invalidKeys
});
}
const imageFilenames = imageFilenamesArray;
const comment = await ForumComment.create({
postId: req.params.id,
authorId: req.user.id,
content,
parentCommentId: parentIdResolved || null,
imageFilenames
});
// 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: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
// Send email notifications (non-blocking)
(async () => {
try {
const commenter = commentWithDetails.author;
const notifiedUserIds = new Set();
// Reload post with author details for email
const postWithAuthor = await ForumPost.findByPk(req.params.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
// If this is a reply, send reply notification to parent comment author
if (parentCommentId) {
const parentComment = await ForumComment.findByPk(parentCommentId, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName', 'email']
}
]
});
// Send reply notification if not replying to yourself
if (parentComment && parentComment.authorId !== req.user.id) {
await emailServices.forum.sendForumReplyNotification(
parentComment.author,
commenter,
postWithAuthor,
commentWithDetails,
parentComment
);
notifiedUserIds.add(parentComment.authorId);
}
} else {
// Send comment notification to post author if not commenting on your own post
if (postWithAuthor.authorId !== req.user.id) {
await emailServices.forum.sendForumCommentNotification(
postWithAuthor.author,
commenter,
postWithAuthor,
commentWithDetails
);
notifiedUserIds.add(postWithAuthor.authorId);
}
}
// Get all unique participants who have commented on this post (excluding commenter and already notified)
const participants = await ForumComment.findAll({
where: {
postId: req.params.id,
authorId: {
[Op.notIn]: [req.user.id, ...Array.from(notifiedUserIds)]
},
isDeleted: false
},
attributes: ['authorId'],
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName', 'email']
}
],
group: ['ForumComment.authorId', 'author.id']
});
// Send thread activity notifications to all unique participants
for (const participant of participants) {
if (participant.author) {
await emailServices.forum.sendForumThreadActivityNotification(
participant.author,
commenter,
postWithAuthor,
commentWithDetails
);
}
}
} catch (emailError) {
// Email errors don't block comment creation
logger.error("Failed to send forum comment notification emails", {
error: emailError.message,
stack: emailError.stack,
commentId: comment.id,
postId: req.params.id
});
console.error("Email notification error:", emailError);
}
})();
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum comment created", {
postId: req.params.id,
commentId: comment.id,
authorId: req.user.id,
isReply: !!parentCommentId
});
res.status(201).json(commentWithDetails);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum comment creation failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
authorId: req.user.id
});
next(error);
}
});
// PUT /api/forum/comments/:id - Edit comment
router.put('/comments/:id', authenticateToken, async (req, res, next) => {
try {
// Require email verification
if (!req.user.isVerified) {
return res.status(403).json({
error: "Please verify your email address before editing comments.",
code: "EMAIL_NOT_VERIFIED"
});
}
const { content, imageFilenames: rawImageFilenames } = req.body;
const comment = await ForumComment.findByPk(req.params.id);
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
if (comment.authorId !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
if (comment.isDeleted) {
return res.status(400).json({ error: 'Cannot edit deleted comment' });
}
const updateData = { content };
// Handle image filenames if provided
if (rawImageFilenames !== undefined) {
const imageFilenamesArray = Array.isArray(rawImageFilenames) ? rawImageFilenames : [];
const keyValidation = validateS3Keys(imageFilenamesArray, 'forum', { maxKeys: IMAGE_LIMITS.forum });
if (!keyValidation.valid) {
return res.status(400).json({ error: keyValidation.error });
}
updateData.imageFilenames = imageFilenamesArray;
}
await comment.update(updateData);
const updatedComment = await ForumComment.findByPk(comment.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName']
}
]
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum comment updated", {
commentId: req.params.id,
authorId: req.user.id
});
res.json(updatedComment);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum comment update failed", {
error: error.message,
stack: error.stack,
commentId: req.params.id,
authorId: req.user.id
});
next(error);
}
});
// DELETE /api/forum/comments/:id - Soft delete comment
router.delete('/comments/:id', authenticateToken, async (req, res, next) => {
try {
const comment = await ForumComment.findByPk(req.params.id);
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
if (comment.authorId !== req.user.id) {
return res.status(403).json({ error: 'Unauthorized' });
}
// Soft delete (preserve content for potential restoration)
await comment.update({ isDeleted: true });
// Decrement comment count
const post = await ForumPost.findByPk(comment.postId);
if (post && post.commentCount > 0) {
await post.decrement('commentCount');
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum comment deleted", {
commentId: req.params.id,
authorId: req.user.id,
postId: comment.postId
});
res.status(204).send();
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Forum comment deletion failed", {
error: error.message,
stack: error.stack,
commentId: req.params.id,
authorId: req.user.id
});
next(error);
}
});
// GET /api/forum/my-posts - Get user's posts
router.get('/my-posts', authenticateToken, async (req, res, next) => {
try {
const posts = await ForumPost.findAll({
where: { authorId: req.user.id },
include: [
{
model: User,
as: 'author',
attributes: ['id', 'firstName', 'lastName']
},
{
model: PostTag,
as: 'tags',
attributes: ['id', 'tagName']
}
],
order: [['createdAt', 'DESC']]
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("User forum posts fetched", {
userId: req.user.id,
postsCount: posts.length
});
res.json(posts);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("User forum posts fetch failed", {
error: error.message,
stack: error.stack,
userId: req.user.id
});
next(error);
}
});
// GET /api/forum/tags - Get all unique tags for autocomplete
router.get('/tags', async (req, res, next) => {
try {
const { search } = req.query;
const where = {};
if (search) {
where.tagName = { [Op.iLike]: `%${search}%` };
}
const tags = await PostTag.findAll({
where,
attributes: [
'tagName',
[require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'count']
],
group: ['tagName'],
order: [[require('sequelize').fn('COUNT', require('sequelize').col('tagName')), 'DESC']],
limit: 50
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Tags fetched", {
search,
tagsCount: tags.length
});
res.json(tags);
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Tags fetch failed", {
error: error.message,
stack: error.stack,
query: req.query
});
next(error);
}
});
// ============ ADMIN ROUTES ============
// DELETE /api/forum/admin/posts/:id - Admin soft-delete post
router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, res, next) => {
try {
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', '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(),
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,
reason: reason.trim()
});
// Send email notification to post author (non-blocking)
(async () => {
try {
const admin = await User.findByPk(req.user.id, {
attributes: ['id', '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);
reqLogger.error("Admin post deletion failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
adminId: req.user.id
});
next(error);
}
});
// PATCH /api/forum/admin/posts/:id/restore - Admin restore deleted post
router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async (req, res, next) => {
try {
const post = await ForumPost.findByPk(req.params.id);
if (!post) {
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,
deletionReason: null
});
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin restored post", {
postId: req.params.id,
adminId: req.user.id,
originalAuthorId: post.authorId
});
res.status(200).json({ message: 'Post restored successfully', post });
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin post restoration failed", {
error: error.message,
stack: error.stack,
postId: req.params.id,
adminId: req.user.id
});
next(error);
}
});
// DELETE /api/forum/admin/comments/:id - Admin soft-delete comment
router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req, res, next) => {
try {
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', '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(),
deletionReason: reason.trim()
});
// 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);
reqLogger.info("Admin deleted comment", {
commentId: req.params.id,
adminId: req.user.id,
originalAuthorId: comment.authorId,
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', '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);
reqLogger.error("Admin comment deletion failed", {
error: error.message,
stack: error.stack,
commentId: req.params.id,
adminId: req.user.id
});
next(error);
}
});
// PATCH /api/forum/admin/comments/:id/restore - Admin restore deleted comment
router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, async (req, res, next) => {
try {
const comment = await ForumComment.findByPk(req.params.id);
if (!comment) {
return res.status(404).json({ error: 'Comment not found' });
}
if (!comment.isDeleted) {
return res.status(400).json({ error: 'Comment is not deleted' });
}
// Restore the comment with its original content
await comment.update({
isDeleted: false,
deletedBy: null,
deletedAt: null,
deletionReason: null
});
// Increment comment count
const post = await ForumPost.findByPk(comment.postId);
if (post) {
await post.increment('commentCount');
}
const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Admin restored comment", {
commentId: req.params.id,
adminId: req.user.id,
originalAuthorId: comment.authorId,
postId: comment.postId
});
res.status(200).json({
message: 'Comment restored successfully',
comment
});
} catch (error) {
const reqLogger = logger.withRequestId(req.id);
reqLogger.error("Admin comment restoration failed", {
error: error.message,
stack: error.stack,
commentId: req.params.id,
adminId: req.user.id
});
next(error);
}
});
// PATCH /api/forum/admin/posts/:id/close - Admin close discussion
router.patch('/admin/posts/:id/close', authenticateToken, requireAdmin, async (req, res, next) => {
try {
const post = await ForumPost.findByPk(req.params.id, {
include: [
{
model: User,
as: 'author',
attributes: ['id', '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', '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
});
next(error);
}
});
// PATCH /api/forum/admin/posts/:id/reopen - Admin reopen discussion
router.patch('/admin/posts/:id/reopen', authenticateToken, requireAdmin, async (req, res, next) => {
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
});
next(error);
}
});
module.exports = router;