From 96f6b540fea9f6a2857126b7b1e02ed68b7dfac1 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Mon, 2 Feb 2026 13:47:36 +0530 Subject: [PATCH] feat(comments): add reply threading + UI --- ...9d3d9b9c1a23_add_reply_to_task_comments.py | 32 +++++++++++++++ backend/app/api/work.py | 6 +++ backend/app/models/work.py | 4 ++ backend/app/schemas/work.py | 1 + frontend/src/app/projects/[id]/page.tsx | 41 +++++++++++++++++-- 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/9d3d9b9c1a23_add_reply_to_task_comments.py diff --git a/backend/alembic/versions/9d3d9b9c1a23_add_reply_to_task_comments.py b/backend/alembic/versions/9d3d9b9c1a23_add_reply_to_task_comments.py new file mode 100644 index 00000000..f673b954 --- /dev/null +++ b/backend/alembic/versions/9d3d9b9c1a23_add_reply_to_task_comments.py @@ -0,0 +1,32 @@ +"""add reply_to_comment_id to task_comments + +Revision ID: 9d3d9b9c1a23 +Revises: 157587037601 +Create Date: 2026-02-02 08:15:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "9d3d9b9c1a23" +down_revision = "157587037601" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("task_comments", sa.Column("reply_to_comment_id", sa.Integer(), nullable=True)) + op.create_foreign_key( + "fk_task_comments_reply_to_comment_id", + "task_comments", + "task_comments", + ["reply_to_comment_id"], + ["id"], + ) + + +def downgrade() -> None: + op.drop_constraint("fk_task_comments_reply_to_comment_id", "task_comments", type_="foreignkey") + op.drop_column("task_comments", "reply_to_comment_id") diff --git a/backend/app/api/work.py b/backend/app/api/work.py index b80fe007..302ab56d 100644 --- a/backend/app/api/work.py +++ b/backend/app/api/work.py @@ -89,6 +89,12 @@ def create_task_comment(payload: TaskCommentCreate, session: Session = Depends(g if payload.author_employee_id is None: payload = TaskCommentCreate(**{**payload.model_dump(), "author_employee_id": actor_employee_id}) c = TaskComment(**payload.model_dump()) + + # Validate reply target (must exist + belong to same task) + if c.reply_to_comment_id is not None: + parent = session.get(TaskComment, c.reply_to_comment_id) + if parent is None or parent.task_id != c.task_id: + raise HTTPException(status_code=400, detail="Invalid reply_to_comment_id") session.add(c) session.commit() session.refresh(c) diff --git a/backend/app/models/work.py b/backend/app/models/work.py index 545d5657..ce7f8314 100644 --- a/backend/app/models/work.py +++ b/backend/app/models/work.py @@ -30,5 +30,9 @@ class TaskComment(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) task_id: int = Field(foreign_key="tasks.id", index=True) author_employee_id: int | None = Field(default=None, foreign_key="employees.id") + + # Optional reply threading + reply_to_comment_id: int | None = Field(default=None, foreign_key="task_comments.id") + body: str created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/schemas/work.py b/backend/app/schemas/work.py index b483e5b6..9d067d75 100644 --- a/backend/app/schemas/work.py +++ b/backend/app/schemas/work.py @@ -24,4 +24,5 @@ class TaskUpdate(SQLModel): class TaskCommentCreate(SQLModel): task_id: int author_employee_id: int | None = None + reply_to_comment_id: int | None = None body: str diff --git a/frontend/src/app/projects/[id]/page.tsx b/frontend/src/app/projects/[id]/page.tsx index 3c733852..8ff0fe97 100644 --- a/frontend/src/app/projects/[id]/page.tsx +++ b/frontend/src/app/projects/[id]/page.tsx @@ -82,6 +82,7 @@ export default function ProjectDetailPage() { const [reviewerId, setReviewerId] = useState(""); const [commentTaskId, setCommentTaskId] = useState(null); + const [replyToCommentId, setReplyToCommentId] = useState(null); const [commentBody, setCommentBody] = useState(""); const comments = useListTaskCommentsTaskCommentsGet( @@ -94,6 +95,7 @@ export default function ProjectDetailPage() { onSuccess: () => { comments.refetch(); setCommentBody(""); + setReplyToCommentId(null); }, }, }); @@ -113,6 +115,11 @@ export default function ProjectDetailPage() { const projectMembers = memberList; + const commentById = new Map(); + for (const c of commentList) { + if (c.id != null) commentById.set(Number(c.id), c); + } + return (
{!Number.isFinite(projectId) ? ( @@ -258,7 +265,7 @@ export default function ProjectDetailPage() { ))}
- +
+
+ {commentById.get(replyToCommentId)?.body ?? "—"} +
+ + ) : null}