import uuid from typing import List from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from app.core.database import get_db from app.core.rate_limiter import limiter from app.core.config import settings from app.models import User, Task, Comment from app.schemas.comment import ( CommentCreate, CommentUpdate, CommentResponse, CommentListResponse, CommentAuthor, MentionedUser ) from app.middleware.auth import get_current_user, check_task_access from app.services.notification_service import NotificationService router = APIRouter(tags=["comments"]) def comment_to_response(comment: Comment) -> CommentResponse: """Convert Comment model to CommentResponse.""" mentioned_users = [ MentionedUser( id=m.mentioned_user.id, name=m.mentioned_user.name, email=m.mentioned_user.email, ) for m in comment.mentions if m.mentioned_user ] reply_count = len([r for r in comment.replies if not r.is_deleted]) if comment.replies else 0 return CommentResponse( id=comment.id, task_id=comment.task_id, parent_comment_id=comment.parent_comment_id, content=comment.content if not comment.is_deleted else "[This comment has been deleted]", is_edited=comment.is_edited, is_deleted=comment.is_deleted, created_at=comment.created_at, updated_at=comment.updated_at, author=CommentAuthor( id=comment.author.id, name=comment.author.name, email=comment.author.email, ), mentions=mentioned_users, reply_count=reply_count, ) @router.post("/api/tasks/{task_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) @limiter.limit(settings.RATE_LIMIT_STANDARD) async def create_comment( request: Request, task_id: str, comment_data: CommentCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Create a new comment on a task. Rate limited: 60 requests per minute (standard tier). """ task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found", ) if not check_task_access(current_user, task, task.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) # Validate parent comment if provided parent_author_id = None if comment_data.parent_comment_id: parent_comment = db.query(Comment).filter( Comment.id == comment_data.parent_comment_id, Comment.task_id == task_id, ).first() if not parent_comment: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Parent comment not found", ) parent_author_id = parent_comment.author_id # Check @mention limit mention_count = NotificationService.count_mentions(comment_data.content) if mention_count > NotificationService.MAX_MENTIONS_PER_COMMENT: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Maximum {NotificationService.MAX_MENTIONS_PER_COMMENT} mentions allowed per comment", ) # Create comment comment = Comment( id=str(uuid.uuid4()), task_id=task_id, parent_comment_id=comment_data.parent_comment_id, author_id=current_user.id, content=comment_data.content, ) db.add(comment) db.flush() # Process mentions and create notifications (auto-publishes after commit) NotificationService.process_mentions(db, comment, task, current_user) # Notify parent comment author if this is a reply (auto-publishes after commit) if parent_author_id: NotificationService.notify_comment_reply(db, comment, task, current_user, parent_author_id) db.commit() db.refresh(comment) return comment_to_response(comment) @router.get("/api/tasks/{task_id}/comments", response_model=CommentListResponse) async def list_comments( task_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List all comments on a task.""" task = db.query(Task).filter(Task.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found", ) if not check_task_access(current_user, task, task.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) # Get root comments (no parent) ordered by creation time comments = db.query(Comment).filter( Comment.task_id == task_id, Comment.parent_comment_id == None, ).order_by(Comment.created_at).all() return CommentListResponse( comments=[comment_to_response(c) for c in comments], total=len(comments), ) @router.get("/api/comments/{comment_id}/replies", response_model=CommentListResponse) async def list_replies( comment_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """List replies to a comment.""" comment = db.query(Comment).filter(Comment.id == comment_id).first() if not comment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found", ) task = comment.task if not check_task_access(current_user, task, task.project): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) replies = db.query(Comment).filter( Comment.parent_comment_id == comment_id, ).order_by(Comment.created_at).all() return CommentListResponse( comments=[comment_to_response(r) for r in replies], total=len(replies), ) @router.put("/api/comments/{comment_id}", response_model=CommentResponse) async def update_comment( comment_id: str, comment_data: CommentUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Update a comment (only by author).""" comment = db.query(Comment).filter(Comment.id == comment_id).first() if not comment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found", ) if comment.is_deleted: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot edit a deleted comment", ) if comment.author_id != current_user.id and not current_user.is_system_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the author can edit this comment", ) # Check @mention limit in updated content mention_count = NotificationService.count_mentions(comment_data.content) if mention_count > NotificationService.MAX_MENTIONS_PER_COMMENT: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Maximum {NotificationService.MAX_MENTIONS_PER_COMMENT} mentions allowed per comment", ) comment.content = comment_data.content comment.is_edited = True db.commit() db.refresh(comment) return comment_to_response(comment) @router.delete("/api/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_comment( comment_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Delete a comment (soft delete, only by author or admin).""" comment = db.query(Comment).filter(Comment.id == comment_id).first() if not comment: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found", ) if comment.author_id != current_user.id and not current_user.is_system_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only the author can delete this comment", ) # Soft delete - mark as deleted but keep for reply chain comment.is_deleted = True comment.content = "" db.commit() return None