---
title: Comments
description: Project and task comment threads — what they share, what only a task comment carries, and the author-or-manager moderation rule both enforce.
---

Two flat comment thread types, one per work item: `ProjectCommentModule` at `/api/projects/:projectId/comments` and `TaskCommentModule` at `/api/tasks/:taskId/comments`. Both carry the same guard stack as [Workspace](/api/workspace) — `AuthGuard` + `OrganizationGuard`, optional `x-organization-id` header, 403 `You do not belong to an active organization` with no active membership.

## Routes

| Method | Path | Summary |
| --- | --- | --- |
| `GET` | `/api/projects/:projectId/comments` | List a project's comment thread, oldest first |
| `POST` | `/api/projects/:projectId/comments` | Post a comment on a project |
| `PATCH` | `/api/projects/:projectId/comments/:commentId` | Edit a comment |
| `DELETE` | `/api/projects/:projectId/comments/:commentId` | Soft-delete a comment |
| `GET` | `/api/tasks/:taskId/comments` | List a task's comment thread, oldest first, with attachments |
| `POST` | `/api/tasks/:taskId/comments` | Post a comment on a task |
| `PATCH` | `/api/tasks/:taskId/comments/:commentId` | Edit a comment |
| `DELETE` | `/api/tasks/:taskId/comments/:commentId` | Soft-delete a comment |

## What both threads share

**Body validation.** `body` is required, 1–5000 characters, and trimmed **before** the length check runs:

```ts title="backend/src/project-comment/dto/project-comment.dto.ts"
const trim = ({ value }: { value: unknown }) =>
  typeof value === 'string' ? value.trim() : value;

export class CreateProjectCommentDto {
  @Transform(trim)
  @IsString()
  @Length(1, 5000)
  body: string;
}
```

Trimming in the DTO rather than in the service is deliberate: validating first and trimming afterward would accept a body of only whitespace and persist an empty string, and an empty comment row is indistinguishable from a bug.

**Ordering.** Both list endpoints sort `createdAt ASC, id ASC` — the `id` tiebreaker matters because two comments posted in the same millisecond must not swap places between requests.

**No existence disclosure.** A `projectId`/`taskId` that does not resolve to this organization, or a `commentId` that does not belong to that thread, is a **404**, not a 403 — the same rule the rest of the API follows so a probe cannot distinguish "not yours" from "does not exist".

**Moderation.** Editing or deleting is restricted to the comment's author, or an organization `OWNER`/`ADMIN` acting on anyone's comment:

```ts title="backend/src/task-comment/task-comment.service.ts"
private assertCanModerate(comment: TaskComment, userId: string, role: OrgRole, verb: 'edit' | 'delete') {
  if (comment.author?.id !== userId && role === OrgRole.MEMBER)
    throw new ForbiddenException(
      `Only the comment author or an organization manager can ${verb} this comment`,
    );
}
```

Both services implement this identically rather than sharing a base class — the same rule, restated once per module.

## Project comments — what they deliberately don't have

`ProjectComment` carries no `assets` relation, no replies, and no reactions:

> Deliberately narrower than `TaskComment`: no `assets` M2M, because the Workspace comment composer has no asset picker and inventing one would mean shipping a control with no design. Replies and reactions are likewise absent — the page renders both, and both are disabled in place rather than faked.

```json title="GET /api/projects/PRa7Bk9x2Q/comments"
{
  "message": "Comments retrieved successfully",
  "data": [
    {
      "id": "PCa7Bk9x2Q",
      "body": "Pushed the new palette to the shared library.",
      "author": { "id": "USa1B2c3D4", "fullName": "Amoako Owusu", "imageUrl": null },
      "createdAt": "2026-09-01T09:00:00.000Z",
      "updatedAt": "2026-09-01T09:00:00.000Z"
    }
  ]
}
```

`CreateProjectCommentDto`/`UpdateProjectCommentDto` both take `body` only — there is nothing else to send.

## Task comments — attachments

`TaskComment` carries an `assets` many-to-many via `task_comment_assets`, a **different grain** from a task's own asset links (`task_assets` — see [Workspace](/api/workspace#task-asset-attachment)): "this task is about these files" versus "this remark came with these files." Neither cascades into the other.

```json title="POST /api/tasks/TAa7Bk9x2Q/comments"
{
  "body": "Attached the latest export for review.",
  "assetIds": ["ASa7Bk9x2Q"]
}
```

`assetIds` is optional on create (defaults to none) and, like task-asset attachment, is validated against **organization-owned assets only**:

```json title="403 — an attached asset is not this organization's"
{ "statusCode": 403, "message": "Comment assets must belong to the task organization" }
```

On `PATCH`, `assetIds` follows the same replace-or-leave contract as `PUT /api/tasks/:taskId/assets`: **omitting** the key leaves the existing attachments alone; sending `[]` detaches all of them. Without this, a typo'd attachment at create time could only be fixed by deleting the whole comment — `UpdateTaskCommentDto` exists specifically to carry `assetIds` too.

```json title="GET /api/tasks/TAa7Bk9x2Q/comments"
{
  "message": "Comments retrieved successfully",
  "data": [
    {
      "id": "TCa7Bk9x2Q",
      "body": "Attached the latest export for review.",
      "author": { "id": "USa1B2c3D4", "fullName": "Amoako Owusu", "imageUrl": null },
      "assets": [
        {
          "id": "ASa7Bk9x2Q",
          "assetName": "wordmark-final.png",
          "assetType": "image/png",
          "currentVersion": 2,
          "sizeBytes": 48213,
          "isFavorite": false,
          "createdBy": { "id": "USa1B2c3D4", "fullName": "Amoako Owusu", "email": "amoako@example.com" },
          "collectionId": "CLa7Bk9x2Q",
          "projectId": "PRa7Bk9x2Q",
          "createdAt": "2026-08-30T12:00:00.000Z",
          "updatedAt": "2026-09-01T08:55:00.000Z"
        }
      ],
      "createdAt": "2026-09-01T09:00:00.000Z",
      "updatedAt": "2026-09-01T09:00:00.000Z"
    }
  ]
}
```

## Errors

| Situation | Status | Message |
| --- | --- | --- |
| Project/task in another organization, or unknown id | 404 | `Project not found` / `Task not found` |
| Comment does not belong to the given thread, or unknown id | 404 | `Comment not found` |
| Edit/delete by someone who is neither the author nor `OWNER`/`ADMIN` | 403 | `Only the comment author or an organization manager can edit this comment` / `...delete this comment` |
| A task-comment attachment does not belong to the task's organization | 403 | `Comment assets must belong to the task organization` |
| No active membership in the resolved organization | 403 | `You do not belong to an active organization` |
