Comments
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 — 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:
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:
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: noassetsM2M, 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.
{
"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): “this task is about these files” versus “this remark came with these files.” Neither cascades into the other.
{
"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:
{ "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.
{
"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 |
