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

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

View File

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

View File

@@ -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 {

View File

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

View File

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