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

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

View File

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

View File

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

View File

@@ -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. */