Files
task-stream/libs/api/tracker/domain/src/lib/comment/comment.ts
Josep Piguillem ea4ef82239 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>
2026-10-09 11:14:37 +02:00

107 lines
2.8 KiB
TypeScript

import {
BusinessRuleError,
ForbiddenError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDeletedEvent } from '../events/comment-deleted.event';
import { CommentEditedEvent } from '../events/comment-edited.event';
/** Data of a comment (what CommentAddedEvent carries). */
export interface CommentProps {
id: string;
issueId: string;
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;
}
const MAX_BODY = 20_000;
/** 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;
constructor(id: string) {
super();
this.id = id;
}
edit(actorId: string, body: string): void {
this.assertAlive();
if (actorId !== this.authorId) {
throw new ForbiddenError('Only the author can edit a comment');
}
const next = validCommentBody(body);
if (next === this.body) return;
this.apply(
new CommentEditedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
next,
),
);
}
/** @param canModerate whether the actor manages the project (may delete others' comments). */
delete(actorId: string, canModerate: boolean): void {
this.assertAlive();
if (actorId !== this.authorId && !canModerate) {
throw new ForbiddenError(
'Only the author or a project admin can delete a comment',
);
}
this.apply(
new CommentDeletedEvent(actorId, this.id, this.projectId, this.issueId),
);
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onCommentAddedEvent({ comment }: CommentAddedEvent): void {
Object.assign(this, comment);
}
onCommentEditedEvent(event: CommentEditedEvent): void {
this.body = event.body;
this.editedAt = event.occurredAt;
}
onCommentDeletedEvent(): void {
this.deleted = true;
}
private assertAlive(): void {
if (this.deleted) throw new BusinessRuleError('Comment was deleted');
}
}
export function validCommentBody(body: string): string {
const trimmed = body.trim();
assertThat(trimmed.length > 0, 'Comment cannot be empty');
assertThat(
trimmed.length <= MAX_BODY,
`Comment cannot exceed ${MAX_BODY} characters`,
);
return trimmed;
}