diff --git a/backend/src/db_models.py b/backend/src/db_models.py index 8603e76..392c449 100644 --- a/backend/src/db_models.py +++ b/backend/src/db_models.py @@ -60,6 +60,8 @@ class User(Base): back_populates="members" ) created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator") + comments: Mapped[List["RequirementComment"]] = relationship("RequirementComment", back_populates="user") + comment_replies: Mapped[List["RequirementCommentReply"]] = relationship("RequirementCommentReply", back_populates="user") class Tag(Base): @@ -225,6 +227,11 @@ class Requirement(Base): foreign_keys="RequirementLink.target_req_id", back_populates="target_requirement" ) + comments: Mapped[List["RequirementComment"]] = relationship( + "RequirementComment", + back_populates="requirement", + cascade="all, delete-orphan" + ) # Indexes __table_args__ = ( @@ -386,3 +393,92 @@ class RequirementLink(Base): Index("idx_link_target", "target_req_id"), UniqueConstraint("source_req_id", "target_req_id", "relationship_type_id", name="uq_req_link_pair"), ) + + +class RequirementComment(Base): + """ + Top-level comments on a requirement. + Supports soft delete to preserve comment threads. + """ + __tablename__ = "requirement_comments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + requirement_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("requirements.id", ondelete="CASCADE"), + nullable=False + ) + user_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True + ) + comment_text: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + nullable=True + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=True + ) + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Relationships + requirement: Mapped["Requirement"] = relationship("Requirement", back_populates="comments") + user: Mapped[Optional["User"]] = relationship("User", back_populates="comments") + replies: Mapped[List["RequirementCommentReply"]] = relationship( + "RequirementCommentReply", + back_populates="parent_comment", + cascade="all, delete-orphan" + ) + + # Indexes + __table_args__ = ( + Index("idx_rc_req", "requirement_id"), + ) + + +class RequirementCommentReply(Base): + """ + Replies to top-level comments. + Only links to requirement_comments, NOT to itself - enforces 1-level nesting. + """ + __tablename__ = "requirement_comment_replies" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + parent_comment_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("requirement_comments.id", ondelete="CASCADE"), + nullable=False + ) + user_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True + ) + reply_text: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + nullable=True + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=True + ) + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # Relationships + parent_comment: Mapped["RequirementComment"] = relationship("RequirementComment", back_populates="replies") + user: Mapped[Optional["User"]] = relationship("User", back_populates="comment_replies") + + # Indexes + __table_args__ = ( + Index("idx_rcr_parent", "parent_comment_id"), + ) diff --git a/backend/src/main.py b/backend/src/main.py index 333c602..2320390 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -13,7 +13,8 @@ from src.models import ( ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest, RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest, RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult, - RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES + RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES, + CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest ) from src.controller import AuthController from src.config import get_openid, get_settings @@ -21,7 +22,7 @@ from src.database import init_db, close_db, get_db from src.repositories import ( RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository, - RelationshipTypeRepository, RequirementLinkRepository + RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository ) import logging @@ -1472,3 +1473,278 @@ async def delete_requirement_link( await link_repo.delete(link_id) await db.commit() + + +# =========================================== +# Comment Endpoints +# =========================================== + +def _build_comment_response(comment) -> CommentResponse: + """Helper function to build CommentResponse from a RequirementComment model.""" + return CommentResponse( + id=comment.id, + comment_text=comment.comment_text, + created_at=comment.created_at, + updated_at=comment.updated_at, + author_id=comment.user_id, + author_username=comment.user.username if comment.user else None, + author_full_name=comment.user.full_name if comment.user else None, + author_role=comment.user.role.role_name if comment.user and comment.user.role else None, + replies=[ + CommentReplyResponse( + id=reply.id, + reply_text=reply.reply_text, + created_at=reply.created_at, + updated_at=reply.updated_at, + author_id=reply.user_id, + author_username=reply.user.username if reply.user else None, + author_full_name=reply.user.full_name if reply.user else None, + author_role=reply.user.role.role_name if reply.user and reply.user.role else None, + ) + for reply in comment.replies + ] + ) + + +@app.get("/api/requirements/{requirement_id}/comments", response_model=List[CommentResponse]) +async def get_requirement_comments( + requirement_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Get all comments for a requirement with their replies. + User must be a member of the requirement's project. + + Args: + requirement_id: The requirement ID + + Returns: + List of comments with nested replies. + """ + user = await _get_current_user_db(request, db) + + # Check if requirement exists + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(requirement_id) + if not requirement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Requirement with id {requirement_id} not found" + ) + + await _verify_project_membership(requirement.project_id, user.id, db) + + comment_repo = CommentRepository(db) + comments = await comment_repo.get_comments_by_requirement_id(requirement_id) + + return [_build_comment_response(c) for c in comments] + + +@app.post("/api/requirements/{requirement_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) +async def create_comment( + requirement_id: int, + request: Request, + comment_data: CommentCreateRequest, + db: AsyncSession = Depends(get_db) +): + """ + Create a new comment on a requirement. + Any project member can create comments. + + Args: + requirement_id: The requirement to comment on + comment_data: The comment content + + Returns: + The created comment. + """ + user = await _get_current_user_db(request, db) + + # Check if requirement exists + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(requirement_id) + if not requirement: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Requirement with id {requirement_id} not found" + ) + + await _verify_project_membership(requirement.project_id, user.id, db) + + # Validate comment text + if not comment_data.comment_text.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Comment text cannot be empty" + ) + + comment_repo = CommentRepository(db) + comment = await comment_repo.create_comment( + requirement_id=requirement_id, + user_id=user.id, + comment_text=comment_data.comment_text.strip() + ) + + await db.commit() + + # Fetch the complete comment with user data + comment = await comment_repo.get_comment_by_id(comment.id) + + return _build_comment_response(comment) + + +@app.delete("/api/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_comment( + comment_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Soft delete a comment (hides it from view). + Only the comment author or an admin can delete. + + Args: + comment_id: The comment ID to delete + """ + user = await _get_current_user_db(request, db) + + comment_repo = CommentRepository(db) + comment = await comment_repo.get_comment_by_id(comment_id) + + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Comment with id {comment_id} not found" + ) + + # Verify user is a member of the project + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(comment.requirement_id) + await _verify_project_membership(requirement.project_id, user.id, db) + + # Only author or admin can delete + if comment.user_id != user.id and user.role_id != 3: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the comment author or an admin can delete this comment" + ) + + await comment_repo.soft_delete_comment(comment_id) + await db.commit() + + +@app.post("/api/comments/{comment_id}/replies", response_model=CommentReplyResponse, status_code=status.HTTP_201_CREATED) +async def create_reply( + comment_id: int, + request: Request, + reply_data: ReplyCreateRequest, + db: AsyncSession = Depends(get_db) +): + """ + Create a reply to a comment. + Any project member can create replies. + + Args: + comment_id: The parent comment ID + reply_data: The reply content + + Returns: + The created reply. + """ + user = await _get_current_user_db(request, db) + + # Check if parent comment exists + comment_repo = CommentRepository(db) + parent_comment = await comment_repo.get_comment_by_id(comment_id) + + if not parent_comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Comment with id {comment_id} not found" + ) + + if parent_comment.is_deleted: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot reply to a deleted comment" + ) + + # Verify user is a member of the project + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(parent_comment.requirement_id) + await _verify_project_membership(requirement.project_id, user.id, db) + + # Validate reply text + if not reply_data.reply_text.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Reply text cannot be empty" + ) + + reply_repo = ReplyRepository(db) + reply = await reply_repo.create_reply( + parent_comment_id=comment_id, + user_id=user.id, + reply_text=reply_data.reply_text.strip() + ) + + await db.commit() + + # Fetch the complete reply with user data + reply = await reply_repo.get_reply_by_id(reply.id) + + return CommentReplyResponse( + id=reply.id, + reply_text=reply.reply_text, + created_at=reply.created_at, + updated_at=reply.updated_at, + author_id=reply.user_id, + author_username=reply.user.username if reply.user else None, + author_full_name=reply.user.full_name if reply.user else None, + author_role=reply.user.role.role_name if reply.user and reply.user.role else None, + ) + + +@app.delete("/api/replies/{reply_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_reply( + reply_id: int, + request: Request, + db: AsyncSession = Depends(get_db) +): + """ + Soft delete a reply (hides it from view). + Only the reply author or an admin can delete. + + Args: + reply_id: The reply ID to delete + """ + user = await _get_current_user_db(request, db) + + reply_repo = ReplyRepository(db) + reply = await reply_repo.get_reply_by_id(reply_id) + + if not reply: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Reply with id {reply_id} not found" + ) + + # Get the parent comment to check project membership + comment_repo = CommentRepository(db) + parent_comment = await comment_repo.get_comment_by_id(reply.parent_comment_id) + + # Verify user is a member of the project + req_repo = RequirementRepository(db) + requirement = await req_repo.get_by_id(parent_comment.requirement_id) + await _verify_project_membership(requirement.project_id, user.id, db) + + # Only author or admin can delete + if reply.user_id != user.id and user.role_id != 3: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the reply author or an admin can delete this reply" + ) + + await reply_repo.soft_delete_reply(reply_id) + await db.commit() diff --git a/backend/src/models.py b/backend/src/models.py index 348990b..4c13dd5 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -309,3 +309,45 @@ class RequirementSearchResult(BaseModel): class Config: from_attributes = True + + +# Comment schemas +class CommentReplyResponse(BaseModel): + """Response schema for a comment reply.""" + id: int + reply_text: str + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + author_id: Optional[int] = None + author_username: Optional[str] = None + author_full_name: Optional[str] = None + author_role: Optional[str] = None + + class Config: + from_attributes = True + + +class CommentResponse(BaseModel): + """Response schema for a comment with its replies.""" + id: int + comment_text: str + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + author_id: Optional[int] = None + author_username: Optional[str] = None + author_full_name: Optional[str] = None + author_role: Optional[str] = None + replies: List[CommentReplyResponse] = [] + + class Config: + from_attributes = True + + +class CommentCreateRequest(BaseModel): + """Request schema for creating a comment.""" + comment_text: str + + +class ReplyCreateRequest(BaseModel): + """Request schema for creating a reply.""" + reply_text: str diff --git a/backend/src/repositories/__init__.py b/backend/src/repositories/__init__.py index ae729ac..0fb222c 100644 --- a/backend/src/repositories/__init__.py +++ b/backend/src/repositories/__init__.py @@ -11,6 +11,7 @@ from src.repositories.validation_status_repository import ValidationStatusReposi from src.repositories.validation_repository import ValidationRepository from src.repositories.relationship_type_repository import RelationshipTypeRepository from src.repositories.requirement_link_repository import RequirementLinkRepository +from src.repositories.comment_repository import CommentRepository, ReplyRepository __all__ = [ "UserRepository", @@ -24,4 +25,6 @@ __all__ = [ "ValidationRepository", "RelationshipTypeRepository", "RequirementLinkRepository", + "CommentRepository", + "ReplyRepository", ] diff --git a/backend/src/repositories/comment_repository.py b/backend/src/repositories/comment_repository.py new file mode 100644 index 0000000..30a0086 --- /dev/null +++ b/backend/src/repositories/comment_repository.py @@ -0,0 +1,184 @@ +""" +Repository for Comment database operations. +Handles CRUD operations for requirement comments and replies. +""" +from typing import List, Optional +from sqlalchemy import select, and_ +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession +from src.db_models import RequirementComment, RequirementCommentReply, User + + +class CommentRepository: + """Repository for comment CRUD operations.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_comment( + self, + requirement_id: int, + user_id: int, + comment_text: str + ) -> RequirementComment: + """ + Create a new top-level comment on a requirement. + + Args: + requirement_id: The requirement being commented on + user_id: The user creating the comment + comment_text: The comment content + """ + comment = RequirementComment( + requirement_id=requirement_id, + user_id=user_id, + comment_text=comment_text + ) + self.db.add(comment) + await self.db.flush() + await self.db.refresh(comment) + return comment + + async def get_comment_by_id(self, comment_id: int) -> Optional[RequirementComment]: + """Get a comment by ID with related user data.""" + result = await self.db.execute( + select(RequirementComment) + .options( + selectinload(RequirementComment.user).selectinload(User.role), + selectinload(RequirementComment.replies).selectinload(RequirementCommentReply.user).selectinload(User.role) + ) + .where(RequirementComment.id == comment_id) + ) + return result.scalar_one_or_none() + + async def get_comments_by_requirement_id(self, requirement_id: int) -> List[RequirementComment]: + """ + Get all non-deleted comments for a requirement, ordered by creation date (oldest first). + Includes replies (also filtered by is_deleted) and user data. + """ + result = await self.db.execute( + select(RequirementComment) + .options( + selectinload(RequirementComment.user).selectinload(User.role), + selectinload(RequirementComment.replies).selectinload(RequirementCommentReply.user).selectinload(User.role) + ) + .where( + and_( + RequirementComment.requirement_id == requirement_id, + RequirementComment.is_deleted == False + ) + ) + .order_by(RequirementComment.created_at.asc()) + ) + comments = list(result.scalars().all()) + + # Filter out deleted replies from each comment + for comment in comments: + comment.replies = [r for r in comment.replies if not r.is_deleted] + # Sort replies by created_at + comment.replies.sort(key=lambda r: r.created_at or r.id) + + return comments + + async def update_comment( + self, + comment_id: int, + comment_text: str + ) -> Optional[RequirementComment]: + """Update a comment's text.""" + comment = await self.get_comment_by_id(comment_id) + if not comment: + return None + + comment.comment_text = comment_text + await self.db.flush() + await self.db.refresh(comment) + return comment + + async def soft_delete_comment(self, comment_id: int) -> bool: + """ + Soft delete a comment by setting is_deleted to True. + Returns True if deleted, False if not found. + """ + result = await self.db.execute( + select(RequirementComment).where(RequirementComment.id == comment_id) + ) + comment = result.scalar_one_or_none() + if comment: + comment.is_deleted = True + await self.db.flush() + return True + return False + + +class ReplyRepository: + """Repository for comment reply CRUD operations.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_reply( + self, + parent_comment_id: int, + user_id: int, + reply_text: str + ) -> RequirementCommentReply: + """ + Create a new reply to a comment. + + Args: + parent_comment_id: The parent comment being replied to + user_id: The user creating the reply + reply_text: The reply content + """ + reply = RequirementCommentReply( + parent_comment_id=parent_comment_id, + user_id=user_id, + reply_text=reply_text + ) + self.db.add(reply) + await self.db.flush() + await self.db.refresh(reply) + return reply + + async def get_reply_by_id(self, reply_id: int) -> Optional[RequirementCommentReply]: + """Get a reply by ID with related user and parent comment data.""" + result = await self.db.execute( + select(RequirementCommentReply) + .options( + selectinload(RequirementCommentReply.user).selectinload(User.role), + selectinload(RequirementCommentReply.parent_comment) + ) + .where(RequirementCommentReply.id == reply_id) + ) + return result.scalar_one_or_none() + + async def update_reply( + self, + reply_id: int, + reply_text: str + ) -> Optional[RequirementCommentReply]: + """Update a reply's text.""" + reply = await self.get_reply_by_id(reply_id) + if not reply: + return None + + reply.reply_text = reply_text + await self.db.flush() + await self.db.refresh(reply) + return reply + + async def soft_delete_reply(self, reply_id: int) -> bool: + """ + Soft delete a reply by setting is_deleted to True. + Returns True if deleted, False if not found. + """ + result = await self.db.execute( + select(RequirementCommentReply).where(RequirementCommentReply.id == reply_id) + ) + reply = result.scalar_one_or_none() + if reply: + reply.is_deleted = True + await self.db.flush() + return True + return False diff --git a/frontend/src/pages/RequirementDetailPage.tsx b/frontend/src/pages/RequirementDetailPage.tsx index 0537ff0..49fc406 100644 --- a/frontend/src/pages/RequirementDetailPage.tsx +++ b/frontend/src/pages/RequirementDetailPage.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from 'react' import { useAuth, useProject } from '@/hooks' import { useParams, Link } from 'react-router-dom' -import { requirementService, validationService, relationshipService } from '@/services' +import { requirementService, validationService, relationshipService, commentService } from '@/services' import type { Requirement } from '@/services/requirementService' import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService' -import type { ValidationStatus, ValidationHistory } from '@/types' +import type { ValidationStatus, ValidationHistory, Comment } from '@/types' // Tab types type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' @@ -41,6 +41,17 @@ export default function RequirementDetailPage() { const [addLinkError, setAddLinkError] = useState(null) const [deletingLinkId, setDeletingLinkId] = useState(null) + // Comments state + const [comments, setComments] = useState([]) + const [commentsLoading, setCommentsLoading] = useState(false) + const [newCommentText, setNewCommentText] = useState('') + const [postingComment, setPostingComment] = useState(false) + const [replyingToCommentId, setReplyingToCommentId] = useState(null) + const [replyText, setReplyText] = useState('') + const [postingReply, setPostingReply] = useState(false) + const [deletingCommentId, setDeletingCommentId] = useState(null) + const [deletingReplyId, setDeletingReplyId] = useState(null) + // Fetch requirement data on mount useEffect(() => { const fetchRequirement = async () => { @@ -112,6 +123,25 @@ export default function RequirementDetailPage() { fetchRelationshipsData() }, [activeTab, id, currentProject]) + // Fetch comments data when shared-comments tab is active + useEffect(() => { + const fetchCommentsData = async () => { + if (activeTab !== 'shared-comments' || !id) return + + try { + setCommentsLoading(true) + const commentsData = await commentService.getComments(parseInt(id, 10)) + setComments(commentsData) + } catch (err) { + console.error('Failed to fetch comments:', err) + } finally { + setCommentsLoading(false) + } + } + + fetchCommentsData() + }, [activeTab, id]) + // Debounced search for target requirements useEffect(() => { if (!showAddRelationshipModal || !currentProject || !id) return @@ -247,6 +277,107 @@ export default function RequirementDetailPage() { return user.role_id === 1 || link.created_by_id === user.db_user_id } + // Check if user can delete a comment or reply (author or admin) + const canDelete = (authorId: number | null): boolean => { + if (!user) return false + return user.role_id === 3 || authorId === user.db_user_id + } + + // Get display name for comment author + const getAuthorDisplayName = (fullName: string | null, username: string | null): string => { + return fullName || username || 'Unknown User' + } + + // Get role display name + const getRoleDisplayName = (role: string | null): string => { + if (!role) return 'User' + const roleDisplayNames: Record = { + 'editor': 'Editor', + 'auditor': 'Auditor', + 'admin': 'Project Admin' + } + return roleDisplayNames[role] || role + } + + // Handle posting a new comment + const handlePostComment = async () => { + if (!newCommentText.trim() || !id) return + + try { + setPostingComment(true) + const newComment = await commentService.createComment(parseInt(id, 10), { + comment_text: newCommentText.trim() + }) + setComments(prev => [...prev, newComment]) + setNewCommentText('') + } catch (err) { + console.error('Failed to post comment:', err) + alert('Failed to post comment. Please try again.') + } finally { + setPostingComment(false) + } + } + + // Handle posting a reply + const handlePostReply = async (commentId: number) => { + if (!replyText.trim()) return + + try { + setPostingReply(true) + const newReply = await commentService.createReply(commentId, { + reply_text: replyText.trim() + }) + setComments(prev => prev.map(c => + c.id === commentId + ? { ...c, replies: [...c.replies, newReply] } + : c + )) + setReplyText('') + setReplyingToCommentId(null) + } catch (err) { + console.error('Failed to post reply:', err) + alert('Failed to post reply. Please try again.') + } finally { + setPostingReply(false) + } + } + + // Delete a comment + const handleDeleteComment = async (commentId: number) => { + if (!confirm('Are you sure you want to delete this comment? This will also hide all replies.')) return + + try { + setDeletingCommentId(commentId) + await commentService.deleteComment(commentId) + setComments(prev => prev.filter(c => c.id !== commentId)) + } catch (err) { + console.error('Failed to delete comment:', err) + alert('Failed to delete comment. Please try again.') + } finally { + setDeletingCommentId(null) + } + } + + // Delete a reply + const handleDeleteReply = async (replyId: number, commentId: number) => { + if (!confirm('Are you sure you want to delete this reply?')) return + + try { + setDeletingReplyId(replyId) + await commentService.deleteReply(replyId) + setComments(prev => prev.map(c => + c.id === commentId + ? { ...c, replies: c.replies.filter(r => r.id !== replyId) } + : c + )) + } catch (err) { + console.error('Failed to delete reply:', err) + alert('Failed to delete reply. Please try again.') + } finally { + setDeletingReplyId(null) + } + } + // Get validation status style const getValidationStatusStyle = (status: string): string => { switch (status) { @@ -461,17 +592,157 @@ export default function RequirementDetailPage() { return (

Shared Comments

-

No comments yet.

-
+ + {/* New Comment Form */} +