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:
1 parent
423afa658d
commit
60fb39f98f
28 files changed
+522
-69
No files matched your search
@@ -73,4 +73,50 @@ describe('Comment', () => {
|
||||
expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachments', () => {
|
||||
it('creates a comment with deduplicated files', () => {
|
||||
const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds: ['a', 'b', 'a'] });
|
||||
|
||||
expect(comment.fileIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('limits the number of files', () => {
|
||||
const fileIds = Array.from({ length: Comment.MAX_ATTACHMENTS + 1 }, (_, i) => `f${i}`);
|
||||
|
||||
expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds })).toThrow();
|
||||
});
|
||||
|
||||
it('treats a changed file set as an edit, even with the same text', () => {
|
||||
const comment = buildComment();
|
||||
|
||||
expect(comment.edit('Original', 'user-1', ['a'])).toBe(true);
|
||||
expect(comment.fileIds).toEqual(['a']);
|
||||
expect(comment.editedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('keeps files when fileIds is not passed', () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['a']);
|
||||
|
||||
expect(comment.edit('Changed', 'user-1')).toBe(true);
|
||||
expect(comment.fileIds).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('reports no change for the same text and files', () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['a', 'b']);
|
||||
|
||||
expect(comment.edit('Original', 'user-1', ['a', 'b'])).toBe(false);
|
||||
});
|
||||
|
||||
it('drops files on delete so GC can collect them', () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['a']);
|
||||
|
||||
comment.delete('user-1');
|
||||
|
||||
expect(comment.fileIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface ICommentRepository {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}): Promise<{ list: Comment[]; nextCursor: string | null }>;
|
||||
/**
|
||||
* Создание и обновление сохраняют и вложения (fileIds) — атомарно с комментарием.
|
||||
* FileNotFoundException, если файл удалили между проверкой и записью.
|
||||
*/
|
||||
create(comment: Comment): Promise<Comment>;
|
||||
update(comment: Comment): Promise<void>;
|
||||
/** Задачи неудалённых комментариев, к которым прикреплён файл */
|
||||
findIssueIdsByAttachedFile(fileId: string): Promise<number[]>;
|
||||
/** Подмножество fileIds, прикреплённых хотя бы к одному комментарию */
|
||||
findReferencedFileIds(fileIds: string[]): Promise<string[]>;
|
||||
}
|
||||
Reference in new issue
Block a user