From 60fb39f98fcaaf5864f54e5d4fb8e6a3f36ed3e5 Mon Sep 17 00:00:00 2001 From: Vadim Date: Fri, 2 Oct 2026 09:18:08 +0300 Subject: [PATCH] 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 --- .../migration.sql | 18 +++++ prisma/schema.prisma | 39 +++++++--- .../comment/application/dto/comment.dto.ts | 3 + .../comment-application.mapper.spec.ts | 27 ++++++- .../mappers/comment-application.mapper.ts | 14 +++- ...ment-attachment-file-owner.service.spec.ts | 40 ++++++++++ .../comment-attachment-file-owner.service.ts | 44 +++++++++++ ...service.ts => comment-expander.service.ts} | 23 ++++-- .../create-comment/create-comment.command.ts | 2 + .../create-comment.use-case.spec.ts | 35 ++++++++- .../create-comment/create-comment.use-case.ts | 14 +++- .../delete-comment.use-case.spec.ts | 14 +++- .../delete-comment/delete-comment.use-case.ts | 6 +- .../list-comments.use-case.spec.ts | 4 +- .../list-comments/list-comments.use-case.ts | 6 +- .../application/use-cases/test-helpers.ts | 20 ++++- .../update-comment/update-comment.command.ts | 2 + .../update-comment.use-case.spec.ts | 31 +++++++- .../update-comment/update-comment.use-case.ts | 19 ++++- src/modules/comment/comment.module.ts | 9 ++- .../domain/entities/comment.entity.spec.ts | 46 ++++++++++++ .../comment/domain/entities/comment.entity.ts | 50 +++++++++++-- .../comment.repository.interface.ts | 8 ++ .../prisma/mappers/comment.mapper.ts | 7 +- .../repositories/comment.prisma-repository.ts | 74 ++++++++++++++++--- .../controllers/comment.controller.ts | 14 +++- .../presentation/dto/create-comment.dto.ts | 11 ++- .../presentation/dto/update-comment.dto.ts | 11 ++- 28 files changed, 522 insertions(+), 69 deletions(-) create mode 100644 prisma/migrations/20261002060755_comment_attachment/migration.sql create mode 100644 src/modules/comment/application/services/comment-attachment-file-owner.service.spec.ts create mode 100644 src/modules/comment/application/services/comment-attachment-file-owner.service.ts rename src/modules/comment/application/services/{comment-author-expander.service.ts => comment-expander.service.ts} (51%) diff --git a/prisma/migrations/20261002060755_comment_attachment/migration.sql b/prisma/migrations/20261002060755_comment_attachment/migration.sql new file mode 100644 index 0000000..c3c8aea --- /dev/null +++ b/prisma/migrations/20261002060755_comment_attachment/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9073f75..5747452 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -196,16 +196,17 @@ model NotificationMessage { /// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL). /// deletedAt — мягкое удаление, editedAt — время последней правки текста. model Comment { - id String @id @default(uuid(7)) - content String - issueId Int - issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) - authorId String? - author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) - editedAt DateTime? @db.Timestamptz(3) - deletedAt DateTime? @db.Timestamptz(3) - createdAt DateTime @default(now()) @db.Timestamptz(3) - updatedAt DateTime @updatedAt @db.Timestamptz(3) + id String @id @default(uuid(7)) + content String + issueId Int + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + authorId String? + author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) + editedAt DateTime? @db.Timestamptz(3) + 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]) @@ -226,7 +227,8 @@ model File { uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @db.Timestamptz(3) - issueAttachments IssueAttachment[] + 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") +} diff --git a/src/modules/comment/application/dto/comment.dto.ts b/src/modules/comment/application/dto/comment.dto.ts index d61cf97..7184c5f 100644 --- a/src/modules/comment/application/dto/comment.dto.ts +++ b/src/modules/comment/application/dto/comment.dto.ts @@ -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; diff --git a/src/modules/comment/application/mappers/comment-application.mapper.spec.ts b/src/modules/comment/application/mappers/comment-application.mapper.spec.ts index a5d8caf..bd2a3b1 100644 --- a/src/modules/comment/application/mappers/comment-application.mapper.spec.ts +++ b/src/modules/comment/application/mappers/comment-application.mapper.spec.ts @@ -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([]); + }); }); diff --git a/src/modules/comment/application/mappers/comment-application.mapper.ts b/src/modules/comment/application/mappers/comment-application.mapper.ts index 5f455b7..d96a9c4 100644 --- a/src/modules/comment/application/mappers/comment-application.mapper.ts +++ b/src/modules/comment/application/mappers/comment-application.mapper.ts @@ -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): CommentDto { + static toDto( + comment: Comment, + usersById: ReadonlyMap, + filesById: ReadonlyMap = 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, diff --git a/src/modules/comment/application/services/comment-attachment-file-owner.service.spec.ts b/src/modules/comment/application/services/comment-attachment-file-owner.service.spec.ts new file mode 100644 index 0000000..4b55d8a --- /dev/null +++ b/src/modules/comment/application/services/comment-attachment-file-owner.service.spec.ts @@ -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); + }); +}); diff --git a/src/modules/comment/application/services/comment-attachment-file-owner.service.ts b/src/modules/comment/application/services/comment-attachment-file-owner.service.ts new file mode 100644 index 0000000..ce38627 --- /dev/null +++ b/src/modules/comment/application/services/comment-attachment-file-owner.service.ts @@ -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 { + return this.commentRepository.findReferencedFileIds(fileIds); + } + + /** Файл виден, если видна задача хоть одного неудалённого комментария с ним */ + async isVisibleThrough(fileId: string, userId: string): Promise { + const issueIds = await this.commentRepository.findIssueIdsByAttachedFile(fileId); + + for (const issueId of issueIds) { + if (await this.issueRepository.findById(issueId, userId)) { + return true; + } + } + return false; + } +} diff --git a/src/modules/comment/application/services/comment-author-expander.service.ts b/src/modules/comment/application/services/comment-expander.service.ts similarity index 51% rename from src/modules/comment/application/services/comment-author-expander.service.ts rename to src/modules/comment/application/services/comment-expander.service.ts index b401914..7f0c753 100644 --- a/src/modules/comment/application/services/comment-author-expander.service.ts +++ b/src/modules/comment/application/services/comment-expander.service.ts @@ -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 { 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)); } } diff --git a/src/modules/comment/application/use-cases/create-comment/create-comment.command.ts b/src/modules/comment/application/use-cases/create-comment/create-comment.command.ts index cbbcb07..a54ced9 100644 --- a/src/modules/comment/application/use-cases/create-comment/create-comment.command.ts +++ b/src/modules/comment/application/use-cases/create-comment/create-comment.command.ts @@ -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[] = [], ) {} } diff --git a/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts index 20dd077..6b108de 100644 --- a/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts +++ b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts @@ -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(); + }); }); diff --git a/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts index fcc223d..e4b8b55 100644 --- a/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts +++ b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts @@ -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 { @@ -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); diff --git a/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts index 0bd669c..afe8378 100644 --- a/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts +++ b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts @@ -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()); diff --git a/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts index 831a704..d5d704a 100644 --- a/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts +++ b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts @@ -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)); } } diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts index 080da65..504d572 100644 --- a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts @@ -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 () => { diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts index f3d95dc..e63bd7d 100644 --- a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts @@ -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 }; } } diff --git a/src/modules/comment/application/use-cases/test-helpers.ts b/src/modules/comment/application/use-cases/test-helpers.ts index 283ac32..a4dec4b 100644 --- a/src/modules/comment/application/use-cases/test-helpers.ts +++ b/src/modules/comment/application/use-cases/test-helpers.ts @@ -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 = {}) => + ({ + 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; }; diff --git a/src/modules/comment/application/use-cases/update-comment/update-comment.command.ts b/src/modules/comment/application/use-cases/update-comment/update-comment.command.ts index 308b41f..571cff2 100644 --- a/src/modules/comment/application/use-cases/update-comment/update-comment.command.ts +++ b/src/modules/comment/application/use-cases/update-comment/update-comment.command.ts @@ -3,5 +3,7 @@ export class UpdateCommentCommand { public readonly id: string, public readonly userId: string, public readonly content: string, + /** Новый набор вложений целиком; undefined — не менять */ + public readonly fileIds?: string[], ) {} } diff --git a/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts index 90aff6c..f2911c9 100644 --- a/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts +++ b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts @@ -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(); + }); }); diff --git a/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts index c294196..2305567 100644 --- a/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts +++ b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts @@ -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 { 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); diff --git a/src/modules/comment/comment.module.ts b/src/modules/comment/comment.module.ts index b2c60cf..973747d 100644 --- a/src/modules/comment/comment.module.ts +++ b/src/modules/comment/comment.module.ts @@ -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-контейнер здесь diff --git a/src/modules/comment/domain/entities/comment.entity.spec.ts b/src/modules/comment/domain/entities/comment.entity.spec.ts index 7d14ab2..e63d8fc 100644 --- a/src/modules/comment/domain/entities/comment.entity.spec.ts +++ b/src/modules/comment/domain/entities/comment.entity.spec.ts @@ -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([]); + }); + }); }); diff --git a/src/modules/comment/domain/entities/comment.entity.ts b/src/modules/comment/domain/entities/comment.entity.ts index 7fb3c3f..b7dcc24 100644 --- a/src/modules/comment/domain/entities/comment.entity.ts +++ b/src/modules/comment/domain/entities/comment.entity.ts @@ -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) { diff --git a/src/modules/comment/domain/repositories/comment.repository.interface.ts b/src/modules/comment/domain/repositories/comment.repository.interface.ts index c865b9a..207c4cd 100644 --- a/src/modules/comment/domain/repositories/comment.repository.interface.ts +++ b/src/modules/comment/domain/repositories/comment.repository.interface.ts @@ -15,6 +15,14 @@ export interface ICommentRepository { limit: number; cursor?: string; }): Promise<{ list: Comment[]; nextCursor: string | null }>; + /** + * Создание и обновление сохраняют и вложения (fileIds) — атомарно с комментарием. + * FileNotFoundException, если файл удалили между проверкой и записью. + */ create(comment: Comment): Promise; update(comment: Comment): Promise; + /** Задачи неудалённых комментариев, к которым прикреплён файл */ + findIssueIdsByAttachedFile(fileId: string): Promise; + /** Подмножество fileIds, прикреплённых хотя бы к одному комментарию */ + findReferencedFileIds(fileIds: string[]): Promise; } diff --git a/src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts b/src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts index ea5a847..95b5ec5 100644 --- a/src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts +++ b/src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts @@ -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 })) }, }; } diff --git a/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts b/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts index 691e9a5..f0999e6 100644 --- a/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts +++ b/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts @@ -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 { - 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 { 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 { - await this.prisma.comment.update({ - where: { id: comment.id as string }, - data: CommentPrismaMapper.toUpdateInput(comment), + 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 { + 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 { + 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); } } diff --git a/src/modules/comment/presentation/controllers/comment.controller.ts b/src/modules/comment/presentation/controllers/comment.controller.ts index 61398a0..229cbc0 100644 --- a/src/modules/comment/presentation/controllers/comment.controller.ts +++ b/src/modules/comment/presentation/controllers/comment.controller.ts @@ -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; diff --git a/src/modules/comment/presentation/dto/create-comment.dto.ts b/src/modules/comment/presentation/dto/create-comment.dto.ts index aabe716..94691e3 100644 --- a/src/modules/comment/presentation/dto/create-comment.dto.ts +++ b/src/modules/comment/presentation/dto/create-comment.dto.ts @@ -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[]; } diff --git a/src/modules/comment/presentation/dto/update-comment.dto.ts b/src/modules/comment/presentation/dto/update-comment.dto.ts index 2a97cdf..f770890 100644 --- a/src/modules/comment/presentation/dto/update-comment.dto.ts +++ b/src/modules/comment/presentation/dto/update-comment.dto.ts @@ -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[]; }