1738 lines
50 KiB
JavaScript
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;
|