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>
107 lines
2.8 KiB
TypeScript
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;
|
|
}
|