feat: replies to comments

A comment can answer another one (replyTo), one level deep: answering a
reply joins the same thread, and it must be on the same ticket. Whoever
wrote the comment gets a new REPLIED notification, and the activity says
"respondió a un comentario".

A comment deleted while it has replies stays, without its text
(deleted: true), so the thread still reads; it goes with its last reply
and does not count in commentCount.

The ticket page shows threads with their replies indented and a
"Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take
and show replyTo. Existing comments are unchanged. Docs, Word and PDF
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:14:37 +02:00
parent c2928d2385
commit ea4ef82239
44 changed files with 517 additions and 60 deletions

View File

@@ -1,4 +1,5 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { CommentFactory } from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './add-comment.command';
import { CommandResult, ack } from './command-result';
@@ -24,10 +25,24 @@ export class AddCommentCommandHandler
projectId: issue.projectId,
authorId: command.actorId,
body: command.body,
replyTo: command.replyTo
? await this.threadOf(command.replyTo, issue.id)
: null,
});
this.eventPublisher.mergeObjectContext(comment);
await comment.commitAndWait();
return ack(comment);
}
/**
* The top-level comment a reply hangs from: one level only, so answering
* a reply joins its thread.
*/
private async threadOf(commentId: string, issueId: string): Promise<string> {
const parent = await this.access.comment(commentId);
if (parent.issueId !== issueId)
throw new BusinessRuleError('A reply must be on the same ticket');
return parent.replyTo ?? parent.id;
}
}