admin soft delete functionality, also fixed google sign in when user doesn't have first and last name
This commit is contained in:
@@ -127,4 +127,23 @@ const requireVerifiedEmail = (req, res, next) => {
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { authenticateToken, optionalAuth, requireVerifiedEmail };
|
||||
// Require admin role middleware - must be used after authenticateToken
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
error: "Authentication required",
|
||||
code: "NO_AUTH",
|
||||
});
|
||||
}
|
||||
|
||||
if (req.user.role !== "admin") {
|
||||
return res.status(403).json({
|
||||
error: "Admin access required",
|
||||
code: "INSUFFICIENT_PERMISSIONS",
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { authenticateToken, optionalAuth, requireVerifiedEmail, requireAdmin };
|
||||
|
||||
@@ -43,6 +43,18 @@ const ForumComment = sequelize.define('ForumComment', {
|
||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||
allowNull: true,
|
||||
defaultValue: []
|
||||
},
|
||||
deletedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
deletedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,22 @@ const ForumPost = sequelize.define('ForumPost', {
|
||||
type: DataTypes.ARRAY(DataTypes.TEXT),
|
||||
allowNull: true,
|
||||
defaultValue: []
|
||||
},
|
||||
isDeleted: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
deletedBy: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
deletedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -137,6 +137,11 @@ const User = sequelize.define(
|
||||
defaultValue: 0,
|
||||
allowNull: false,
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.ENUM("user", "admin"),
|
||||
defaultValue: "user",
|
||||
allowNull: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
hooks: {
|
||||
|
||||
@@ -173,6 +173,7 @@ router.post(
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isVerified: user.isVerified,
|
||||
role: user.role,
|
||||
},
|
||||
verificationEmailSent,
|
||||
// Don't send token in response body for security
|
||||
@@ -269,6 +270,7 @@ router.post(
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isVerified: user.isVerified,
|
||||
role: user.role,
|
||||
},
|
||||
// Don't send token in response body for security
|
||||
});
|
||||
@@ -318,15 +320,36 @@ router.post(
|
||||
const {
|
||||
sub: googleId,
|
||||
email,
|
||||
given_name: firstName,
|
||||
family_name: lastName,
|
||||
given_name: givenName,
|
||||
family_name: familyName,
|
||||
picture,
|
||||
} = payload;
|
||||
|
||||
if (!email || !firstName || !lastName) {
|
||||
if (!email) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Required user information not provided by Google" });
|
||||
.json({ error: "Email not provided by Google" });
|
||||
}
|
||||
|
||||
// Handle cases where Google doesn't provide name fields
|
||||
// Generate fallback values from email or use placeholder
|
||||
let firstName = givenName;
|
||||
let lastName = familyName;
|
||||
|
||||
if (!firstName || !lastName) {
|
||||
const emailUsername = email.split('@')[0];
|
||||
// Try to split email username by common separators
|
||||
const nameParts = emailUsername.split(/[._-]/);
|
||||
|
||||
if (!firstName) {
|
||||
firstName = nameParts[0] ? nameParts[0].charAt(0).toUpperCase() + nameParts[0].slice(1) : 'Google';
|
||||
}
|
||||
|
||||
if (!lastName) {
|
||||
lastName = nameParts.length > 1
|
||||
? nameParts[nameParts.length - 1].charAt(0).toUpperCase() + nameParts[nameParts.length - 1].slice(1)
|
||||
: 'User';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user exists by Google ID first
|
||||
@@ -422,6 +445,7 @@ router.post(
|
||||
lastName: user.lastName,
|
||||
profileImage: user.profileImage,
|
||||
isVerified: user.isVerified,
|
||||
role: user.role,
|
||||
},
|
||||
// Don't send token in response body for security
|
||||
});
|
||||
@@ -658,6 +682,7 @@ router.post("/refresh", async (req, res) => {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
isVerified: user.isVerified,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
const express = require('express');
|
||||
const { Op } = require('sequelize');
|
||||
const { ForumPost, ForumComment, PostTag, User } = require('../models');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { authenticateToken, requireAdmin, optionalAuth } = require('../middleware/auth');
|
||||
const { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
|
||||
const logger = require('../utils/logger');
|
||||
const emailServices = require('../services/email');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to build nested comment tree
|
||||
const buildCommentTree = (comments) => {
|
||||
const buildCommentTree = (comments, isAdmin = false) => {
|
||||
const commentMap = {};
|
||||
const rootComments = [];
|
||||
|
||||
// Create a map of all comments
|
||||
comments.forEach(comment => {
|
||||
commentMap[comment.id] = { ...comment.toJSON(), replies: [] };
|
||||
const commentJson = comment.toJSON();
|
||||
|
||||
// Sanitize deleted comments for non-admin users
|
||||
if (commentJson.isDeleted && !isAdmin) {
|
||||
commentJson.content = '';
|
||||
commentJson.images = [];
|
||||
}
|
||||
|
||||
commentMap[comment.id] = { ...commentJson, replies: [] };
|
||||
});
|
||||
|
||||
// Build the tree structure
|
||||
@@ -30,7 +38,7 @@ const buildCommentTree = (comments) => {
|
||||
};
|
||||
|
||||
// GET /api/forum/posts - Browse all posts
|
||||
router.get('/posts', async (req, res) => {
|
||||
router.get('/posts', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
search,
|
||||
@@ -44,6 +52,11 @@ router.get('/posts', async (req, res) => {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -86,6 +99,12 @@ router.get('/posts', async (req, res) => {
|
||||
model: PostTag,
|
||||
as: 'tags',
|
||||
attributes: ['id', 'tagName']
|
||||
},
|
||||
{
|
||||
model: ForumComment,
|
||||
as: 'comments',
|
||||
attributes: ['id', 'isDeleted'],
|
||||
required: false
|
||||
}
|
||||
];
|
||||
|
||||
@@ -104,6 +123,15 @@ router.get('/posts', async (req, res) => {
|
||||
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,
|
||||
@@ -116,7 +144,7 @@ router.get('/posts', async (req, res) => {
|
||||
});
|
||||
|
||||
res.json({
|
||||
posts: rows,
|
||||
posts: postsWithFlags,
|
||||
totalPages: Math.ceil(count / limit),
|
||||
currentPage: parseInt(page),
|
||||
totalPosts: count
|
||||
@@ -133,14 +161,14 @@ router.get('/posts', async (req, res) => {
|
||||
});
|
||||
|
||||
// GET /api/forum/posts/:id - Get single post with all comments
|
||||
router.get('/posts/:id', async (req, res) => {
|
||||
router.get('/posts/:id', optionalAuth, async (req, res) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'author',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
attributes: ['id', 'username', 'firstName', 'lastName', 'role']
|
||||
},
|
||||
{
|
||||
model: PostTag,
|
||||
@@ -150,13 +178,12 @@ router.get('/posts/:id', async (req, res) => {
|
||||
{
|
||||
model: ForumComment,
|
||||
as: 'comments',
|
||||
where: { isDeleted: false },
|
||||
required: false,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'author',
|
||||
attributes: ['id', 'username', 'firstName', 'lastName']
|
||||
attributes: ['id', 'username', 'firstName', 'lastName', 'role']
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -170,12 +197,18 @@ router.get('/posts/:id', async (req, res) => {
|
||||
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
|
||||
await post.increment('viewCount');
|
||||
|
||||
// Build nested comment tree
|
||||
const isAdmin = req.user && req.user.role === 'admin';
|
||||
const postData = post.toJSON();
|
||||
postData.comments = buildCommentTree(post.comments);
|
||||
postData.comments = buildCommentTree(post.comments, isAdmin);
|
||||
postData.viewCount += 1; // Reflect the increment
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
@@ -765,8 +798,8 @@ router.delete('/comments/:id', authenticateToken, async (req, res) => {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
await comment.update({ isDeleted: true, content: '[deleted]' });
|
||||
// Soft delete (preserve content for potential restoration)
|
||||
await comment.update({ isDeleted: true });
|
||||
|
||||
// Decrement comment count
|
||||
const post = await ForumPost.findByPk(comment.postId);
|
||||
@@ -871,4 +904,173 @@ router.get('/tags', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============ ADMIN ROUTES ============
|
||||
|
||||
// DELETE /api/forum/admin/posts/:id - Admin soft-delete post
|
||||
router.delete('/admin/posts/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: 'Post not found' });
|
||||
}
|
||||
|
||||
// Soft delete the post
|
||||
await post.update({
|
||||
isDeleted: true,
|
||||
deletedBy: req.user.id,
|
||||
deletedAt: new Date()
|
||||
});
|
||||
|
||||
const reqLogger = logger.withRequestId(req.id);
|
||||
reqLogger.info("Admin deleted post", {
|
||||
postId: req.params.id,
|
||||
adminId: req.user.id,
|
||||
originalAuthorId: post.authorId
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/admin/posts/:id/restore - Admin restore deleted post
|
||||
router.patch('/admin/posts/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const post = await ForumPost.findByPk(req.params.id);
|
||||
|
||||
if (!post) {
|
||||
return res.status(404).json({ error: 'Post not found' });
|
||||
}
|
||||
|
||||
// Restore the post
|
||||
await post.update({
|
||||
isDeleted: false,
|
||||
deletedBy: null,
|
||||
deletedAt: 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
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/forum/admin/comments/:id - Admin soft-delete comment
|
||||
router.delete('/admin/comments/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const comment = await ForumComment.findByPk(req.params.id);
|
||||
|
||||
if (!comment) {
|
||||
return res.status(404).json({ error: 'Comment not found' });
|
||||
}
|
||||
|
||||
// Soft delete the comment (preserve original content for potential restoration)
|
||||
await comment.update({
|
||||
isDeleted: true,
|
||||
deletedBy: req.user.id,
|
||||
deletedAt: new Date()
|
||||
});
|
||||
|
||||
// Decrement comment count if not already deleted
|
||||
if (!comment.isDeleted) {
|
||||
const post = await ForumPost.findByPk(comment.postId);
|
||||
if (post && post.commentCount > 0) {
|
||||
await post.decrement('commentCount');
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/forum/admin/comments/:id/restore - Admin restore deleted comment
|
||||
router.patch('/admin/comments/:id/restore', authenticateToken, requireAdmin, async (req, res) => {
|
||||
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
|
||||
});
|
||||
|
||||
// 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
|
||||
});
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user