const express = require('express'); const { Op } = require('sequelize'); const { ForumPost, ForumComment, PostTag, User } = require('../models'); const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth'); const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload'); const logger = require('../utils/logger'); const emailServices = require('../services/email'); const googleMapsService = require('../services/googleMapsService'); const locationService = require('../services/locationService'); 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, uploadForumPostImages, async (req, res, next) => { try { let { title, content, category, tags, zipCode, latitude: providedLat, longitude: providedLng } = req.body; // Parse tags if they come as JSON string (from FormData) if (typeof tags === 'string') { try { tags = JSON.parse(tags); } catch (e) { tags = []; } } // Extract image filenames if uploaded const imageFilenames = req.files ? req.files.map(file => file.filename) : []; // 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 { 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 } = req.body; await post.update({ title, content, category }); // 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, uploadForumCommentImages, async (req, res, next) => { try { const { content, parentCommentId } = req.body; 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 (parentCommentId) { const parentComment = await ForumComment.findByPk(parentCommentId); if (!parentComment || parentComment.postId !== post.id) { return res.status(400).json({ error: 'Invalid parent comment' }); } } // Extract image filenames if uploaded const imageFilenames = req.files ? req.files.map(file => file.filename) : []; const comment = await ForumComment.create({ postId: req.params.id, authorId: req.user.id, content, parentCommentId: parentCommentId || 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 { const { content } = 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' }); } await comment.update({ content }); 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;