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;
}
}

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -15,9 +15,14 @@ export class CommentsService {
private readonly queryBus: QueryBus,
) {}
add(actorId: string, issueId: string, body: string): Promise<CommandResult> {
add(
actorId: string,
issueId: string,
body: string,
replyTo: string | null = null,
): Promise<CommandResult> {
return this.commandBus.execute(
new AddCommentCommand(actorId, issueId, body),
new AddCommentCommand(actorId, issueId, body, replyTo),
);
}

View File

@@ -200,7 +200,9 @@ export function describeEvent(
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
if (event instanceof CommentAddedEvent)
return `${actor} comentó en ${issueKey}`;
return event.comment.replyTo
? `${actor} respondió a un comentario en ${issueKey}`
: `${actor} comentó en ${issueKey}`;
if (event instanceof CommentEditedEvent)
return `${actor} editó un comentario en ${issueKey}`;
if (event instanceof CommentDeletedEvent)

View File

@@ -27,6 +27,8 @@ export class CommentAddedEventHandler
projectId: comment.projectId,
author: users.of(comment.authorId),
body: comment.body,
replyTo: comment.replyTo ?? null,
deleted: false,
createdAt: comment.createdAt,
editedAt: null,
version: versionOf(event),

View File

@@ -3,7 +3,7 @@ import { CommentDeletedEvent } from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier } from './read-model-notifier';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentDeletedEvent)
export class CommentDeletedEventHandler
@@ -17,12 +17,35 @@ export class CommentDeletedEventHandler
) {}
async handle(event: CommentDeletedEvent) {
await this.upsertMaterializedCommentRepository.remove(event.aggregateId);
const comment = await this.findComments.findById(event.aggregateId);
await this.vanishOrKeep(event.aggregateId, event);
// The last reply under a deleted comment takes it along.
const parent = comment?.replyTo
? await this.findComments.findById(comment.replyTo)
: null;
if (parent?.deleted) await this.vanishOrKeep(parent.id, event);
await this.upsertMaterializedIssueRepository.setCommentCount(
event.issueId,
await this.findComments.countByIssue(event.issueId),
);
await this.notifier.comment(event.aggregateId, event.issueId, event);
await this.notifier.issue(event.issueId, event);
}
/**
* A comment with replies stays, without its text, so the conversation
* under it still reads; one without them goes.
*/
private async vanishOrKeep(id: string, event: CommentDeletedEvent) {
if (await this.findComments.countReplies(id)) {
await this.upsertMaterializedCommentRepository.upsert({
id,
version: versionOf(event),
body: '',
deleted: true,
});
} else {
await this.upsertMaterializedCommentRepository.remove(id);
}
await this.notifier.comment(id, event.issueId, event);
}
}

View File

@@ -11,6 +11,7 @@ import {
TrackerEvent,
withoutMentionMarkup,
} from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { UserDirectory } from '../ports/user-directory';
@@ -43,6 +44,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly users: UserDirectory,
private readonly findComments: FindCommentsRepository,
) {}
async handle(event: TrackerEvent): Promise<void> {
@@ -154,6 +156,16 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
});
}
const answered = event.comment.replyTo
? await this.findComments.findById(event.comment.replyTo)
: null;
if (answered && !answered.deleted) {
drafts.push({
userId: answered.author.id,
kind: NotificationKind.REPLIED,
summary: `${actor} respondió a tu comentario en ${issue.key}: ${said}`,
});
}
for (const userId of [assigneeId, reporterId]) {
if (!userId) continue;
drafts.push({

View File

@@ -12,6 +12,7 @@ import {
} from '@task-stream/api/tracker/domain';
import { FakeUserDirectory } from '../../testing/in-memory';
import { ChangeFeed } from '../ports/change-feed';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { NotificationMailer } from '../ports/notification-mailer';
@@ -100,6 +101,13 @@ function setup(mailEnabled = false) {
}),
} as unknown as FindProjectsRepository,
users,
{
// A comment of the PM's, that others answer.
findById: async (id: string) =>
id === 'c-pm'
? { id, author: { id: 'pm', name: 'PM' }, deleted: false }
: null,
} as unknown as FindCommentsRepository,
);
return { repo, published, mailed, handler };
}
@@ -163,6 +171,7 @@ describe('notifications', () => {
projectId: 'p-1',
authorId: 'bob',
body: 'Ya está arreglado',
replyTo: null,
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
@@ -185,6 +194,7 @@ describe('notifications', () => {
projectId: 'p-1',
authorId: 'bob',
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
replyTo: null,
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
@@ -200,6 +210,32 @@ describe('notifications', () => {
expect(repo.all[0].summary).toContain('«@PM mira esto');
});
it('tells whoever wrote a comment that someone answered it', async () => {
const { handler, repo } = setup();
await handler.handle(
at(
new CommentAddedEvent('bob', {
id: 'c-3',
issueId: 'i-1',
projectId: 'p-1',
authorId: 'bob',
body: 'Lo miro',
replyTo: 'c-pm',
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,
}),
1,
),
);
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
['pm', NotificationKind.REPLIED],
['qa', NotificationKind.COMMENTED],
]);
expect(repo.all[0].summary).toContain('respondió a tu comentario');
});
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
const { handler, repo, mailed } = setup(true);
repo.emailOn.add('bob');

View File

@@ -8,5 +8,7 @@ export abstract class FindCommentsRepository {
limit: number,
offset: number,
): Promise<Page<CommentReadModel>>;
/** Comments still standing (deleted ones kept for their replies do not count). */
abstract countByIssue(issueId: string): Promise<number>;
abstract countReplies(commentId: string): Promise<number>;
}

View File

@@ -11,6 +11,7 @@ describe('CommentersFollowSaga', () => {
projectId: 'p-1',
authorId: 'ana',
body: 'Lo miro',
replyTo: null,
deleted: false,
createdAt: '2026-10-08T10:00:00.000Z',
editedAt: null,