Add attachments to comments

- 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>
This commit is contained in:
vadimandClaude Opus 5.5 committed 2026-10-02 09:18:08 +03:00
1 parent 423afa658d
commit 60fb39f98f
28 files changed
+522 -69

No files matched your search

@@ -7,6 +7,8 @@ interface CommentProps {
issueId: number;
/** null — автор удалил аккаунт */
authorId: string | null;
/** Прикреплённые файлы (file-модуль), по времени загрузки */
fileIds: string[];
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
@@ -17,14 +19,19 @@ interface CommentProps {
* Комментарий к задаче. Удаление мягкое: запись остаётся (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 }): Comment {
static create(params: { issueId: number; authorId: string; content: string; fileIds?: string[] }): Comment {
const now = new Date();
return new Comment({
@@ -32,6 +39,7 @@ export class Comment {
content: CommentContent.create(params.content),
issueId: params.issueId,
authorId: params.authorId,
fileIds: Comment.normalizeFileIds(params.fileIds ?? []),
editedAt: null,
deletedAt: null,
createdAt: now,
@@ -45,6 +53,7 @@ export class Comment {
content: string;
issueId: number;
authorId: string | null;
fileIds?: string[];
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
@@ -53,6 +62,7 @@ export class Comment {
return new Comment({
...props,
content: CommentContent.create(props.content),
fileIds: [...(props.fileIds ?? [])],
});
}
@@ -72,6 +82,10 @@ export class Comment {
return this.props.authorId;
}
get fileIds(): readonly string[] {
return this.props.fileIds;
}
get editedAt(): Date | null {
return this.props.editedAt;
}
@@ -92,17 +106,27 @@ export class Comment {
return this.props.updatedAt;
}
/** Меняет текст. Возвращает false, если текст не изменился */
edit(content: string, userId: string): boolean {
/**
* Меняет текст и, если передан fileIds, — набор вложений (целиком).
* Возвращает false, если ничего не изменилось.
*/
edit(content: string, userId: string, fileIds?: string[]): boolean {
this.assertCanModify(userId);
const next = CommentContent.create(content);
if (next.equals(this.props.content)) {
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 = next;
this.props.content = nextContent;
this.props.fileIds = nextFileIds;
this.props.editedAt = now;
this.props.updatedAt = now;
return true;
@@ -112,10 +136,24 @@ export class Comment {
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) {