admin soft delete functionality, also fixed google sign in when user doesn't have first and last name

This commit is contained in:
jackiettran
2025-11-17 11:21:52 -05:00
parent 3a6da3d47d
commit e260992ef2
13 changed files with 580 additions and 33 deletions

View File

@@ -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 };

View File

@@ -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
}
});

View File

@@ -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
}
});

View File

@@ -137,6 +137,11 @@ const User = sequelize.define(
defaultValue: 0,
allowNull: false,
},
role: {
type: DataTypes.ENUM("user", "admin"),
defaultValue: "user",
allowNull: false,
},
},
{
hooks: {

View File

@@ -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) {

View File

@@ -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;