From b045fbeb01070bb34d42df5cd088581129eac1ea Mon Sep 17 00:00:00 2001 From: jackiettran <41605212+jackiettran@users.noreply.github.com> Date: Tue, 11 Nov 2025 18:23:11 -0500 Subject: [PATCH] Can mark a comment as the answer, some layout changes --- backend/models/ForumPost.js | 10 +- backend/routes/forum.js | 84 +++++- frontend/src/components/CommentThread.tsx | 88 ++++-- frontend/src/components/ForumPostCard.tsx | 115 -------- frontend/src/components/ForumPostListItem.tsx | 111 ++++++++ frontend/src/components/PostStatusBadge.tsx | 6 +- frontend/src/pages/ForumPostDetail.tsx | 268 +++++++----------- frontend/src/pages/ForumPosts.tsx | 11 +- frontend/src/pages/MyPosts.tsx | 4 +- frontend/src/services/api.ts | 2 + frontend/src/types/index.ts | 3 +- 11 files changed, 379 insertions(+), 323 deletions(-) delete mode 100644 frontend/src/components/ForumPostCard.tsx create mode 100644 frontend/src/components/ForumPostListItem.tsx diff --git a/backend/models/ForumPost.js b/backend/models/ForumPost.js index 8f43905..cacf061 100644 --- a/backend/models/ForumPost.js +++ b/backend/models/ForumPost.js @@ -29,7 +29,7 @@ const ForumPost = sequelize.define('ForumPost', { defaultValue: 'general_discussion' }, status: { - type: DataTypes.ENUM('open', 'solved', 'closed'), + type: DataTypes.ENUM('open', 'answered', 'closed'), defaultValue: 'open' }, viewCount: { @@ -43,6 +43,14 @@ const ForumPost = sequelize.define('ForumPost', { isPinned: { type: DataTypes.BOOLEAN, defaultValue: false + }, + acceptedAnswerId: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: 'ForumComments', + key: 'id' + } } }); diff --git a/backend/routes/forum.js b/backend/routes/forum.js index 7775287..e1d917c 100644 --- a/backend/routes/forum.js +++ b/backend/routes/forum.js @@ -156,9 +156,11 @@ router.get('/posts/:id', async (req, res) => { as: 'author', attributes: ['id', 'username', 'firstName', 'lastName'] } - ], - order: [['createdAt', 'ASC']] + ] } + ], + order: [ + [{ model: ForumComment, as: 'comments' }, 'createdAt', 'ASC'] ] }); @@ -370,7 +372,7 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => { return res.status(403).json({ error: 'Only the author can update post status' }); } - if (!['open', 'solved', 'closed'].includes(status)) { + if (!['open', 'answered', 'closed'].includes(status)) { return res.status(400).json({ error: 'Invalid status value' }); } @@ -411,6 +413,82 @@ router.patch('/posts/:id/status', authenticateToken, async (req, res) => { } }); +// PATCH /api/forum/posts/:id/accept-answer - Mark/unmark comment as accepted answer +router.patch('/posts/:id/accept-answer', authenticateToken, async (req, res) => { + 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 + await post.update({ + acceptedAnswerId: commentId || null, + status: commentId ? 'answered' : 'open' + }); + + const updatedPost = await ForumPost.findByPk(post.id, { + include: [ + { + model: User, + as: 'author', + attributes: ['id', 'username', 'firstName', 'lastName'] + }, + { + model: PostTag, + as: 'tags', + attributes: ['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 + }); + res.status(500).json({ error: error.message }); + } +}); + // POST /api/forum/posts/:id/comments - Add comment/reply router.post('/posts/:id/comments', authenticateToken, async (req, res) => { try { diff --git a/frontend/src/components/CommentThread.tsx b/frontend/src/components/CommentThread.tsx index aca677b..0125f04 100644 --- a/frontend/src/components/CommentThread.tsx +++ b/frontend/src/components/CommentThread.tsx @@ -1,13 +1,16 @@ -import React, { useState } from 'react'; -import { ForumComment } from '../types'; -import CommentForm from './CommentForm'; +import React, { useState } from "react"; +import { ForumComment } from "../types"; +import CommentForm from "./CommentForm"; interface CommentThreadProps { comment: ForumComment; onReply: (commentId: string, content: string) => Promise; onEdit?: (commentId: string, content: string) => Promise; onDelete?: (commentId: string) => Promise; + onMarkAsAnswer?: (commentId: string) => Promise; currentUserId?: string; + isPostAuthor?: boolean; + acceptedAnswerId?: string; depth?: number; } @@ -16,7 +19,10 @@ const CommentThread: React.FC = ({ onReply, onEdit, onDelete, + onMarkAsAnswer, currentUserId, + isPostAuthor = false, + acceptedAnswerId, depth = 0, }) => { const [showReplyForm, setShowReplyForm] = useState(false); @@ -33,7 +39,7 @@ const CommentThread: React.FC = ({ const diffDays = Math.floor(diffHours / 24); if (diffMinutes < 1) { - return 'Just now'; + return "Just now"; } else if (diffMinutes < 60) { return `${diffMinutes}m ago`; } else if (diffHours < 24) { @@ -60,7 +66,10 @@ const CommentThread: React.FC = ({ }; const handleDelete = async () => { - if (onDelete && window.confirm('Are you sure you want to delete this comment?')) { + if ( + onDelete && + window.confirm("Are you sure you want to delete this comment?") + ) { await onDelete(comment.id); } }; @@ -68,15 +77,22 @@ const CommentThread: React.FC = ({ const isAuthor = currentUserId === comment.authorId; const maxDepth = 5; const canNest = depth < maxDepth; + const isAcceptedAnswer = acceptedAnswerId === comment.id; + const canMarkAsAnswer = + isPostAuthor && depth === 0 && onMarkAsAnswer && !comment.isDeleted; + + const handleMarkAsAnswer = async () => { + if (onMarkAsAnswer) { + await onMarkAsAnswer(comment.id); + } + }; if (comment.isDeleted) { return ( -
0 ? 'ms-4' : ''} mb-3`}> +
0 ? "ms-4" : ""} mb-3`}>
- - [Comment deleted] - + [Comment deleted]
{comment.replies && comment.replies.length > 0 && ( @@ -88,7 +104,10 @@ const CommentThread: React.FC = ({ onReply={onReply} onEdit={onEdit} onDelete={onDelete} + onMarkAsAnswer={onMarkAsAnswer} currentUserId={currentUserId} + isPostAuthor={isPostAuthor} + acceptedAnswerId={acceptedAnswerId} depth={depth + 1} /> ))} @@ -99,22 +118,33 @@ const CommentThread: React.FC = ({ } return ( -
0 ? 'ms-4' : ''} mb-3`}> -
+
0 ? "ms-4" : ""} mb-3`}> +
+ {isAcceptedAnswer && ( +
+ + + Answer + +
+ )}
-
- {comment.author?.firstName?.charAt(0) || '?'} +
+ {comment.author?.firstName?.charAt(0) || "?"}
- {comment.author?.firstName || 'Unknown'} {comment.author?.lastName || ''} + {comment.author?.firstName || "Unknown"}{" "} + {comment.author?.lastName || ""} {formatDate(comment.createdAt)} - {comment.updatedAt !== comment.createdAt && ' (edited)'} + {comment.updatedAt !== comment.createdAt && " (edited)"}
@@ -123,7 +153,9 @@ const CommentThread: React.FC = ({ className="btn btn-sm btn-link text-decoration-none" onClick={() => setIsCollapsed(!isCollapsed)} > - + )}
@@ -156,7 +188,7 @@ const CommentThread: React.FC = ({
) : ( -

+

{comment.content}

)} @@ -171,6 +203,23 @@ const CommentThread: React.FC = ({ Reply )} + {canMarkAsAnswer && !isEditing && ( + + )} {isAuthor && onEdit && !isEditing && ( - )} +
{post.status !== 'closed' && ( -
- - )} -
-
- - {/* Comments Section */} -
-
-
- - Comments ({post.commentCount || 0}) -
-
-
- {user ? ( -
-
Add a comment
- -
- ) : ( -
- - Log in to join the discussion.
)}
- {post.comments && post.comments.length > 0 ? ( -
- {post.comments.map((comment: ForumComment) => ( - +
+ + Comments ({post.commentCount || 0}) +
+ + {post.comments && post.comments.length > 0 ? ( +
+ {[...post.comments] + .sort((a, b) => { + // Sort accepted answer to the top + if (a.id === post.acceptedAnswerId) return -1; + if (b.id === post.acceptedAnswerId) return 1; + return 0; + }) + .map((comment: ForumComment) => ( + + ))} +
+ ) : ( +
+ +

No comments yet. Be the first to comment!

+
+ )} + +
+ + {user ? ( +
+
Add a comment
+ - ))} -
- ) : ( -
- -

No comments yet. Be the first to comment!

-
- )} -
-
-
- - {/* Sidebar */} -
-
-
-
About this post
-
-
-
- Category: -
- -
-
-
- Status: -
- -
-
-
- Created: -
{formatDate(post.createdAt)}
-
-
- Last updated: -
{formatDate(post.updatedAt)}
-
-
- Author: -
- - {post.author?.firstName || 'Unknown'} {post.author?.lastName || ''} - -
-
-
-
- -
-
-
Actions
-
-
-
- - - Back to Forum - - {user && ( - - - Create New Post - +
+ ) : ( +
+ + Log in to join the discussion. +
)}
+
); diff --git a/frontend/src/pages/ForumPosts.tsx b/frontend/src/pages/ForumPosts.tsx index d6c104b..1c37e49 100644 --- a/frontend/src/pages/ForumPosts.tsx +++ b/frontend/src/pages/ForumPosts.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { forumAPI } from '../services/api'; import { ForumPost } from '../types'; -import ForumPostCard from '../components/ForumPostCard'; +import ForumPostListItem from '../components/ForumPostListItem'; import AuthButton from '../components/AuthButton'; const ForumPosts: React.FC = () => { @@ -138,7 +138,7 @@ const ForumPosts: React.FC = () => { > - +
@@ -151,7 +151,6 @@ const ForumPosts: React.FC = () => { > - @@ -194,11 +193,9 @@ const ForumPosts: React.FC = () => { ) : ( <> -
+
{posts.map((post) => ( -
- -
+ ))}
diff --git a/frontend/src/pages/MyPosts.tsx b/frontend/src/pages/MyPosts.tsx index 4303fd0..4a3ca44 100644 --- a/frontend/src/pages/MyPosts.tsx +++ b/frontend/src/pages/MyPosts.tsx @@ -208,11 +208,11 @@ const MyPosts: React.FC = () => { {post.status === 'open' && ( )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 5e9d23a..a8170fc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -262,6 +262,8 @@ export const forumAPI = { deletePost: (id: string) => api.delete(`/forum/posts/${id}`), updatePostStatus: (id: string, status: string) => api.patch(`/forum/posts/${id}/status`, { status }), + acceptAnswer: (postId: string, commentId: string | null) => + api.patch(`/forum/posts/${postId}/accept-answer`, { commentId }), getMyPosts: () => api.get("/forum/my-posts"), getTags: (params?: any) => api.get("/forum/tags", { params }), createComment: (postId: string, data: any) => diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index a3c657a..ff0630e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -261,10 +261,11 @@ export interface ForumPost { content: string; authorId: string; category: "item_request" | "technical_support" | "community_resources" | "general_discussion"; - status: "open" | "solved" | "closed"; + status: "open" | "answered" | "closed"; viewCount: number; commentCount: number; isPinned: boolean; + acceptedAnswerId?: string; author?: User; tags?: PostTag[]; comments?: ForumComment[];