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(); 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), type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: true, allowNull: true,
defaultValue: [] 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), type: DataTypes.ARRAY(DataTypes.TEXT),
allowNull: true, allowNull: true,
defaultValue: [] 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, defaultValue: 0,
allowNull: false, allowNull: false,
}, },
role: {
type: DataTypes.ENUM("user", "admin"),
defaultValue: "user",
allowNull: false,
},
}, },
{ {
hooks: { hooks: {

View File

@@ -173,6 +173,7 @@ router.post(
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
isVerified: user.isVerified, isVerified: user.isVerified,
role: user.role,
}, },
verificationEmailSent, verificationEmailSent,
// Don't send token in response body for security // Don't send token in response body for security
@@ -269,6 +270,7 @@ router.post(
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
isVerified: user.isVerified, isVerified: user.isVerified,
role: user.role,
}, },
// Don't send token in response body for security // Don't send token in response body for security
}); });
@@ -318,15 +320,36 @@ router.post(
const { const {
sub: googleId, sub: googleId,
email, email,
given_name: firstName, given_name: givenName,
family_name: lastName, family_name: familyName,
picture, picture,
} = payload; } = payload;
if (!email || !firstName || !lastName) { if (!email) {
return res return res
.status(400) .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 // Check if user exists by Google ID first
@@ -422,6 +445,7 @@ router.post(
lastName: user.lastName, lastName: user.lastName,
profileImage: user.profileImage, profileImage: user.profileImage,
isVerified: user.isVerified, isVerified: user.isVerified,
role: user.role,
}, },
// Don't send token in response body for security // Don't send token in response body for security
}); });
@@ -658,6 +682,7 @@ router.post("/refresh", async (req, res) => {
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
isVerified: user.isVerified, isVerified: user.isVerified,
role: user.role,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -1,20 +1,28 @@
const express = require('express'); const express = require('express');
const { Op } = require('sequelize'); const { Op } = require('sequelize');
const { ForumPost, ForumComment, PostTag, User } = require('../models'); 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 { uploadForumPostImages, uploadForumCommentImages } = require('../middleware/upload');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const emailServices = require('../services/email'); const emailServices = require('../services/email');
const router = express.Router(); const router = express.Router();
// Helper function to build nested comment tree // Helper function to build nested comment tree
const buildCommentTree = (comments) => { const buildCommentTree = (comments, isAdmin = false) => {
const commentMap = {}; const commentMap = {};
const rootComments = []; const rootComments = [];
// Create a map of all comments // Create a map of all comments
comments.forEach(comment => { 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 // Build the tree structure
@@ -30,7 +38,7 @@ const buildCommentTree = (comments) => {
}; };
// GET /api/forum/posts - Browse all posts // GET /api/forum/posts - Browse all posts
router.get('/posts', async (req, res) => { router.get('/posts', optionalAuth, async (req, res) => {
try { try {
const { const {
search, search,
@@ -44,6 +52,11 @@ router.get('/posts', async (req, res) => {
const where = {}; const where = {};
// Filter out deleted posts unless user is admin
if (!req.user || req.user.role !== 'admin') {
where.isDeleted = false;
}
if (category) { if (category) {
where.category = category; where.category = category;
} }
@@ -86,6 +99,12 @@ router.get('/posts', async (req, res) => {
model: PostTag, model: PostTag,
as: 'tags', as: 'tags',
attributes: ['id', 'tagName'] attributes: ['id', 'tagName']
},
{
model: ForumComment,
as: 'comments',
attributes: ['id', 'isDeleted'],
required: false
} }
]; ];
@@ -104,6 +123,15 @@ router.get('/posts', async (req, res) => {
distinct: true 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); const reqLogger = logger.withRequestId(req.id);
reqLogger.info("Forum posts fetched", { reqLogger.info("Forum posts fetched", {
search, search,
@@ -116,7 +144,7 @@ router.get('/posts', async (req, res) => {
}); });
res.json({ res.json({
posts: rows, posts: postsWithFlags,
totalPages: Math.ceil(count / limit), totalPages: Math.ceil(count / limit),
currentPage: parseInt(page), currentPage: parseInt(page),
totalPosts: count totalPosts: count
@@ -133,14 +161,14 @@ router.get('/posts', async (req, res) => {
}); });
// GET /api/forum/posts/:id - Get single post with all comments // 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 { try {
const post = await ForumPost.findByPk(req.params.id, { const post = await ForumPost.findByPk(req.params.id, {
include: [ include: [
{ {
model: User, model: User,
as: 'author', as: 'author',
attributes: ['id', 'username', 'firstName', 'lastName'] attributes: ['id', 'username', 'firstName', 'lastName', 'role']
}, },
{ {
model: PostTag, model: PostTag,
@@ -150,13 +178,12 @@ router.get('/posts/:id', async (req, res) => {
{ {
model: ForumComment, model: ForumComment,
as: 'comments', as: 'comments',
where: { isDeleted: false },
required: false, required: false,
include: [ include: [
{ {
model: User, model: User,
as: 'author', 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' }); 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 // Increment view count
await post.increment('viewCount'); await post.increment('viewCount');
// Build nested comment tree // Build nested comment tree
const isAdmin = req.user && req.user.role === 'admin';
const postData = post.toJSON(); const postData = post.toJSON();
postData.comments = buildCommentTree(post.comments); postData.comments = buildCommentTree(post.comments, isAdmin);
postData.viewCount += 1; // Reflect the increment postData.viewCount += 1; // Reflect the increment
const reqLogger = logger.withRequestId(req.id); 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' }); return res.status(403).json({ error: 'Unauthorized' });
} }
// Soft delete // Soft delete (preserve content for potential restoration)
await comment.update({ isDeleted: true, content: '[deleted]' }); await comment.update({ isDeleted: true });
// Decrement comment count // Decrement comment count
const post = await ForumPost.findByPk(comment.postId); 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; module.exports = router;

View File

@@ -44,7 +44,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
const clientId = process.env.REACT_APP_GOOGLE_CLIENT_ID; const clientId = process.env.REACT_APP_GOOGLE_CLIENT_ID;
const redirectUri = `${window.location.origin}/auth/google/callback`; const redirectUri = `${window.location.origin}/auth/google/callback`;
const scope = 'email profile'; const scope = 'openid email profile';
const responseType = 'code'; const responseType = 'code';
const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` + const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +

View File

@@ -13,6 +13,9 @@ interface CommentThreadProps {
isPostAuthor?: boolean; isPostAuthor?: boolean;
acceptedAnswerId?: string; acceptedAnswerId?: string;
depth?: number; depth?: number;
isAdmin?: boolean;
onAdminDelete?: (commentId: string) => Promise<void>;
onAdminRestore?: (commentId: string) => Promise<void>;
} }
const CommentThread: React.FC<CommentThreadProps> = ({ const CommentThread: React.FC<CommentThreadProps> = ({
@@ -25,6 +28,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
isPostAuthor = false, isPostAuthor = false,
acceptedAnswerId, acceptedAnswerId,
depth = 0, depth = 0,
isAdmin = false,
onAdminDelete,
onAdminRestore,
}) => { }) => {
const [showReplyForm, setShowReplyForm] = useState(false); const [showReplyForm, setShowReplyForm] = useState(false);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -89,7 +95,7 @@ const CommentThread: React.FC<CommentThreadProps> = ({
} }
}; };
if (comment.isDeleted) { if (comment.isDeleted && !isAdmin) {
return ( return (
<div className={`comment-deleted ${depth > 0 ? "ms-4" : ""} mb-3`}> <div className={`comment-deleted ${depth > 0 ? "ms-4" : ""} mb-3`}>
<div className="card bg-light"> <div className="card bg-light">
@@ -111,6 +117,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
isPostAuthor={isPostAuthor} isPostAuthor={isPostAuthor}
acceptedAnswerId={acceptedAnswerId} acceptedAnswerId={acceptedAnswerId}
depth={depth + 1} depth={depth + 1}
isAdmin={isAdmin}
onAdminDelete={onAdminDelete}
onAdminRestore={onAdminRestore}
/> />
))} ))}
</div> </div>
@@ -121,8 +130,17 @@ const CommentThread: React.FC<CommentThreadProps> = ({
return ( return (
<div className={`comment ${depth > 0 ? "ms-4" : ""} mb-3`}> <div className={`comment ${depth > 0 ? "ms-4" : ""} mb-3`}>
<div className={`card ${isAcceptedAnswer ? "border-success" : ""}`}> <div className={`card ${isAcceptedAnswer ? "border-success" : ""} ${comment.isDeleted && isAdmin ? "border-danger" : ""}`}>
<div className="card-body"> <div className="card-body">
{comment.isDeleted && isAdmin && (
<div className="alert alert-danger alert-sm py-1 px-2 mb-2">
<small>
<i className="bi bi-trash me-1"></i>
<strong>Deleted by Admin</strong>
{comment.deletedAt && ` on ${formatDate(comment.deletedAt)}`}
</small>
</div>
)}
{isAcceptedAnswer && ( {isAcceptedAnswer && (
<div className="mb-2"> <div className="mb-2">
<span className="badge bg-success"> <span className="badge bg-success">
@@ -264,6 +282,24 @@ const CommentThread: React.FC<CommentThreadProps> = ({
Delete Delete
</button> </button>
)} )}
{isAdmin && onAdminDelete && !comment.isDeleted && !isEditing && (
<button
className="btn btn-sm btn-link text-danger text-decoration-none p-0"
onClick={() => onAdminDelete(comment.id)}
>
<i className="bi bi-trash me-1"></i>
Delete (Admin)
</button>
)}
{isAdmin && onAdminRestore && comment.isDeleted && !isEditing && (
<button
className="btn btn-sm btn-link text-success text-decoration-none p-0"
onClick={() => onAdminRestore(comment.id)}
>
<i className="bi bi-arrow-counterclockwise me-1"></i>
Restore (Admin)
</button>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -294,6 +330,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
isPostAuthor={isPostAuthor} isPostAuthor={isPostAuthor}
acceptedAnswerId={acceptedAnswerId} acceptedAnswerId={acceptedAnswerId}
depth={depth + 1} depth={depth + 1}
isAdmin={isAdmin}
onAdminDelete={onAdminDelete}
onAdminRestore={onAdminRestore}
/> />
))} ))}
</div> </div>

View File

@@ -3,12 +3,16 @@ import { Link } from "react-router-dom";
import { ForumPost } from "../types"; import { ForumPost } from "../types";
import CategoryBadge from "./CategoryBadge"; import CategoryBadge from "./CategoryBadge";
import PostStatusBadge from "./PostStatusBadge"; import PostStatusBadge from "./PostStatusBadge";
import { useAuth } from "../contexts/AuthContext";
interface ForumPostListItemProps { interface ForumPostListItemProps {
post: ForumPost; post: ForumPost;
filter?: string;
} }
const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => { const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post, filter }) => {
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
const now = new Date(); const now = new Date();
@@ -38,9 +42,12 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
: text; : text;
}; };
// Build link with filter param if admin and filter is set
const linkTo = filter && isAdmin ? `/forum/${post.id}?filter=${filter}` : `/forum/${post.id}`;
return ( return (
<div className="list-group-item list-group-item-action p-3"> <div className="list-group-item list-group-item-action p-3">
<Link to={`/forum/${post.id}`} className="text-decoration-none d-block"> <Link to={linkTo} className="text-decoration-none d-block">
<div className="row align-items-center"> <div className="row align-items-center">
{/* Main content - 60% */} {/* Main content - 60% */}
<div className="col-md-7"> <div className="col-md-7">
@@ -51,6 +58,12 @@ const ForumPostListItem: React.FC<ForumPostListItemProps> = ({ post }) => {
<i className="bi bi-pin-angle-fill"></i> <i className="bi bi-pin-angle-fill"></i>
</span> </span>
)} )}
{post.isDeleted && isAdmin && (
<span className="badge bg-danger badge-sm">
<i className="bi bi-trash me-1"></i>
Deleted
</span>
)}
<CategoryBadge category={post.category} /> <CategoryBadge category={post.category} />
<PostStatusBadge status={post.status} /> <PostStatusBadge status={post.status} />
{post.tags && {post.tags &&

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { forumAPI, getForumImageUrl } from '../services/api'; import { forumAPI, getForumImageUrl } from '../services/api';
import { ForumPost, ForumComment } from '../types'; import { ForumPost, ForumComment } from '../types';
@@ -13,10 +13,20 @@ const ForumPostDetail: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [post, setPost] = useState<ForumPost | null>(null); const [post, setPost] = useState<ForumPost | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [showAdminModal, setShowAdminModal] = useState(false);
const [adminAction, setAdminAction] = useState<{
type: 'deletePost' | 'deleteComment' | 'restorePost' | 'restoreComment';
id?: string;
} | null>(null);
// Read filter from URL query param
const filter = searchParams.get('filter') || 'active';
const isAdmin = user?.role === 'admin';
useEffect(() => { useEffect(() => {
if (id) { if (id) {
@@ -131,6 +141,62 @@ const ForumPostDetail: React.FC = () => {
} }
}; };
const handleAdminDeletePost = async () => {
setAdminAction({ type: 'deletePost' });
setShowAdminModal(true);
};
const handleAdminRestorePost = async () => {
setAdminAction({ type: 'restorePost' });
setShowAdminModal(true);
};
const handleAdminDeleteComment = async (commentId: string) => {
setAdminAction({ type: 'deleteComment', id: commentId });
setShowAdminModal(true);
};
const handleAdminRestoreComment = async (commentId: string) => {
setAdminAction({ type: 'restoreComment', id: commentId });
setShowAdminModal(true);
};
const confirmAdminAction = async () => {
if (!adminAction) return;
try {
setActionLoading(true);
setShowAdminModal(false);
switch (adminAction.type) {
case 'deletePost':
await forumAPI.adminDeletePost(id!);
break;
case 'restorePost':
await forumAPI.adminRestorePost(id!);
break;
case 'deleteComment':
await forumAPI.adminDeleteComment(adminAction.id!);
break;
case 'restoreComment':
await forumAPI.adminRestoreComment(adminAction.id!);
break;
}
await fetchPost(); // Refresh to show updated status
} catch (err: any) {
alert(err.response?.data?.error || 'Failed to perform admin action');
} finally {
setActionLoading(false);
setAdminAction(null);
}
};
const cancelAdminAction = () => {
setShowAdminModal(false);
setAdminAction(null);
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleString(); return date.toLocaleString();
@@ -180,8 +246,15 @@ const ForumPostDetail: React.FC = () => {
<div className="row"> <div className="row">
<div className="col-lg-8"> <div className="col-lg-8">
{/* Post Content */} {/* Post Content */}
<div className="card mb-4"> <div className={`card mb-4 ${post.isDeleted && isAdmin ? 'border-danger' : ''}`}>
<div className="card-body"> <div className="card-body">
{post.isDeleted && isAdmin && (
<div className="alert alert-danger mb-3">
<i className="bi bi-eye-slash me-2"></i>
<strong>Deleted by Admin</strong> - This post is deleted and hidden from regular users
{post.deletedAt && ` on ${formatDate(post.deletedAt)}`}
</div>
)}
{post.isPinned && ( {post.isPinned && (
<span className="badge bg-danger me-2 mb-2"> <span className="badge bg-danger me-2 mb-2">
<i className="bi bi-pin-angle-fill me-1"></i> <i className="bi bi-pin-angle-fill me-1"></i>
@@ -275,6 +348,30 @@ const ForumPostDetail: React.FC = () => {
</div> </div>
)} )}
{isAdmin && (
<div className="d-flex gap-2 flex-wrap mb-3">
{!post.isDeleted ? (
<button
className="btn btn-danger"
onClick={handleAdminDeletePost}
disabled={actionLoading}
>
<i className="bi bi-trash me-1"></i>
Delete Post (Admin)
</button>
) : (
<button
className="btn btn-success"
onClick={handleAdminRestorePost}
disabled={actionLoading}
>
<i className="bi bi-arrow-counterclockwise me-1"></i>
Restore Post (Admin)
</button>
)}
</div>
)}
<hr /> <hr />
{/* Comments Section */} {/* Comments Section */}
@@ -293,6 +390,12 @@ const ForumPostDetail: React.FC = () => {
if (b.id === post.acceptedAnswerId) return 1; if (b.id === post.acceptedAnswerId) return 1;
return 0; return 0;
}) })
.filter((comment: ForumComment) => {
// Filter comments based on deletion status (admin only)
if (!isAdmin) return true; // Non-admins see all non-deleted (backend already filters)
if (filter === 'deleted') return comment.isDeleted; // Only show deleted comments
return true; // 'active' and 'all' show all comments
})
.map((comment: ForumComment) => ( .map((comment: ForumComment) => (
<CommentThread <CommentThread
key={comment.id} key={comment.id}
@@ -304,6 +407,9 @@ const ForumPostDetail: React.FC = () => {
currentUserId={user?.id} currentUserId={user?.id}
isPostAuthor={isAuthor} isPostAuthor={isAuthor}
acceptedAnswerId={post.acceptedAnswerId} acceptedAnswerId={post.acceptedAnswerId}
isAdmin={isAdmin}
onAdminDelete={handleAdminDeleteComment}
onAdminRestore={handleAdminRestoreComment}
/> />
))} ))}
</div> </div>
@@ -336,6 +442,69 @@ const ForumPostDetail: React.FC = () => {
</div> </div>
</div> </div>
{/* Admin Action Confirmation Modal */}
{showAdminModal && (
<div className="modal fade show d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<i className="bi bi-shield-exclamation me-2"></i>
Confirm Admin Action
</h5>
<button
type="button"
className="btn-close"
onClick={cancelAdminAction}
disabled={actionLoading}
></button>
</div>
<div className="modal-body">
{adminAction?.type === 'deletePost' && (
<p>Are you sure you want to delete this post? It will be deleted and hidden from regular users but can be restored later.</p>
)}
{adminAction?.type === 'restorePost' && (
<p>Are you sure you want to restore this post? It will become visible to all users again.</p>
)}
{adminAction?.type === 'deleteComment' && (
<p>Are you sure you want to delete this comment? It will be deleted and hidden from regular users but can be restored later.</p>
)}
{adminAction?.type === 'restoreComment' && (
<p>Are you sure you want to restore this comment? It will become visible to all users again.</p>
)}
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={cancelAdminAction}
disabled={actionLoading}
>
Cancel
</button>
<button
type="button"
className={`btn ${adminAction?.type.includes('delete') ? 'btn-danger' : 'btn-success'}`}
onClick={confirmAdminAction}
disabled={actionLoading}
>
{actionLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Processing...
</>
) : (
<>
{adminAction?.type.includes('delete') ? 'Delete' : 'Restore'}
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { forumAPI } from '../services/api'; import { forumAPI } from '../services/api';
import { ForumPost } from '../types'; import { ForumPost } from '../types';
@@ -8,6 +8,8 @@ import AuthButton from '../components/AuthButton';
const ForumPosts: React.FC = () => { const ForumPosts: React.FC = () => {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const [searchParams, setSearchParams] = useSearchParams();
const [posts, setPosts] = useState<ForumPost[]>([]); const [posts, setPosts] = useState<ForumPost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -15,12 +17,16 @@ const ForumPosts: React.FC = () => {
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalPosts, setTotalPosts] = useState(0); const [totalPosts, setTotalPosts] = useState(0);
// Initialize filter from URL param or default to 'active'
const initialFilter = searchParams.get('filter') || 'active';
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
search: '', search: '',
category: '', category: '',
tag: '', tag: '',
status: '', status: '',
sort: 'recent' sort: 'recent',
deletionFilter: initialFilter // all, active, deleted
}); });
const categories = [ const categories = [
@@ -65,6 +71,11 @@ const ForumPosts: React.FC = () => {
const { name, value } = e.target; const { name, value } = e.target;
setFilters(prev => ({ ...prev, [name]: value })); setFilters(prev => ({ ...prev, [name]: value }));
setCurrentPage(1); setCurrentPage(1);
// Update URL param when deletion filter changes
if (name === 'deletionFilter' && isAdmin) {
setSearchParams({ filter: value });
}
}; };
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
@@ -81,6 +92,16 @@ const ForumPosts: React.FC = () => {
setCurrentPage(1); setCurrentPage(1);
}; };
// Filter posts based on deletion status (admin only)
const filteredPosts = isAdmin ? posts.filter(post => {
if (filters.deletionFilter === 'active') {
return !post.isDeleted; // Show active posts regardless of deleted comments
} else if (filters.deletionFilter === 'deleted') {
return post.isDeleted || post.hasDeletedComments; // Show deleted posts OR posts with deleted comments
}
return true; // 'all' shows everything
}) : posts;
return ( return (
<div className="container mt-4"> <div className="container mt-4">
<div className="d-flex justify-content-between align-items-center mb-4"> <div className="d-flex justify-content-between align-items-center mb-4">
@@ -112,7 +133,7 @@ const ForumPosts: React.FC = () => {
{/* Filters */} {/* Filters */}
<div className="row mb-4"> <div className="row mb-4">
<div className="col-md-6"> <div className={isAdmin ? "col-md-5" : "col-md-6"}>
<form onSubmit={handleSearch}> <form onSubmit={handleSearch}>
<div className="input-group"> <div className="input-group">
<input <input
@@ -129,7 +150,21 @@ const ForumPosts: React.FC = () => {
</div> </div>
</form> </form>
</div> </div>
<div className="col-md-3"> {isAdmin && (
<div className="col-md-2">
<select
className="form-select"
name="deletionFilter"
value={filters.deletionFilter}
onChange={handleFilterChange}
>
<option value="active">Active</option>
<option value="all">All</option>
<option value="deleted">Deleted</option>
</select>
</div>
)}
<div className={isAdmin ? "col-md-2" : "col-md-3"}>
<select <select
className="form-select" className="form-select"
name="status" name="status"
@@ -142,7 +177,7 @@ const ForumPosts: React.FC = () => {
<option value="closed">Closed</option> <option value="closed">Closed</option>
</select> </select>
</div> </div>
<div className="col-md-3"> <div className={isAdmin ? "col-md-3" : "col-md-3"}>
<select <select
className="form-select" className="form-select"
name="sort" name="sort"
@@ -171,11 +206,11 @@ const ForumPosts: React.FC = () => {
<> <>
<div className="d-flex justify-content-between align-items-center mb-3"> <div className="d-flex justify-content-between align-items-center mb-3">
<p className="text-muted mb-0"> <p className="text-muted mb-0">
Showing {posts.length} of {totalPosts} posts Showing {filteredPosts.length} of {totalPosts} posts
</p> </p>
</div> </div>
{posts.length === 0 ? ( {filteredPosts.length === 0 ? (
<div className="text-center py-5"> <div className="text-center py-5">
<i className="bi bi-inbox display-1 text-muted"></i> <i className="bi bi-inbox display-1 text-muted"></i>
<h3 className="mt-3">No posts found</h3> <h3 className="mt-3">No posts found</h3>
@@ -194,8 +229,8 @@ const ForumPosts: React.FC = () => {
) : ( ) : (
<> <>
<div className="list-group list-group-flush mb-4"> <div className="list-group list-group-flush mb-4">
{posts.map((post) => ( {filteredPosts.map((post) => (
<ForumPostListItem key={post.id} post={post} /> <ForumPostListItem key={post.id} post={post} filter={isAdmin ? filters.deletionFilter : undefined} />
))} ))}
</div> </div>

View File

@@ -281,6 +281,11 @@ export const forumAPI = {
api.put(`/forum/comments/${commentId}`, data), api.put(`/forum/comments/${commentId}`, data),
deleteComment: (commentId: string) => deleteComment: (commentId: string) =>
api.delete(`/forum/comments/${commentId}`), api.delete(`/forum/comments/${commentId}`),
// Admin endpoints
adminDeletePost: (id: string) => api.delete(`/forum/admin/posts/${id}`),
adminRestorePost: (id: string) => api.patch(`/forum/admin/posts/${id}/restore`),
adminDeleteComment: (id: string) => api.delete(`/forum/admin/comments/${id}`),
adminRestoreComment: (id: string) => api.patch(`/forum/admin/comments/${id}/restore`),
}; };
export const stripeAPI = { export const stripeAPI = {

View File

@@ -29,6 +29,7 @@ export interface User {
country?: string; country?: string;
profileImage?: string; profileImage?: string;
isVerified: boolean; isVerified: boolean;
role?: "user" | "admin";
stripeConnectedAccountId?: string; stripeConnectedAccountId?: string;
addresses?: Address[]; addresses?: Address[];
} }
@@ -267,6 +268,10 @@ export interface ForumPost {
isPinned: boolean; isPinned: boolean;
acceptedAnswerId?: string; acceptedAnswerId?: string;
images?: string[]; images?: string[];
isDeleted?: boolean;
deletedBy?: string;
deletedAt?: string;
hasDeletedComments?: boolean;
author?: User; author?: User;
tags?: PostTag[]; tags?: PostTag[];
comments?: ForumComment[]; comments?: ForumComment[];
@@ -282,6 +287,8 @@ export interface ForumComment {
parentCommentId?: string; parentCommentId?: string;
isDeleted: boolean; isDeleted: boolean;
images?: string[]; images?: string[];
deletedBy?: string;
deletedAt?: string;
author?: User; author?: User;
replies?: ForumComment[]; replies?: ForumComment[];
createdAt: string; createdAt: string;