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