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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface CommentProps {
|
||||
projectId: string;
|
||||
authorId: string;
|
||||
body: string;
|
||||
/**
|
||||
* The comment this one answers; always a top-level one (one level of
|
||||
* replies). Null for a top-level comment, and on comments made before
|
||||
* there were replies.
|
||||
*/
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
@@ -22,12 +28,13 @@ export interface CommentProps {
|
||||
|
||||
const MAX_BODY = 20_000;
|
||||
|
||||
/** Decisions and explanations inside a ticket. */
|
||||
/** Decisions and explanations inside a ticket, and the replies to them. */
|
||||
export class Comment extends VersionedAggregateRoot implements CommentProps {
|
||||
public issueId!: string;
|
||||
public projectId!: string;
|
||||
public authorId!: string;
|
||||
public body!: string;
|
||||
public replyTo: string | null = null;
|
||||
public deleted = false;
|
||||
public createdAt!: string;
|
||||
public editedAt: string | null = null;
|
||||
|
||||
@@ -10,6 +10,8 @@ export class CommentFactory {
|
||||
projectId: string;
|
||||
authorId: string;
|
||||
body: string;
|
||||
/** Top-level comment it answers. */
|
||||
replyTo?: string | null;
|
||||
}): Comment {
|
||||
const data: CommentProps = {
|
||||
id: newId(),
|
||||
@@ -17,6 +19,7 @@ export class CommentFactory {
|
||||
projectId: props.projectId,
|
||||
authorId: props.authorId,
|
||||
body: validCommentBody(props.body),
|
||||
replyTo: props.replyTo ?? null,
|
||||
deleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
editedAt: null,
|
||||
|
||||
@@ -6,6 +6,13 @@ export class CommentReadModel {
|
||||
projectId!: string;
|
||||
author!: UserRefReadModel;
|
||||
body!: string;
|
||||
/** Top-level comment it answers; null for a top-level one. */
|
||||
replyTo!: string | null;
|
||||
/**
|
||||
* Deleted while it had replies: kept, without its text, so the
|
||||
* conversation under it still makes sense. Gone with its last reply.
|
||||
*/
|
||||
deleted!: boolean;
|
||||
createdAt!: string;
|
||||
editedAt!: string | null;
|
||||
version!: number;
|
||||
|
||||
@@ -12,6 +12,8 @@ export enum NotificationKind {
|
||||
COMMENTED = 'COMMENTED',
|
||||
/** Someone wrote @you. */
|
||||
MENTIONED = 'MENTIONED',
|
||||
/** Someone answered a comment of yours. */
|
||||
REPLIED = 'REPLIED',
|
||||
/** A ticket you follow changed. */
|
||||
WATCHED = 'WATCHED',
|
||||
/** You were added to a project. */
|
||||
|
||||
@@ -45,6 +45,13 @@ import {
|
||||
|
||||
/* Query-side adapters (MongoDB). */
|
||||
|
||||
/** Comments projected before there were replies have neither field. */
|
||||
const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({
|
||||
...fromDoc(doc),
|
||||
replyTo: doc.replyTo ?? null,
|
||||
deleted: doc.deleted ?? false,
|
||||
});
|
||||
|
||||
const escapeRegex = (text: string) =>
|
||||
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
@@ -446,7 +453,7 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
|
||||
|
||||
async findById(id: string): Promise<CommentReadModel | null> {
|
||||
const doc = await this.comments.findById(id).lean<CommentDoc>();
|
||||
return doc ? fromDoc(doc) : null;
|
||||
return doc ? commentFromDoc(doc) : null;
|
||||
}
|
||||
|
||||
async findByIssue(
|
||||
@@ -463,11 +470,15 @@ export class MongoFindCommentsRepository extends FindCommentsRepository {
|
||||
.lean<CommentDoc[]>(),
|
||||
this.comments.countDocuments({ issueId }),
|
||||
]);
|
||||
return { items: docs.map(fromDoc), total };
|
||||
return { items: docs.map(commentFromDoc), total };
|
||||
}
|
||||
|
||||
countByIssue(issueId: string): Promise<number> {
|
||||
return this.comments.countDocuments({ issueId });
|
||||
return this.comments.countDocuments({ issueId, deleted: { $ne: true } });
|
||||
}
|
||||
|
||||
countReplies(commentId: string): Promise<number> {
|
||||
return this.comments.countDocuments({ replyTo: commentId });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ export const CommentSchema = new Schema<CommentDoc>(
|
||||
{
|
||||
_id: String,
|
||||
issueId: { type: String, index: true },
|
||||
replyTo: { type: String, index: true },
|
||||
author: userRef,
|
||||
version: Number,
|
||||
},
|
||||
|
||||
@@ -8,13 +8,18 @@ import { CommandResultGql, CommentGql } from './types';
|
||||
export class CommentResolver {
|
||||
constructor(private readonly comments: CommentsService) {}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Comment on a ticket, or answer a comment (replyTo; one level of replies)',
|
||||
})
|
||||
addComment(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('body') body: string,
|
||||
@Args('replyTo', { type: () => ID, nullable: true })
|
||||
replyTo: string | null,
|
||||
) {
|
||||
return this.comments.add(me.id, issueId, body);
|
||||
return this.comments.add(me.id, issueId, body, replyTo ?? null);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
|
||||
@@ -445,7 +445,17 @@ export class CommentGql {
|
||||
@Field(() => ID) id!: string;
|
||||
@Field(() => ID) issueId!: string;
|
||||
@Field(() => UserRefGql) author!: UserRefGql;
|
||||
@Field() body!: string;
|
||||
@Field({ description: 'Empty on a deleted comment kept for its replies' })
|
||||
body!: string;
|
||||
@Field(() => ID, {
|
||||
nullable: true,
|
||||
description: 'The top-level comment it answers; null for a top-level one',
|
||||
})
|
||||
replyTo!: string | null;
|
||||
@Field({
|
||||
description: 'Deleted, but kept (without its text) while it has replies',
|
||||
})
|
||||
deleted!: boolean;
|
||||
@Field() createdAt!: string;
|
||||
@Field(() => String, { nullable: true }) editedAt!: string | null;
|
||||
@Field(() => Int) version!: number;
|
||||
|
||||
@@ -955,6 +955,17 @@ export class CommentBodyDto {
|
||||
body!: string;
|
||||
}
|
||||
|
||||
export class AddCommentDto extends CommentBodyDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Comment it answers (one level: answering a reply joins its thread)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
// ---- Releases ---------------------------------------------------------------
|
||||
|
||||
export class CreateReleaseDto {
|
||||
|
||||
@@ -466,7 +466,21 @@ export class CommentDto {
|
||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||
@ApiProperty({ format: 'uuid' }) issueId!: string;
|
||||
@ApiProperty({ type: UserRefDto }) author!: UserRefDto;
|
||||
@ApiProperty() body!: string;
|
||||
@ApiProperty({
|
||||
description: 'Empty on a deleted comment kept for its replies',
|
||||
})
|
||||
body!: string;
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
format: 'uuid',
|
||||
description: 'The top-level comment it answers; null for a top-level one',
|
||||
})
|
||||
replyTo!: string | null;
|
||||
@ApiProperty({
|
||||
description: 'Deleted, but kept (without its text) while it has replies',
|
||||
})
|
||||
deleted!: boolean;
|
||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||
editedAt!: string | null;
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
BulkUpdateIssuesDto,
|
||||
ChangeStatusDto,
|
||||
ChecklistItemBodyDto,
|
||||
CommentBodyDto,
|
||||
AddCommentDto,
|
||||
CreateIssueDto,
|
||||
IssueSearchQueryDto,
|
||||
LinkIssueDto,
|
||||
@@ -529,18 +529,21 @@ export class IssuesController {
|
||||
|
||||
@Post('issues/:issue/comments')
|
||||
@ISSUE
|
||||
@ApiOperation({ summary: 'Comment on the ticket' })
|
||||
@ApiOperation({
|
||||
summary: 'Comment on the ticket, or answer a comment (replyTo)',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async addComment(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: CommentBodyDto,
|
||||
@Body() body: AddCommentDto,
|
||||
) {
|
||||
return this.comments.add(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.body,
|
||||
body.replyTo ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user