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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ export class AddCommentCommand extends Command<CommandResult> {
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly body: string,
|
||||
/** Comment it answers; a reply to a reply joins the same thread. */
|
||||
public readonly replyTo: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -832,6 +832,38 @@ describe('tracker commands (event sourced)', () => {
|
||||
expect(rebuilt.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('answers comments one level deep, on the same ticket', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const spike = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Spike', {}),
|
||||
);
|
||||
const other = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Other', {}),
|
||||
);
|
||||
const root = await run(new AddCommentCommand('dev', spike.id, 'Redis?'));
|
||||
const reply = await run(
|
||||
new AddCommentCommand('qa', spike.id, 'Sí', root.id),
|
||||
);
|
||||
// Answering a reply joins the same thread.
|
||||
const again = await run(
|
||||
new AddCommentCommand('dev', spike.id, 'Vale', reply.id),
|
||||
);
|
||||
const replyTo = async (id: string) =>
|
||||
(await rehydrator.rehydrate(id, Comment)).replyTo;
|
||||
expect(await replyTo(root.id)).toBeNull();
|
||||
expect(await replyTo(reply.id)).toBe(root.id);
|
||||
expect(await replyTo(again.id)).toBe(root.id);
|
||||
|
||||
await expect(
|
||||
run(new AddCommentCommand('dev', other.id, 'Aquí no', root.id)),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
await run(new DeleteCommentCommand('qa', reply.id));
|
||||
await expect(
|
||||
run(new AddCommentCommand('dev', spike.id, 'Tarde', reply.id)),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('detects concurrent modifications through stream positions', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
Reference in New Issue
Block a user