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
+510 -57

No files matched your search

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "comment_attachment" (
"commentId" TEXT NOT NULL,
"fileId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "comment_attachment_pkey" PRIMARY KEY ("commentId","fileId")
);
-- CreateIndex
CREATE INDEX "comment_attachment_fileId_idx" ON "comment_attachment"("fileId");
-- AddForeignKey
ALTER TABLE "comment_attachment" ADD CONSTRAINT "comment_attachment_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comment_attachment" ADD CONSTRAINT "comment_attachment_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+17
View File
@@ -206,6 +206,7 @@ model Comment {
deletedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
attachments CommentAttachment[]
@@index([issueId, createdAt])
@@index([authorId])
@@ -227,6 +228,7 @@ model File {
createdAt DateTime @default(now()) @db.Timestamptz(3)
issueAttachments IssueAttachment[]
commentAttachments CommentAttachment[]
@@unique([storageKey])
@@index([uploaderId])
@@ -247,3 +249,18 @@ model IssueAttachment {
@@index([fileId])
@@map("issue_attachment")
}
/// Вложение комментария — связь комментария с файлом. Управляется вместе
/// с комментарием (fileIds в create/update); при мягком удалении комментария
/// связи снимаются, чтобы файлы забрал GC.
model CommentAttachment {
commentId String
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
fileId String
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @db.Timestamptz(3)
@@id([commentId, fileId])
@@index([fileId])
@@map("comment_attachment")
}
@@ -1,4 +1,5 @@
import { UserDto } from '../../../user/application/dto/user.dto.js';
import { FileDto } from '../../../file/application/dto/file.dto.js';
export class CommentDto {
id: string;
@@ -8,6 +9,8 @@ export class CommentDto {
author: UserDto | null;
/** null у удалённого комментария — текст наружу не отдаём */
content: string | null;
/** Пусто у удалённого комментария. Скачивание — GET /files/:id/download */
attachments: FileDto[];
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
@@ -13,12 +13,13 @@ const author: UserDto = {
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
const buildComment = (deletedAt: Date | null = null, authorId: string | null = 'user-1') =>
const buildComment = (deletedAt: Date | null = null, authorId: string | null = 'user-1', fileIds: string[] = []) =>
Comment.reconstitute({
id: 'comment-1',
content: 'Secret',
issueId: 1,
authorId,
fileIds,
editedAt: null,
deletedAt,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
@@ -46,4 +47,28 @@ describe('CommentApplicationMapper', () => {
expect(dto.authorId).toBeNull();
expect(dto.author).toBeNull();
});
it('expands attachments and skips files that no longer exist', () => {
const file = {
id: 'file-1',
fileName: 'a.png',
mimeType: 'image/png',
size: 1,
uploaderId: 'user-1',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
};
const dto = CommentApplicationMapper.toDto(
buildComment(null, 'user-1', ['file-1', 'gone']),
new Map(),
new Map([[file.id, file]]),
);
expect(dto.attachments).toEqual([file]);
});
it('hides attachments of a deleted comment', () => {
const dto = CommentApplicationMapper.toDto(buildComment(new Date(), 'user-1', ['file-1']), new Map(), new Map());
expect(dto.attachments).toEqual([]);
});
});
@@ -1,15 +1,27 @@
import { Comment } from '../../domain/entities/comment.entity.js';
import { CommentDto } from '../dto/comment.dto.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
import { FileDto } from '../../../file/application/dto/file.dto.js';
export class CommentApplicationMapper {
static toDto(comment: Comment, usersById: ReadonlyMap<string, UserDto>): CommentDto {
static toDto(
comment: Comment,
usersById: ReadonlyMap<string, UserDto>,
filesById: ReadonlyMap<string, FileDto> = new Map(),
): CommentDto {
return {
id: comment.id as string,
issueId: comment.issueId,
authorId: comment.authorId,
author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null,
content: comment.isDeleted ? null : comment.content,
// Файл могли удалить между запросами — такое вложение просто не показываем
attachments: comment.isDeleted
? []
: comment.fileIds.flatMap((fileId) => {
const file = filesById.get(fileId);
return file ? [file] : [];
}),
editedAt: comment.editedAt,
deletedAt: comment.deletedAt,
createdAt: comment.createdAt,
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
import { FileOwnerRegistry } from '../../../file/application/services/file-owner.registry.js';
import { CommentAttachmentFileOwnerService } from './comment-attachment-file-owner.service.js';
import { buildIssue, mockCommentRepository, mockIssueRepository } from '../use-cases/test-helpers.js';
describe('CommentAttachmentFileOwnerService', () => {
let commentRepository: ICommentRepository;
let issueRepository: IIssueRepository;
let registry: FileOwnerRegistry;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
registry = new FileOwnerRegistry();
new CommentAttachmentFileOwnerService(commentRepository, issueRepository, registry).onModuleInit();
});
it('registers itself, so GC keeps files attached to comments', async () => {
vi.mocked(commentRepository.findReferencedFileIds).mockResolvedValue(['a']);
expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a']));
});
it('makes a file visible to users who see the issue of its comment', async () => {
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true);
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-2');
});
it('hides a file when the issue is not visible', async () => {
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
vi.mocked(issueRepository.findById).mockResolvedValue(null);
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-3')).toBe(false);
});
});
@@ -0,0 +1,44 @@
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../domain/repositories/comment.repository.interface.js';
import {
ISSUE_REPOSITORY,
type IIssueRepository,
} from '../../../issue/domain/repositories/issue.repository.interface.js';
import { FileOwnerRegistry, type FileOwner } from '../../../file/application/services/file-owner.registry.js';
/**
* Регистрирует комментарии как владельца файлов: без этого GC file-модуля
* считал бы прикреплённые к комментариям файлы сиротами и удалял их.
*/
@Injectable()
export class CommentAttachmentFileOwnerService implements FileOwner, OnModuleInit {
readonly name = 'comment';
constructor(
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly fileOwners: FileOwnerRegistry,
) {}
onModuleInit(): void {
this.fileOwners.register(this);
}
findReferenced(fileIds: string[]): Promise<string[]> {
return this.commentRepository.findReferencedFileIds(fileIds);
}
/** Файл виден, если видна задача хоть одного неудалённого комментария с ним */
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
const issueIds = await this.commentRepository.findIssueIdsByAttachedFile(fileId);
for (const issueId of issueIds) {
if (await this.issueRepository.findById(issueId, userId)) {
return true;
}
}
return false;
}
}
@@ -4,11 +4,16 @@ import { CommentDto } from '../dto/comment.dto.js';
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
import { GetFilesUseCase } from '../../../file/application/use-cases/get-files/get-files.use-case.js';
import { GetFilesQuery } from '../../../file/application/use-cases/get-files/get-files.query.js';
/** Раскрывает автора комментария (или списка комментариев) в полноценного пользователя */
/** Раскрывает автора и вложения комментария (или списка комментариев) */
@Injectable()
export class CommentAuthorExpanderService {
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
export class CommentExpanderService {
constructor(
private readonly getUsersUseCase: GetUsersUseCase,
private readonly getFilesUseCase: GetFilesUseCase,
) {}
async expand(comment: Comment): Promise<CommentDto> {
const [dto] = await this.expandAll([comment]);
@@ -19,9 +24,15 @@ export class CommentAuthorExpanderService {
if (comments.length === 0) return [];
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))];
const users = userIds.length > 0 ? await this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [];
const usersById = new Map(users.map((user) => [user.id, user]));
const fileIds = comments.flatMap((comment) => comment.fileIds);
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById));
const [users, files] = await Promise.all([
userIds.length > 0 ? this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [],
fileIds.length > 0 ? this.getFilesUseCase.execute(new GetFilesQuery(fileIds)) : [],
]);
const usersById = new Map(users.map((user) => [user.id, user]));
const filesById = new Map(files.map((file) => [file.id, file]));
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById, filesById));
}
}
@@ -3,5 +3,7 @@ export class CreateCommentCommand {
public readonly issueId: number,
public readonly authorId: string,
public readonly content: string,
/** Файлы, заранее загруженные автором через POST /files */
public readonly fileIds: string[] = [],
) {}
}
@@ -6,7 +6,14 @@ import { Comment } from '../../../domain/entities/comment.entity.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CreateCommentUseCase } from './create-comment.use-case.js';
import { CreateCommentCommand } from './create-comment.command.js';
import { buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js';
import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js';
import {
buildIssue,
mockCommentExpander,
mockCommentRepository,
mockFileAccess,
mockIssueRepository,
} from '../test-helpers.js';
describe('CreateCommentUseCase', () => {
let commentRepository: ICommentRepository;
@@ -18,7 +25,13 @@ describe('CreateCommentUseCase', () => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
useCase = new CreateCommentUseCase(commentRepository, issueRepository, mockAuthorExpander(), commentEventsService);
useCase = new CreateCommentUseCase(
commentRepository,
issueRepository,
mockCommentExpander(),
commentEventsService,
mockFileAccess({ foreign: new FileForbiddenException('foreign') }),
);
vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) =>
Comment.reconstitute({
@@ -26,6 +39,7 @@ describe('CreateCommentUseCase', () => {
content: comment.content,
issueId: comment.issueId,
authorId: comment.authorId,
fileIds: [...comment.fileIds],
editedAt: null,
deletedAt: null,
createdAt: comment.createdAt,
@@ -54,4 +68,21 @@ describe('CreateCommentUseCase', () => {
);
expect(commentRepository.create).not.toHaveBeenCalled();
});
it('saves own files as attachments', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
await useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello', ['b', 'a', 'b']));
expect(vi.mocked(commentRepository.create).mock.calls[0]![0].fileIds).toEqual(['a', 'b']);
});
it('does not create the comment when a file is not attachable', async () => {
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
await expect(useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello', ['a', 'foreign']))).rejects.toThrow(
FileForbiddenException,
);
expect(commentRepository.create).not.toHaveBeenCalled();
});
});
@@ -11,8 +11,9 @@ import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issu
import { Comment } from '../../../domain/entities/comment.entity.js';
import { CreateCommentCommand } from './create-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
@Injectable()
export class CreateCommentUseCase {
@@ -21,8 +22,9 @@ export class CreateCommentUseCase {
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService,
private readonly fileAccess: FileAccessService,
) {}
async execute(command: CreateCommentCommand): Promise<CommentDto> {
@@ -36,10 +38,16 @@ export class CreateCommentUseCase {
issueId: command.issueId,
authorId: command.authorId,
content: command.content,
fileIds: command.fileIds,
});
// Прикреплять можно только свои файлы (см. FileAccessService.getAttachableFile)
for (const fileId of comment.fileIds) {
await this.fileAccess.getAttachableFile(fileId, command.authorId);
}
const saved = await this.commentRepository.create(comment);
const dto = await this.commentAuthorExpander.expand(saved);
const dto = await this.commentExpander.expand(saved);
this.commentEventsService.emit('comment.created', dto);
@@ -11,7 +11,7 @@ import { DeleteCommentCommand } from './delete-comment.command.js';
import {
buildComment,
buildIssue,
mockAuthorExpander,
mockCommentExpander,
mockCommentRepository,
mockIssueRepository,
} from '../test-helpers.js';
@@ -29,7 +29,7 @@ describe('DeleteCommentUseCase', () => {
useCase = new DeleteCommentUseCase(
commentRepository,
new CommentAccessService(commentRepository, issueRepository),
mockAuthorExpander(),
mockCommentExpander(),
commentEventsService,
);
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
@@ -49,6 +49,16 @@ describe('DeleteCommentUseCase', () => {
expect(received[0]!.comment.content).toBeNull();
});
it('drops attachments so GC can collect the files', async () => {
const comment = buildComment();
comment.edit('Original', 'user-1', ['file-1']);
vi.mocked(commentRepository.findById).mockResolvedValue(comment);
await useCase.execute(new DeleteCommentCommand('comment-1', 'user-1'));
expect(vi.mocked(commentRepository.update).mock.calls[0]![0].fileIds).toEqual([]);
});
it('throws CommentForbiddenException for a non-author', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
@@ -4,7 +4,7 @@ import {
type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js';
import { DeleteCommentCommand } from './delete-comment.command.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
@@ -15,7 +15,7 @@ export class DeleteCommentUseCase {
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService,
) {}
@@ -25,6 +25,6 @@ export class DeleteCommentUseCase {
comment.delete(command.userId);
await this.commentRepository.update(comment);
this.commentEventsService.emit('comment.deleted', await this.commentAuthorExpander.expand(comment));
this.commentEventsService.emit('comment.deleted', await this.commentExpander.expand(comment));
}
}
@@ -7,7 +7,7 @@ import { ListCommentsQuery } from './list-comments.query.js';
import {
buildComment,
buildIssue,
mockAuthorExpander,
mockCommentExpander,
mockCommentRepository,
mockIssueRepository,
} from '../test-helpers.js';
@@ -20,7 +20,7 @@ describe('ListCommentsUseCase', () => {
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockAuthorExpander());
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockCommentExpander());
});
it('forwards pagination params and returns the next cursor', async () => {
@@ -10,7 +10,7 @@ import {
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { ListCommentsQuery } from './list-comments.query.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
@Injectable()
export class ListCommentsUseCase {
@@ -19,7 +19,7 @@ export class ListCommentsUseCase {
private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
private readonly commentExpander: CommentExpanderService,
) {}
async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> {
@@ -34,6 +34,6 @@ export class ListCommentsUseCase {
cursor: query.cursor,
});
return { list: await this.commentAuthorExpander.expandAll(list), nextCursor };
return { list: await this.commentExpander.expandAll(list), nextCursor };
}
}
@@ -3,8 +3,9 @@ import type { ICommentRepository } from '../../domain/repositories/comment.repos
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
import { Comment } from '../../domain/entities/comment.entity.js';
import { Issue } from '../../../issue/domain/entities/issue.entity.js';
import { CommentAuthorExpanderService } from '../services/comment-author-expander.service.js';
import { CommentExpanderService } from '../services/comment-expander.service.js';
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
import type { FileAccessService } from '../../../file/application/services/file-access.service.js';
export const buildComment = (
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
@@ -41,6 +42,8 @@ export const mockCommentRepository = (): ICommentRepository => ({
findByIssue: vi.fn(),
create: vi.fn(),
update: vi.fn(),
findIssueIdsByAttachedFile: vi.fn(),
findReferencedFileIds: vi.fn(),
});
export const mockIssueRepository = (): IIssueRepository => ({
@@ -53,11 +56,20 @@ export const mockIssueRepository = (): IIssueRepository => ({
delete: vi.fn(),
});
/** Экспандер без похода за пользователями: author всегда null */
export const mockAuthorExpander = () => {
/** Все файлы прикрепляемы, кроме перечисленных — для них бросается заданная ошибка */
export const mockFileAccess = (rejections: Record<string, Error> = {}) =>
({
getAttachableFile: vi.fn(async (fileId: string) => {
if (rejections[fileId]) throw rejections[fileId];
return { id: fileId };
}),
}) as unknown as FileAccessService;
/** Экспандер без похода за пользователями и файлами: author всегда null, attachments пустые */
export const mockCommentExpander = () => {
const toDto = (comment: Comment) => CommentApplicationMapper.toDto(comment, new Map());
return {
expand: vi.fn(async (comment: Comment) => toDto(comment)),
expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)),
} as unknown as CommentAuthorExpanderService;
} as unknown as CommentExpanderService;
};
@@ -3,5 +3,7 @@ export class UpdateCommentCommand {
public readonly id: string,
public readonly userId: string,
public readonly content: string,
/** Новый набор вложений целиком; undefined — не менять */
public readonly fileIds?: string[],
) {}
}
@@ -10,10 +10,13 @@ import { UpdateCommentCommand } from './update-comment.command.js';
import {
buildComment,
buildIssue,
mockAuthorExpander,
mockCommentExpander,
mockCommentRepository,
mockFileAccess,
mockIssueRepository,
} from '../test-helpers.js';
import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js';
import type { FileAccessService } from '../../../../file/application/services/file-access.service.js';
describe('UpdateCommentUseCase', () => {
let commentRepository: ICommentRepository;
@@ -21,16 +24,19 @@ describe('UpdateCommentUseCase', () => {
let commentEventsService: CommentEventsService;
let useCase: UpdateCommentUseCase;
let received: unknown[];
let fileAccess: FileAccessService;
beforeEach(() => {
commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService();
fileAccess = mockFileAccess({ gone: new FileNotFoundException('gone') });
useCase = new UpdateCommentUseCase(
commentRepository,
new CommentAccessService(commentRepository, issueRepository),
mockAuthorExpander(),
mockCommentExpander(),
commentEventsService,
fileAccess,
);
received = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
@@ -82,4 +88,25 @@ describe('UpdateCommentUseCase', () => {
CommentNotFoundException,
);
});
it('replaces attachments, checking only newly added files', async () => {
const comment = buildComment();
comment.edit('Original', 'user-1', ['kept', 'removed']);
vi.mocked(commentRepository.findById).mockResolvedValue(comment);
await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['kept', 'new']));
expect(fileAccess.getAttachableFile).toHaveBeenCalledExactlyOnceWith('new', 'user-1');
expect(vi.mocked(commentRepository.update).mock.calls[0]![0].fileIds).toEqual(['kept', 'new']);
expect(received).toHaveLength(1);
});
it('does not save when an added file is gone', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
await expect(
useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['gone'])),
).rejects.toThrow(FileNotFoundException);
expect(commentRepository.update).not.toHaveBeenCalled();
});
});
@@ -5,9 +5,10 @@ import {
} from '../../../domain/repositories/comment.repository.interface.js';
import { UpdateCommentCommand } from './update-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.js';
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
import { CommentExpanderService } from '../../services/comment-expander.service.js';
import { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
@Injectable()
export class UpdateCommentUseCase {
@@ -15,19 +16,29 @@ export class UpdateCommentUseCase {
@Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService,
private readonly commentAuthorExpander: CommentAuthorExpanderService,
private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService,
private readonly fileAccess: FileAccessService,
) {}
async execute(command: UpdateCommentCommand): Promise<CommentDto> {
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId);
const attachedBefore = new Set(comment.fileIds);
const changed = comment.edit(command.content, command.userId, command.fileIds);
// Проверяем только добавленные файлы: уже прикреплённые проверены при прикреплении
for (const fileId of comment.fileIds) {
if (!attachedBefore.has(fileId)) {
await this.fileAccess.getAttachableFile(fileId, command.userId);
}
}
const changed = comment.edit(command.content, command.userId);
if (changed) {
await this.commentRepository.update(comment);
}
const dto = await this.commentAuthorExpander.expand(comment);
const dto = await this.commentExpander.expand(comment);
if (changed) {
this.commentEventsService.emit('comment.updated', dto);
+6 -3
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { IssueModule } from '../issue/issue.module.js';
import { UserModule } from '../user/user.module.js';
import { FileModule } from '../file/file.module.js';
import { COMMENT_REPOSITORY } from './domain/repositories/comment.repository.interface.js';
import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js';
import { CommentController } from './presentation/controllers/comment.controller.js';
@@ -11,11 +12,12 @@ import { UpdateCommentUseCase } from './application/use-cases/update-comment/upd
import { DeleteCommentUseCase } from './application/use-cases/delete-comment/delete-comment.use-case.js';
import { WatchIssueCommentsUseCase } from './application/use-cases/watch-issue-comments/watch-issue-comments.use-case.js';
import { CommentAccessService } from './application/services/comment-access.service.js';
import { CommentAuthorExpanderService } from './application/services/comment-author-expander.service.js';
import { CommentExpanderService } from './application/services/comment-expander.service.js';
import { CommentEventsService } from './application/services/comment-events.service.js';
import { CommentAttachmentFileOwnerService } from './application/services/comment-attachment-file-owner.service.js';
@Module({
imports: [PrismaModule, IssueModule, UserModule],
imports: [PrismaModule, IssueModule, UserModule, FileModule],
controllers: [CommentController],
providers: [
CreateCommentUseCase,
@@ -24,8 +26,9 @@ import { CommentEventsService } from './application/services/comment-events.serv
DeleteCommentUseCase,
WatchIssueCommentsUseCase,
CommentAccessService,
CommentAuthorExpanderService,
CommentExpanderService,
CommentEventsService,
CommentAttachmentFileOwnerService,
{
// Domain видит только интерфейс ICommentRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
@@ -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[]>;
}
@@ -1,13 +1,17 @@
import type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js';
import { Comment } from '../../../../domain/entities/comment.entity.js';
/** Комментарий вместе с id прикреплённых файлов (см. COMMENT_INCLUDE в репозитории) */
export type PrismaCommentWithAttachments = PrismaComment & { attachments: { fileId: string }[] };
export class CommentPrismaMapper {
static toDomain(record: PrismaComment): Comment {
static toDomain(record: PrismaCommentWithAttachments): Comment {
return Comment.reconstitute({
id: record.id,
content: record.content,
issueId: record.issueId,
authorId: record.authorId,
fileIds: record.attachments.map((attachment) => attachment.fileId),
editedAt: record.editedAt,
deletedAt: record.deletedAt,
createdAt: record.createdAt,
@@ -22,6 +26,7 @@ export class CommentPrismaMapper {
authorId: comment.authorId,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
attachments: { create: comment.fileIds.map((fileId) => ({ fileId })) },
};
}
@@ -5,15 +5,21 @@ import { Comment } from '../../../../domain/entities/comment.entity.js';
import { ICommentRepository } from '../../../../domain/repositories/comment.repository.interface.js';
import { CommentPrismaMapper } from '../mappers/comment.mapper.js';
import { IssueNotFoundException } from '../../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { FileNotFoundException } from '../../../../../file/domain/exceptions/file-not-found.exception.js';
const FOREIGN_KEY_VIOLATION = 'P2003';
/** Вложения подгружаются вместе с комментарием, по времени загрузки файла (uuid v7) */
const COMMENT_INCLUDE = {
attachments: { select: { fileId: true }, orderBy: { fileId: 'asc' } },
} satisfies Prisma.CommentInclude;
@Injectable()
export class CommentPrismaRepository implements ICommentRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string): Promise<Comment | null> {
const record = await this.prisma.comment.findUnique({ where: { id } });
const record = await this.prisma.comment.findUnique({ where: { id }, include: COMMENT_INCLUDE });
return record ? CommentPrismaMapper.toDomain(record) : null;
}
@@ -28,6 +34,7 @@ export class CommentPrismaRepository implements ICommentRepository {
// id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt.
const records = await this.prisma.comment.findMany({
where: { issueId: params.issueId },
include: COMMENT_INCLUDE,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: limit + 1,
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
@@ -44,24 +51,73 @@ export class CommentPrismaRepository implements ICommentRepository {
async create(comment: Comment): Promise<Comment> {
try {
// Вложения создаются вложенной записью — одним атомарным запросом
const record = await this.prisma.comment.create({
data: CommentPrismaMapper.toCreateInput(comment),
include: COMMENT_INCLUDE,
});
return CommentPrismaMapper.toDomain(record);
} catch (error) {
// Задачу удалили между проверкой доступа и вставкой
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) {
throw new IssueNotFoundException(comment.issueId);
}
throw error;
// Задачу или файл удалили между проверкой доступа и вставкой
throw this.mapForeignKeyError(error, comment) ?? error;
}
}
async update(comment: Comment): Promise<void> {
await this.prisma.comment.update({
where: { id: comment.id as string },
const id = comment.id as string;
const fileIds = [...comment.fileIds];
try {
await this.prisma.$transaction(async (tx) => {
await tx.comment.update({
where: { id },
data: CommentPrismaMapper.toUpdateInput(comment),
});
// Синхронизируем набор вложений: лишние снимаем, новые добавляем
await tx.commentAttachment.deleteMany({ where: { commentId: id, fileId: { notIn: fileIds } } });
if (fileIds.length > 0) {
await tx.commentAttachment.createMany({
data: fileIds.map((fileId) => ({ commentId: id, fileId })),
skipDuplicates: true,
});
}
});
} catch (error) {
throw this.mapForeignKeyError(error, comment) ?? error;
}
}
async findIssueIdsByAttachedFile(fileId: string): Promise<number[]> {
const records = await this.prisma.comment.findMany({
where: { deletedAt: null, attachments: { some: { fileId } } },
select: { issueId: true },
distinct: ['issueId'],
});
return records.map((record) => record.issueId);
}
async findReferencedFileIds(fileIds: string[]): Promise<string[]> {
if (fileIds.length === 0) return [];
const records = await this.prisma.commentAttachment.findMany({
where: { fileId: { in: fileIds } },
select: { fileId: true },
distinct: ['fileId'],
});
return records.map((record) => record.fileId);
}
/** Какой FK нарушен — по имени ограничения в meta (comment_attachment_fileId_fkey и т.п.) */
private mapForeignKeyError(error: unknown, comment: Comment): Error | null {
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== FOREIGN_KEY_VIOLATION) {
return null;
}
if (JSON.stringify(error.meta ?? {}).includes('fileId')) {
return new FileNotFoundException(comment.fileIds.join(', '));
}
return new IssueNotFoundException(comment.issueId);
}
}
@@ -35,6 +35,8 @@ import { CommentNotFoundException } from '../../domain/exceptions/comment-not-fo
import { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js';
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
import { IssueVisibleGuard } from '../guards/issue-visible.guard.js';
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
@Controller()
export class CommentController {
@@ -66,7 +68,7 @@ export class CommentController {
@Session() session: UserSession,
) {
return this.handleErrors(() =>
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content)),
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content, dto.fileIds)),
);
}
@@ -93,7 +95,7 @@ export class CommentController {
@Patch('comments/:id')
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
return this.handleErrors(() =>
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content)),
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content, dto.fileIds)),
);
}
@@ -108,10 +110,14 @@ export class CommentController {
try {
return await fn();
} catch (error) {
if (error instanceof CommentNotFoundException || error instanceof IssueNotFoundException) {
if (
error instanceof CommentNotFoundException ||
error instanceof IssueNotFoundException ||
error instanceof FileNotFoundException
) {
throw new NotFoundException(error.message);
}
if (error instanceof CommentForbiddenException) {
if (error instanceof CommentForbiddenException || error instanceof FileForbiddenException) {
throw new ForbiddenException(error.message);
}
throw error;
@@ -1,9 +1,18 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
import { Comment } from '../../domain/entities/comment.entity.js';
export class CreateCommentRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH)
content: string;
/** id файлов, загруженных через POST /files */
@IsOptional()
@IsArray()
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
@IsString({ each: true })
@MaxLength(64, { each: true })
fileIds?: string[];
}
@@ -1,9 +1,18 @@
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
import { Comment } from '../../domain/entities/comment.entity.js';
export class UpdateCommentRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH)
content: string;
/** id файлов, загруженных через POST /files — набор целиком; не передан — не менять */
@IsOptional()
@IsArray()
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
@IsString({ each: true })
@MaxLength(64, { each: true })
fileIds?: string[];
}