import { CommentContent } from '../value-objects/comment-content.vo.js'; import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js'; interface CommentProps { id: string | null; content: CommentContent; issueId: number; /** null — автор удалил аккаунт */ authorId: string | null; /** Прикреплённые файлы (file-модуль), по времени загрузки */ fileIds: string[]; editedAt: Date | null; deletedAt: Date | null; createdAt: Date; updatedAt: Date; } /** * Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt), * чтобы в ленте можно было показать «комментарий удалён». * * Вложения — часть комментария: задаются при создании и правке, * снимаются при удалении (тогда осиротевшие файлы удалит GC file-модуля). * * `id` (uuid v7) генерируется базой, поэтому у нового комментария * он равен null до сохранения в репозитории. */ export class Comment { static readonly MAX_ATTACHMENTS = 10; private constructor(private readonly props: CommentProps) {} /** Фабричный метод для создания НОВОГО комментария */ static create(params: { issueId: number; authorId: string; content: string; fileIds?: string[] }): Comment { const now = new Date(); return new Comment({ id: null, content: CommentContent.create(params.content), issueId: params.issueId, authorId: params.authorId, fileIds: Comment.normalizeFileIds(params.fileIds ?? []), editedAt: null, deletedAt: null, createdAt: now, updatedAt: now, }); } /** Восстановление сущности из персистентного слоя */ static reconstitute(props: { id: string; content: string; issueId: number; authorId: string | null; fileIds?: string[]; editedAt: Date | null; deletedAt: Date | null; createdAt: Date; updatedAt: Date; }): Comment { return new Comment({ ...props, content: CommentContent.create(props.content), fileIds: [...(props.fileIds ?? [])], }); } get id(): string | null { return this.props.id; } get content(): string { return this.props.content.value; } get issueId(): number { return this.props.issueId; } get authorId(): string | null { return this.props.authorId; } get fileIds(): readonly string[] { return this.props.fileIds; } get editedAt(): Date | null { return this.props.editedAt; } get deletedAt(): Date | null { return this.props.deletedAt; } get isDeleted(): boolean { return this.props.deletedAt !== null; } get createdAt(): Date { return this.props.createdAt; } get updatedAt(): Date { return this.props.updatedAt; } /** * Меняет текст и, если передан fileIds, — набор вложений (целиком). * Возвращает false, если ничего не изменилось. */ edit(content: string, userId: string, fileIds?: string[]): boolean { this.assertCanModify(userId); const nextContent = CommentContent.create(content); const nextFileIds = fileIds === undefined ? this.props.fileIds : Comment.normalizeFileIds(fileIds); const contentChanged = !nextContent.equals(this.props.content); const filesChanged = nextFileIds.length !== this.props.fileIds.length || nextFileIds.some((id, i) => id !== this.props.fileIds[i]); if (!contentChanged && !filesChanged) { return false; } const now = new Date(); this.props.content = nextContent; this.props.fileIds = nextFileIds; this.props.editedAt = now; this.props.updatedAt = now; return true; } delete(userId: string): void { this.assertCanModify(userId); const now = new Date(); this.props.fileIds = []; this.props.deletedAt = now; this.props.updatedAt = now; } /** * Без дублей и в порядке загрузки: id файлов — uuid v7, их строковый порядок * совпадает с временем создания. Так порядок одинаков в ответе на create * и при чтении из БД, а сравнение наборов в edit не зависит от порядка у клиента. */ private static normalizeFileIds(fileIds: string[]): string[] { const unique = [...new Set(fileIds)].sort(); if (unique.length > Comment.MAX_ATTACHMENTS) { throw new Error(`К комментарию можно прикрепить не больше ${Comment.MAX_ATTACHMENTS} файлов`); } return unique; } /** Править и удалять комментарий может только его автор, и только пока он не удалён */ private assertCanModify(userId: string): void { if (this.isDeleted) { throw new Error('Комментарий уже удалён'); } if (this.props.authorId !== userId) { throw new CommentForbiddenException(this.props.id ?? 'new'); } } }