- CommentAttachment link table (comment <-> file) + migration - fileIds in create/update comment; update replaces the whole set, synced in one transaction with the comment - CommentDto.attachments; empty for deleted comments - Soft delete drops attachments so GC can collect the files - Max 10 files per comment, ordered by upload time (uuid v7) - Registers comments as a file owner - Rename CommentAuthorExpanderService -> CommentExpanderService (expands authors and files) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
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');
|
|
}
|
|
}
|
|
}
|