Added comment functionality
This commit is contained in:
@@ -60,6 +60,8 @@ class User(Base):
|
|||||||
back_populates="members"
|
back_populates="members"
|
||||||
)
|
)
|
||||||
created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator")
|
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):
|
class Tag(Base):
|
||||||
@@ -225,6 +227,11 @@ class Requirement(Base):
|
|||||||
foreign_keys="RequirementLink.target_req_id",
|
foreign_keys="RequirementLink.target_req_id",
|
||||||
back_populates="target_requirement"
|
back_populates="target_requirement"
|
||||||
)
|
)
|
||||||
|
comments: Mapped[List["RequirementComment"]] = relationship(
|
||||||
|
"RequirementComment",
|
||||||
|
back_populates="requirement",
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
# Indexes
|
# Indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -386,3 +393,92 @@ class RequirementLink(Base):
|
|||||||
Index("idx_link_target", "target_req_id"),
|
Index("idx_link_target", "target_req_id"),
|
||||||
UniqueConstraint("source_req_id", "target_req_id", "relationship_type_id", name="uq_req_link_pair"),
|
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"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from src.models import (
|
|||||||
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
||||||
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
|
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
|
||||||
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult,
|
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.controller import AuthController
|
||||||
from src.config import get_openid, get_settings
|
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 (
|
from src.repositories import (
|
||||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||||
RelationshipTypeRepository, RequirementLinkRepository
|
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository
|
||||||
)
|
)
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -1472,3 +1473,278 @@ async def delete_requirement_link(
|
|||||||
|
|
||||||
await link_repo.delete(link_id)
|
await link_repo.delete(link_id)
|
||||||
await db.commit()
|
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()
|
||||||
|
|||||||
@@ -309,3 +309,45 @@ class RequirementSearchResult(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from src.repositories.validation_status_repository import ValidationStatusReposi
|
|||||||
from src.repositories.validation_repository import ValidationRepository
|
from src.repositories.validation_repository import ValidationRepository
|
||||||
from src.repositories.relationship_type_repository import RelationshipTypeRepository
|
from src.repositories.relationship_type_repository import RelationshipTypeRepository
|
||||||
from src.repositories.requirement_link_repository import RequirementLinkRepository
|
from src.repositories.requirement_link_repository import RequirementLinkRepository
|
||||||
|
from src.repositories.comment_repository import CommentRepository, ReplyRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"UserRepository",
|
"UserRepository",
|
||||||
@@ -24,4 +25,6 @@ __all__ = [
|
|||||||
"ValidationRepository",
|
"ValidationRepository",
|
||||||
"RelationshipTypeRepository",
|
"RelationshipTypeRepository",
|
||||||
"RequirementLinkRepository",
|
"RequirementLinkRepository",
|
||||||
|
"CommentRepository",
|
||||||
|
"ReplyRepository",
|
||||||
]
|
]
|
||||||
|
|||||||
184
backend/src/repositories/comment_repository.py
Normal file
184
backend/src/repositories/comment_repository.py
Normal file
@@ -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
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth, useProject } from '@/hooks'
|
import { useAuth, useProject } from '@/hooks'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
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 { Requirement } from '@/services/requirementService'
|
||||||
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
||||||
import type { ValidationStatus, ValidationHistory } from '@/types'
|
import type { ValidationStatus, ValidationHistory, Comment } from '@/types'
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
||||||
@@ -41,6 +41,17 @@ export default function RequirementDetailPage() {
|
|||||||
const [addLinkError, setAddLinkError] = useState<string | null>(null)
|
const [addLinkError, setAddLinkError] = useState<string | null>(null)
|
||||||
const [deletingLinkId, setDeletingLinkId] = useState<number | null>(null)
|
const [deletingLinkId, setDeletingLinkId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Comments state
|
||||||
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
|
const [commentsLoading, setCommentsLoading] = useState(false)
|
||||||
|
const [newCommentText, setNewCommentText] = useState('')
|
||||||
|
const [postingComment, setPostingComment] = useState(false)
|
||||||
|
const [replyingToCommentId, setReplyingToCommentId] = useState<number | null>(null)
|
||||||
|
const [replyText, setReplyText] = useState('')
|
||||||
|
const [postingReply, setPostingReply] = useState(false)
|
||||||
|
const [deletingCommentId, setDeletingCommentId] = useState<number | null>(null)
|
||||||
|
const [deletingReplyId, setDeletingReplyId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Fetch requirement data on mount
|
// Fetch requirement data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRequirement = async () => {
|
const fetchRequirement = async () => {
|
||||||
@@ -112,6 +123,25 @@ export default function RequirementDetailPage() {
|
|||||||
fetchRelationshipsData()
|
fetchRelationshipsData()
|
||||||
}, [activeTab, id, currentProject])
|
}, [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
|
// Debounced search for target requirements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showAddRelationshipModal || !currentProject || !id) return
|
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
|
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<string, string> = {
|
||||||
|
'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
|
// Get validation status style
|
||||||
const getValidationStatusStyle = (status: string): string => {
|
const getValidationStatusStyle = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -461,17 +592,157 @@ export default function RequirementDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Shared Comments</h3>
|
<h3 className="text-xl font-bold text-gray-800 mb-4">Shared Comments</h3>
|
||||||
<p className="text-gray-500">No comments yet.</p>
|
|
||||||
<div className="mt-4">
|
{/* New Comment Form */}
|
||||||
|
<div className="mb-6 p-4 border border-gray-200 rounded bg-gray-50">
|
||||||
<textarea
|
<textarea
|
||||||
|
value={newCommentText}
|
||||||
|
onChange={(e) => setNewCommentText(e.target.value)}
|
||||||
placeholder="Add a comment..."
|
placeholder="Add a comment..."
|
||||||
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 mb-2"
|
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
disabled={postingComment}
|
||||||
/>
|
/>
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
<div className="mt-2 flex justify-end">
|
||||||
Post Comment
|
<button
|
||||||
</button>
|
onClick={handlePostComment}
|
||||||
|
disabled={postingComment || !newCommentText.trim()}
|
||||||
|
className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{postingComment ? 'Posting...' : 'Post Comment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
{commentsLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
|
||||||
|
<p className="mt-2 text-gray-500 text-sm">Loading comments...</p>
|
||||||
|
</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">No comments yet. Be the first to comment!</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="border border-gray-200 rounded">
|
||||||
|
{/* Main Comment */}
|
||||||
|
<div className="p-4 bg-white">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Author Info */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-800">
|
||||||
|
{getAuthorDisplayName(comment.author_full_name, comment.author_username)}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
||||||
|
{getRoleDisplayName(comment.author_role)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{comment.created_at
|
||||||
|
? new Date(comment.created_at).toLocaleString()
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment Text */}
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap">{comment.comment_text}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
{canDelete(comment.author_id) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteComment(comment.id)}
|
||||||
|
disabled={deletingCommentId === comment.id}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 disabled:opacity-50"
|
||||||
|
title="Delete comment"
|
||||||
|
>
|
||||||
|
{deletingCommentId === comment.id ? '⏳' : '🗑️'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReplyingToCommentId(replyingToCommentId === comment.id ? null : comment.id)
|
||||||
|
setReplyText('')
|
||||||
|
}}
|
||||||
|
className="mt-2 text-xs text-teal-600 hover:text-teal-700 font-medium"
|
||||||
|
>
|
||||||
|
{replyingToCommentId === comment.id ? 'Cancel Reply' : 'Reply'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Reply Form */}
|
||||||
|
{replyingToCommentId === comment.id && (
|
||||||
|
<div className="mt-3 pl-4 border-l-2 border-teal-200">
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
className="w-full p-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
|
||||||
|
rows={2}
|
||||||
|
disabled={postingReply}
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePostReply(comment.id)}
|
||||||
|
disabled={postingReply || !replyText.trim()}
|
||||||
|
className="px-3 py-1 bg-teal-600 text-white rounded text-xs font-medium hover:bg-teal-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{postingReply ? 'Posting...' : 'Post Reply'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
|
{comment.replies.length > 0 && (
|
||||||
|
<div className="border-t border-gray-200 bg-gray-50">
|
||||||
|
{comment.replies.map((reply) => (
|
||||||
|
<div key={reply.id} className="p-4 pl-8 border-b border-gray-100 last:border-b-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Reply Author Info */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-semibold text-gray-700 text-sm">
|
||||||
|
{getAuthorDisplayName(reply.author_full_name, reply.author_username)}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">
|
||||||
|
{getRoleDisplayName(reply.author_role)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{reply.created_at
|
||||||
|
? new Date(reply.created_at).toLocaleString()
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply Text */}
|
||||||
|
<p className="text-gray-600 text-sm whitespace-pre-wrap">{reply.reply_text}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reply Delete Button */}
|
||||||
|
{canDelete(reply.author_id) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteReply(reply.id, comment.id)}
|
||||||
|
disabled={deletingReplyId === reply.id}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 disabled:opacity-50"
|
||||||
|
title="Delete reply"
|
||||||
|
>
|
||||||
|
{deletingReplyId === reply.id ? '⏳' : '🗑️'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
128
frontend/src/services/commentService.ts
Normal file
128
frontend/src/services/commentService.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import type { Comment, CommentReply, CommentCreateRequest, ReplyCreateRequest } from '@/types'
|
||||||
|
|
||||||
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
|
class CommentService {
|
||||||
|
/**
|
||||||
|
* Get all comments for a requirement with their replies.
|
||||||
|
*/
|
||||||
|
async getComments(requirementId: number): Promise<Comment[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/comments`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments: Comment[] = await response.json()
|
||||||
|
return comments
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch comments:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new comment on a requirement.
|
||||||
|
*/
|
||||||
|
async createComment(requirementId: number, data: CommentCreateRequest): Promise<Comment> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment: Comment = await response.json()
|
||||||
|
return comment
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create comment:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a comment (soft delete).
|
||||||
|
*/
|
||||||
|
async deleteComment(commentId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/comments/${commentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete comment:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a reply to a comment.
|
||||||
|
*/
|
||||||
|
async createReply(commentId: number, data: ReplyCreateRequest): Promise<CommentReply> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/comments/${commentId}/replies`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply: CommentReply = await response.json()
|
||||||
|
return reply
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create reply:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a reply (soft delete).
|
||||||
|
*/
|
||||||
|
async deleteReply(replyId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/replies/${replyId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete reply:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commentService = new CommentService()
|
||||||
@@ -21,3 +21,4 @@ export type {
|
|||||||
} from './relationshipService'
|
} from './relationshipService'
|
||||||
export { userService } from './userService'
|
export { userService } from './userService'
|
||||||
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
||||||
|
export { commentService } from './commentService'
|
||||||
|
|||||||
@@ -21,3 +21,33 @@ export interface ValidationCreateRequest {
|
|||||||
status_id: number
|
status_id: number
|
||||||
comment?: string
|
comment?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comment types
|
||||||
|
export interface CommentReply {
|
||||||
|
id: number
|
||||||
|
reply_text: string
|
||||||
|
created_at: string | null
|
||||||
|
author_id: number | null
|
||||||
|
author_username: string | null
|
||||||
|
author_full_name: string | null
|
||||||
|
author_role: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: number
|
||||||
|
comment_text: string
|
||||||
|
created_at: string | null
|
||||||
|
author_id: number | null
|
||||||
|
author_username: string | null
|
||||||
|
author_full_name: string | null
|
||||||
|
author_role: string | null
|
||||||
|
replies: CommentReply[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentCreateRequest {
|
||||||
|
comment_text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyCreateRequest {
|
||||||
|
reply_text: string
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user