diff --git a/prisma/migrations/20261002050156_issue_attachment/migration.sql b/prisma/migrations/20261002050156_issue_attachment/migration.sql new file mode 100644 index 0000000..0a8e539 --- /dev/null +++ b/prisma/migrations/20261002050156_issue_attachment/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "issue_attachment" ( + "issueId" INTEGER NOT NULL, + "fileId" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "issue_attachment_pkey" PRIMARY KEY ("issueId","fileId") +); + +-- CreateIndex +CREATE INDEX "issue_attachment_fileId_idx" ON "issue_attachment"("fileId"); + +-- AddForeignKey +ALTER TABLE "issue_attachment" ADD CONSTRAINT "issue_attachment_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "issue_attachment" ADD CONSTRAINT "issue_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 acbf82f..9073f75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -81,22 +81,23 @@ model Verification { } model Issue { - id Int @id @default(autoincrement()) - key String - title String - content String? - status String - priority Int @default(0) - assigneeId String? - assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull) - reporterId String? - reporter User? @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: SetNull) - projectId String - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - dueDate DateTime? @db.Timestamptz(3) - comments Comment[] - createdAt DateTime @default(now()) @db.Timestamptz(3) - updatedAt DateTime @updatedAt @db.Timestamptz(3) + id Int @id @default(autoincrement()) + key String + title String + content String? + status String + priority Int @default(0) + assigneeId String? + assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull) + reporterId String? + reporter User? @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: SetNull) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + dueDate DateTime? @db.Timestamptz(3) + comments Comment[] + attachments IssueAttachment[] + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) @@index([status]) @@index([assigneeId]) @@ -225,8 +226,24 @@ model File { uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @db.Timestamptz(3) + issueAttachments IssueAttachment[] + @@unique([storageKey]) @@index([uploaderId]) @@index([createdAt]) @@map("file") } + +/// Вложение задачи — связь задачи с файлом. Удаление задачи или файла +/// удаляет связь каскадом; сам файл, оставшийся без связей, удалит GC. +model IssueAttachment { + issueId Int + issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) + fileId String + file File @relation(fields: [fileId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + @@id([issueId, fileId]) + @@index([fileId]) + @@map("issue_attachment") +} diff --git a/src/app.module.ts b/src/app.module.ts index 7acf720..501663c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { StatusModule } from './modules/status/status.module.js'; import { NotificationModule } from './modules/notification/notification.module.js'; import { CommentModule } from './modules/comment/comment.module.js'; import { FileModule } from './modules/file/file.module.js'; +import { IssueAttachmentModule } from './modules/issue-attachment/issue-attachment.module.js'; import { AuthModule } from '@thallesp/nestjs-better-auth'; import { auth } from './lib/auth.js'; import { ConfigModule } from '@nestjs/config'; @@ -53,6 +54,7 @@ import { join } from 'node:path'; NotificationModule, CommentModule, FileModule, + IssueAttachmentModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/modules/issue-attachment/application/dto/issue-attachment.dto.ts b/src/modules/issue-attachment/application/dto/issue-attachment.dto.ts new file mode 100644 index 0000000..c90ba18 --- /dev/null +++ b/src/modules/issue-attachment/application/dto/issue-attachment.dto.ts @@ -0,0 +1,8 @@ +import { FileDto } from '../../../file/application/dto/file.dto.js'; + +export class IssueAttachmentDto { + issueId: number; + file: FileDto; + /** Когда файл прикреплён к задаче (может отличаться от file.createdAt) */ + attachedAt: Date; +} diff --git a/src/modules/issue-attachment/application/services/issue-attachment-expander.service.spec.ts b/src/modules/issue-attachment/application/services/issue-attachment-expander.service.spec.ts new file mode 100644 index 0000000..2fab116 --- /dev/null +++ b/src/modules/issue-attachment/application/services/issue-attachment-expander.service.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { GetFilesUseCase } from '../../../file/application/use-cases/get-files/get-files.use-case.js'; +import { IssueAttachmentExpanderService } from './issue-attachment-expander.service.js'; +import { buildAttachment, buildFileDto } from '../use-cases/test-helpers.js'; + +describe('IssueAttachmentExpanderService', () => { + it('expands attachments into files and skips files deleted in between', async () => { + const getFiles = { execute: vi.fn().mockResolvedValue([buildFileDto('a')]) } as unknown as GetFilesUseCase; + const expander = new IssueAttachmentExpanderService(getFiles); + + const list = await expander.expandAll([buildAttachment({ fileId: 'a' }), buildAttachment({ fileId: 'gone' })]); + + expect(list).toEqual([{ issueId: 1, file: buildFileDto('a'), attachedAt: new Date('2026-01-02T00:00:00.000Z') }]); + }); +}); diff --git a/src/modules/issue-attachment/application/services/issue-attachment-expander.service.ts b/src/modules/issue-attachment/application/services/issue-attachment-expander.service.ts new file mode 100644 index 0000000..0e1de71 --- /dev/null +++ b/src/modules/issue-attachment/application/services/issue-attachment-expander.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { IssueAttachment } from '../../domain/entities/issue-attachment.entity.js'; +import { IssueAttachmentDto } from '../dto/issue-attachment.dto.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'; + +/** Раскрывает вложения в метаданные файлов из file-модуля */ +@Injectable() +export class IssueAttachmentExpanderService { + constructor(private readonly getFilesUseCase: GetFilesUseCase) {} + + async expandAll(attachments: IssueAttachment[]): Promise { + if (attachments.length === 0) return []; + + const files = await this.getFilesUseCase.execute(new GetFilesQuery(attachments.map((a) => a.fileId))); + const filesById = new Map(files.map((file) => [file.id, file])); + + // Файл могли удалить между запросами — такое вложение просто не показываем + return attachments.flatMap((attachment) => { + const file = filesById.get(attachment.fileId); + return file ? [{ issueId: attachment.issueId, file, attachedAt: attachment.createdAt }] : []; + }); + } +} diff --git a/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.spec.ts b/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.spec.ts new file mode 100644 index 0000000..524d295 --- /dev/null +++ b/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.spec.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IIssueAttachmentRepository } from '../../domain/repositories/issue-attachment.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 { IssueAttachmentFileOwnerService } from './issue-attachment-file-owner.service.js'; +import { buildIssue, mockAttachmentRepository, mockIssueRepository } from '../use-cases/test-helpers.js'; + +describe('IssueAttachmentFileOwnerService', () => { + let attachmentRepository: IIssueAttachmentRepository; + let issueRepository: IIssueRepository; + let registry: FileOwnerRegistry; + let owner: IssueAttachmentFileOwnerService; + + beforeEach(() => { + attachmentRepository = mockAttachmentRepository(); + issueRepository = mockIssueRepository(); + registry = new FileOwnerRegistry(); + owner = new IssueAttachmentFileOwnerService(attachmentRepository, issueRepository, registry); + owner.onModuleInit(); + }); + + it('registers itself, so GC keeps attached files', async () => { + vi.mocked(attachmentRepository.findReferencedFileIds).mockResolvedValue(['a']); + + expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a'])); + }); + + it('makes a file visible to users who see one of its issues', async () => { + vi.mocked(attachmentRepository.findIssueIdsByFile).mockResolvedValue([1, 2]); + vi.mocked(issueRepository.findById).mockImplementation(async (id: number) => (id === 2 ? buildIssue() : null)); + + expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true); + expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-2'); + expect(issueRepository.findById).toHaveBeenCalledWith(2, 'user-2'); + }); + + it('hides a file whose issues are all invisible', async () => { + vi.mocked(attachmentRepository.findIssueIdsByFile).mockResolvedValue([1]); + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + expect(await owner.isVisibleThrough('file-1', 'user-3')).toBe(false); + }); +}); diff --git a/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.ts b/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.ts new file mode 100644 index 0000000..5548924 --- /dev/null +++ b/src/modules/issue-attachment/application/services/issue-attachment-file-owner.service.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable, type OnModuleInit } from '@nestjs/common'; +import { + ISSUE_ATTACHMENT_REPOSITORY, + type IIssueAttachmentRepository, +} from '../../domain/repositories/issue-attachment.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 IssueAttachmentFileOwnerService implements FileOwner, OnModuleInit { + readonly name = 'issue'; + + constructor( + @Inject(ISSUE_ATTACHMENT_REPOSITORY) + private readonly attachmentRepository: IIssueAttachmentRepository, + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + private readonly fileOwners: FileOwnerRegistry, + ) {} + + onModuleInit(): void { + this.fileOwners.register(this); + } + + findReferenced(fileIds: string[]): Promise { + return this.attachmentRepository.findReferencedFileIds(fileIds); + } + + /** + * Файл виден, если видна хоть одна задача, к которой он прикреплён. + * Видимость проверяем через IIssueRepository.findById, чтобы правило + * (reporter/assignee) жило в одном месте. Обычно файл прикреплён к одной задаче. + */ + async isVisibleThrough(fileId: string, userId: string): Promise { + const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId); + + for (const issueId of issueIds) { + if (await this.issueRepository.findById(issueId, userId)) { + return true; + } + } + return false; + } +} diff --git a/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.command.ts b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.command.ts new file mode 100644 index 0000000..a503abd --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.command.ts @@ -0,0 +1,7 @@ +export class AttachFileCommand { + constructor( + public readonly issueId: number, + public readonly fileId: string, + public readonly userId: string, + ) {} +} diff --git a/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.spec.ts b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.spec.ts new file mode 100644 index 0000000..63df923 --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.spec.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IIssueAttachmentRepository } from '../../../domain/repositories/issue-attachment.repository.interface.js'; +import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js'; +import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js'; +import type { FileAccessService } from '../../../../file/application/services/file-access.service.js'; +import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js'; +import { AttachFileUseCase } from './attach-file.use-case.js'; +import { AttachFileCommand } from './attach-file.command.js'; +import { buildFileDto, buildIssue, mockAttachmentRepository, mockIssueRepository } from '../test-helpers.js'; + +describe('AttachFileUseCase', () => { + let attachmentRepository: IIssueAttachmentRepository; + let issueRepository: IIssueRepository; + let fileAccess: FileAccessService; + let useCase: AttachFileUseCase; + + beforeEach(() => { + attachmentRepository = mockAttachmentRepository(); + issueRepository = mockIssueRepository(); + fileAccess = { getAttachableFile: vi.fn() } as unknown as FileAccessService; + useCase = new AttachFileUseCase(attachmentRepository, issueRepository, fileAccess); + + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + vi.mocked(fileAccess.getAttachableFile).mockResolvedValue(buildFileDto()); + vi.mocked(attachmentRepository.create).mockImplementation(async (attachment: IssueAttachment) => attachment); + }); + + it('attaches an own file to a visible issue', async () => { + const dto = await useCase.execute(new AttachFileCommand(1, 'file-1', 'user-1')); + + expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-1'); + expect(fileAccess.getAttachableFile).toHaveBeenCalledWith('file-1', 'user-1'); + expect(dto).toEqual({ issueId: 1, file: buildFileDto(), attachedAt: expect.any(Date) }); + }); + + it('throws IssueNotFoundException when the issue is not visible', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new AttachFileCommand(1, 'file-1', 'user-2'))).rejects.toThrow(IssueNotFoundException); + expect(fileAccess.getAttachableFile).not.toHaveBeenCalled(); + expect(attachmentRepository.create).not.toHaveBeenCalled(); + }); + + it.each([new FileNotFoundException('file-1'), new FileForbiddenException('file-1')])( + 'does not attach when the file is not attachable (%s)', + async (error) => { + vi.mocked(fileAccess.getAttachableFile).mockRejectedValue(error); + + await expect(useCase.execute(new AttachFileCommand(1, 'file-1', 'user-1'))).rejects.toThrow(error); + expect(attachmentRepository.create).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.ts b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.ts new file mode 100644 index 0000000..0a4a748 --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/attach-file/attach-file.use-case.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + ISSUE_ATTACHMENT_REPOSITORY, + type IIssueAttachmentRepository, +} from '../../../domain/repositories/issue-attachment.repository.interface.js'; +import { + ISSUE_REPOSITORY, + type IIssueRepository, +} from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { FileAccessService } from '../../../../file/application/services/file-access.service.js'; +import { IssueAttachment } from '../../../domain/entities/issue-attachment.entity.js'; +import { AttachFileCommand } from './attach-file.command.js'; +import { IssueAttachmentDto } from '../../dto/issue-attachment.dto.js'; + +/** + * Прикрепляет к задаче файл, заранее загруженный через POST /files. + * Прикреплять может любой, кому видна задача (как и править её), + * но только собственный файл — см. StoredFile.assertOwnedBy. + */ +@Injectable() +export class AttachFileUseCase { + constructor( + @Inject(ISSUE_ATTACHMENT_REPOSITORY) + private readonly attachmentRepository: IIssueAttachmentRepository, + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + private readonly fileAccess: FileAccessService, + ) {} + + async execute(command: AttachFileCommand): Promise { + if (!(await this.issueRepository.findById(command.issueId, command.userId))) { + throw new IssueNotFoundException(command.issueId); + } + + const file = await this.fileAccess.getAttachableFile(command.fileId, command.userId); + const attachment = await this.attachmentRepository.create( + IssueAttachment.create({ issueId: command.issueId, fileId: command.fileId }), + ); + + return { issueId: attachment.issueId, file, attachedAt: attachment.createdAt }; + } +} diff --git a/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.command.ts b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.command.ts new file mode 100644 index 0000000..808abef --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.command.ts @@ -0,0 +1,7 @@ +export class DetachFileCommand { + constructor( + public readonly issueId: number, + public readonly fileId: string, + public readonly userId: string, + ) {} +} diff --git a/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.spec.ts b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.spec.ts new file mode 100644 index 0000000..0047655 --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IIssueAttachmentRepository } from '../../../domain/repositories/issue-attachment.repository.interface.js'; +import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js'; +import { DetachFileUseCase } from './detach-file.use-case.js'; +import { DetachFileCommand } from './detach-file.command.js'; +import { buildIssue, mockAttachmentRepository, mockIssueRepository } from '../test-helpers.js'; + +describe('DetachFileUseCase', () => { + let attachmentRepository: IIssueAttachmentRepository; + let issueRepository: IIssueRepository; + let useCase: DetachFileUseCase; + + beforeEach(() => { + attachmentRepository = mockAttachmentRepository(); + issueRepository = mockIssueRepository(); + useCase = new DetachFileUseCase(attachmentRepository, issueRepository); + + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + }); + + it('detaches the file from a visible issue', async () => { + vi.mocked(attachmentRepository.delete).mockResolvedValue(true); + + await useCase.execute(new DetachFileCommand(1, 'file-1', 'user-1')); + + expect(attachmentRepository.delete).toHaveBeenCalledWith(1, 'file-1'); + }); + + it('throws IssueAttachmentNotFoundException when the file is not attached', async () => { + vi.mocked(attachmentRepository.delete).mockResolvedValue(false); + + await expect(useCase.execute(new DetachFileCommand(1, 'file-1', 'user-1'))).rejects.toThrow( + IssueAttachmentNotFoundException, + ); + }); + + it('throws IssueNotFoundException when the issue is not visible', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new DetachFileCommand(1, 'file-1', 'user-2'))).rejects.toThrow(IssueNotFoundException); + expect(attachmentRepository.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.ts b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.ts new file mode 100644 index 0000000..b79db82 --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/detach-file/detach-file.use-case.ts @@ -0,0 +1,36 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + ISSUE_ATTACHMENT_REPOSITORY, + type IIssueAttachmentRepository, +} from '../../../domain/repositories/issue-attachment.repository.interface.js'; +import { + ISSUE_REPOSITORY, + type IIssueRepository, +} from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { IssueAttachmentNotFoundException } from '../../../domain/exceptions/issue-attachment-not-found.exception.js'; +import { DetachFileCommand } from './detach-file.command.js'; + +/** + * Открепляет файл от задачи. Сам файл не удаляется: если он больше + * ни к чему не прикреплён, его удалит GC file-модуля. + */ +@Injectable() +export class DetachFileUseCase { + constructor( + @Inject(ISSUE_ATTACHMENT_REPOSITORY) + private readonly attachmentRepository: IIssueAttachmentRepository, + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + ) {} + + async execute(command: DetachFileCommand): Promise { + if (!(await this.issueRepository.findById(command.issueId, command.userId))) { + throw new IssueNotFoundException(command.issueId); + } + + if (!(await this.attachmentRepository.delete(command.issueId, command.fileId))) { + throw new IssueAttachmentNotFoundException(command.issueId, command.fileId); + } + } +} diff --git a/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.query.ts b/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.query.ts new file mode 100644 index 0000000..18ba98e --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.query.ts @@ -0,0 +1,6 @@ +export class ListIssueAttachmentsQuery { + constructor( + public readonly issueId: number, + public readonly userId: string, + ) {} +} diff --git a/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.use-case.spec.ts b/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.use-case.spec.ts new file mode 100644 index 0000000..bec9f3c --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.use-case.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IIssueAttachmentRepository } from '../../../domain/repositories/issue-attachment.repository.interface.js'; +import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { ListIssueAttachmentsUseCase } from './list-issue-attachments.use-case.js'; +import { ListIssueAttachmentsQuery } from './list-issue-attachments.query.js'; +import { + buildAttachment, + buildIssue, + mockAttachmentExpander, + mockAttachmentRepository, + mockIssueRepository, +} from '../test-helpers.js'; + +describe('ListIssueAttachmentsUseCase', () => { + let attachmentRepository: IIssueAttachmentRepository; + let issueRepository: IIssueRepository; + let useCase: ListIssueAttachmentsUseCase; + + beforeEach(() => { + attachmentRepository = mockAttachmentRepository(); + issueRepository = mockIssueRepository(); + useCase = new ListIssueAttachmentsUseCase(attachmentRepository, issueRepository, mockAttachmentExpander()); + }); + + it('lists attachments of a visible issue', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + vi.mocked(attachmentRepository.findByIssue).mockResolvedValue([ + buildAttachment({ fileId: 'a' }), + buildAttachment({ fileId: 'b' }), + ]); + + const list = await useCase.execute(new ListIssueAttachmentsQuery(1, 'user-1')); + + expect(attachmentRepository.findByIssue).toHaveBeenCalledWith(1); + expect(list.map((item) => item.file.id)).toEqual(['a', 'b']); + }); + + it('throws IssueNotFoundException when the issue is not visible', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new ListIssueAttachmentsQuery(1, 'user-2'))).rejects.toThrow(IssueNotFoundException); + expect(attachmentRepository.findByIssue).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.use-case.ts b/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.use-case.ts new file mode 100644 index 0000000..bd85faf --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/list-issue-attachments/list-issue-attachments.use-case.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + ISSUE_ATTACHMENT_REPOSITORY, + type IIssueAttachmentRepository, +} from '../../../domain/repositories/issue-attachment.repository.interface.js'; +import { + ISSUE_REPOSITORY, + type IIssueRepository, +} from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { ListIssueAttachmentsQuery } from './list-issue-attachments.query.js'; +import { IssueAttachmentDto } from '../../dto/issue-attachment.dto.js'; +import { IssueAttachmentExpanderService } from '../../services/issue-attachment-expander.service.js'; + +@Injectable() +export class ListIssueAttachmentsUseCase { + constructor( + @Inject(ISSUE_ATTACHMENT_REPOSITORY) + private readonly attachmentRepository: IIssueAttachmentRepository, + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + private readonly attachmentExpander: IssueAttachmentExpanderService, + ) {} + + async execute(query: ListIssueAttachmentsQuery): Promise { + if (!(await this.issueRepository.findById(query.issueId, query.userId))) { + throw new IssueNotFoundException(query.issueId); + } + + const attachments = await this.attachmentRepository.findByIssue(query.issueId); + return this.attachmentExpander.expandAll(attachments); + } +} diff --git a/src/modules/issue-attachment/application/use-cases/test-helpers.ts b/src/modules/issue-attachment/application/use-cases/test-helpers.ts new file mode 100644 index 0000000..71a4584 --- /dev/null +++ b/src/modules/issue-attachment/application/use-cases/test-helpers.ts @@ -0,0 +1,39 @@ +import { vi } from 'vitest'; +import type { IIssueAttachmentRepository } from '../../domain/repositories/issue-attachment.repository.interface.js'; +import { IssueAttachment } from '../../domain/entities/issue-attachment.entity.js'; +import { FileDto } from '../../../file/application/dto/file.dto.js'; +import { IssueAttachmentExpanderService } from '../services/issue-attachment-expander.service.js'; + +export { buildIssue, mockIssueRepository } from '../../../comment/application/use-cases/test-helpers.js'; + +export const buildAttachment = (overrides: Partial<{ issueId: number; fileId: string }> = {}) => + IssueAttachment.reconstitute({ + issueId: overrides.issueId ?? 1, + fileId: overrides.fileId ?? 'file-1', + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }); + +export const buildFileDto = (id = 'file-1'): FileDto => ({ + id, + fileName: 'photo.png', + mimeType: 'image/png', + size: 10, + uploaderId: 'user-1', + createdAt: new Date('2026-01-01T00:00:00.000Z'), +}); + +export const mockAttachmentRepository = (): IIssueAttachmentRepository => ({ + findByIssue: vi.fn(), + findIssueIdsByFile: vi.fn(), + findReferencedFileIds: vi.fn(), + create: vi.fn(), + delete: vi.fn(), +}); + +/** Экспандер без похода в file-модуль: у каждого вложения файл с его fileId */ +export const mockAttachmentExpander = () => + ({ + expandAll: vi.fn(async (attachments: IssueAttachment[]) => + attachments.map((a) => ({ issueId: a.issueId, file: buildFileDto(a.fileId), attachedAt: a.createdAt })), + ), + }) as unknown as IssueAttachmentExpanderService; diff --git a/src/modules/issue-attachment/domain/entities/issue-attachment.entity.ts b/src/modules/issue-attachment/domain/entities/issue-attachment.entity.ts new file mode 100644 index 0000000..113a5a2 --- /dev/null +++ b/src/modules/issue-attachment/domain/entities/issue-attachment.entity.ts @@ -0,0 +1,35 @@ +interface IssueAttachmentProps { + issueId: number; + fileId: string; + createdAt: Date; +} + +/** + * Вложение задачи — связь задачи с файлом из file-модуля. + * Идентичность — пара (issueId, fileId): один файл прикрепляется к задаче один раз. + */ +export class IssueAttachment { + private constructor(private readonly props: IssueAttachmentProps) {} + + /** Фабричный метод для создания НОВОГО вложения */ + static create(params: { issueId: number; fileId: string }): IssueAttachment { + return new IssueAttachment({ ...params, createdAt: new Date() }); + } + + /** Восстановление сущности из персистентного слоя */ + static reconstitute(props: IssueAttachmentProps): IssueAttachment { + return new IssueAttachment({ ...props }); + } + + get issueId(): number { + return this.props.issueId; + } + + get fileId(): string { + return this.props.fileId; + } + + get createdAt(): Date { + return this.props.createdAt; + } +} diff --git a/src/modules/issue-attachment/domain/exceptions/issue-attachment-not-found.exception.ts b/src/modules/issue-attachment/domain/exceptions/issue-attachment-not-found.exception.ts new file mode 100644 index 0000000..36320d3 --- /dev/null +++ b/src/modules/issue-attachment/domain/exceptions/issue-attachment-not-found.exception.ts @@ -0,0 +1,6 @@ +export class IssueAttachmentNotFoundException extends Error { + constructor(issueId: number, fileId: string) { + super(`Вложение fileId=${fileId} у задачи id=${issueId} не найдено`); + this.name = 'IssueAttachmentNotFoundException'; + } +} diff --git a/src/modules/issue-attachment/domain/repositories/issue-attachment.repository.interface.ts b/src/modules/issue-attachment/domain/repositories/issue-attachment.repository.interface.ts new file mode 100644 index 0000000..300841d --- /dev/null +++ b/src/modules/issue-attachment/domain/repositories/issue-attachment.repository.interface.ts @@ -0,0 +1,21 @@ +import { IssueAttachment } from '../entities/issue-attachment.entity.js'; + +/** DI-токен, т.к. интерфейсы стираются при компиляции TS */ +export const ISSUE_ATTACHMENT_REPOSITORY = Symbol('ISSUE_ATTACHMENT_REPOSITORY'); + +export interface IIssueAttachmentRepository { + /** Вложения задачи от старых к новым */ + findByIssue(issueId: number): Promise; + /** Задачи, к которым прикреплён файл */ + findIssueIdsByFile(fileId: string): Promise; + /** Подмножество fileIds, прикреплённых хотя бы к одной задаче */ + findReferencedFileIds(fileIds: string[]): Promise; + /** + * Идемпотентно: повторное прикрепление возвращает существующее вложение. + * Бросает IssueNotFoundException / FileNotFoundException, если задачу + * или файл удалили между проверкой и вставкой. + */ + create(attachment: IssueAttachment): Promise; + /** false — такого вложения не было */ + delete(issueId: number, fileId: string): Promise; +} diff --git a/src/modules/issue-attachment/infrastructure/persistence/prisma/mappers/issue-attachment.mapper.ts b/src/modules/issue-attachment/infrastructure/persistence/prisma/mappers/issue-attachment.mapper.ts new file mode 100644 index 0000000..1ebcca8 --- /dev/null +++ b/src/modules/issue-attachment/infrastructure/persistence/prisma/mappers/issue-attachment.mapper.ts @@ -0,0 +1,20 @@ +import type { IssueAttachment as PrismaIssueAttachment, Prisma } from '../../../../../../generated/prisma/client.js'; +import { IssueAttachment } from '../../../../domain/entities/issue-attachment.entity.js'; + +export class IssueAttachmentPrismaMapper { + static toDomain(record: PrismaIssueAttachment): IssueAttachment { + return IssueAttachment.reconstitute({ + issueId: record.issueId, + fileId: record.fileId, + createdAt: record.createdAt, + }); + } + + static toCreateInput(attachment: IssueAttachment): Prisma.IssueAttachmentUncheckedCreateInput { + return { + issueId: attachment.issueId, + fileId: attachment.fileId, + createdAt: attachment.createdAt, + }; + } +} diff --git a/src/modules/issue-attachment/infrastructure/persistence/prisma/repositories/issue-attachment.prisma-repository.ts b/src/modules/issue-attachment/infrastructure/persistence/prisma/repositories/issue-attachment.prisma-repository.ts new file mode 100644 index 0000000..86f0227 --- /dev/null +++ b/src/modules/issue-attachment/infrastructure/persistence/prisma/repositories/issue-attachment.prisma-repository.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js'; +import { Prisma } from '../../../../../../generated/prisma/client.js'; +import { IssueAttachment } from '../../../../domain/entities/issue-attachment.entity.js'; +import { IIssueAttachmentRepository } from '../../../../domain/repositories/issue-attachment.repository.interface.js'; +import { IssueAttachmentPrismaMapper } from '../mappers/issue-attachment.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'; + +@Injectable() +export class IssueAttachmentPrismaRepository implements IIssueAttachmentRepository { + constructor(private readonly prisma: PrismaAdapter) {} + + async findByIssue(issueId: number): Promise { + const records = await this.prisma.issueAttachment.findMany({ + where: { issueId }, + orderBy: [{ createdAt: 'asc' }, { fileId: 'asc' }], + }); + return records.map(IssueAttachmentPrismaMapper.toDomain); + } + + async findIssueIdsByFile(fileId: string): Promise { + const records = await this.prisma.issueAttachment.findMany({ + where: { fileId }, + select: { issueId: true }, + }); + return records.map((record) => record.issueId); + } + + async findReferencedFileIds(fileIds: string[]): Promise { + if (fileIds.length === 0) return []; + + const records = await this.prisma.issueAttachment.findMany({ + where: { fileId: { in: fileIds } }, + select: { fileId: true }, + distinct: ['fileId'], + }); + return records.map((record) => record.fileId); + } + + async create(attachment: IssueAttachment): Promise { + try { + // upsert с пустым update — идемпотентное прикрепление без гонки «проверил — вставил» + const record = await this.prisma.issueAttachment.upsert({ + where: { issueId_fileId: { issueId: attachment.issueId, fileId: attachment.fileId } }, + create: IssueAttachmentPrismaMapper.toCreateInput(attachment), + update: {}, + }); + + return IssueAttachmentPrismaMapper.toDomain(record); + } catch (error) { + // Задачу или файл удалили между проверкой доступа и вставкой. + // Какой именно FK нарушен — по имени ограничения в meta + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) { + if (JSON.stringify(error.meta ?? {}).includes('fileId')) { + throw new FileNotFoundException(attachment.fileId); + } + throw new IssueNotFoundException(attachment.issueId); + } + throw error; + } + } + + async delete(issueId: number, fileId: string): Promise { + const { count } = await this.prisma.issueAttachment.deleteMany({ where: { issueId, fileId } }); + return count > 0; + } +} diff --git a/src/modules/issue-attachment/issue-attachment.module.ts b/src/modules/issue-attachment/issue-attachment.module.ts new file mode 100644 index 0000000..25b4113 --- /dev/null +++ b/src/modules/issue-attachment/issue-attachment.module.ts @@ -0,0 +1,33 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js'; +import { IssueModule } from '../issue/issue.module.js'; +import { FileModule } from '../file/file.module.js'; +import { ISSUE_ATTACHMENT_REPOSITORY } from './domain/repositories/issue-attachment.repository.interface.js'; +import { IssueAttachmentPrismaRepository } from './infrastructure/persistence/prisma/repositories/issue-attachment.prisma-repository.js'; +import { IssueAttachmentController } from './presentation/controllers/issue-attachment.controller.js'; +import { IssueAttachmentResolver } from './presentation/resolvers/issue-attachment.resolver.js'; +import { ListIssueAttachmentsUseCase } from './application/use-cases/list-issue-attachments/list-issue-attachments.use-case.js'; +import { AttachFileUseCase } from './application/use-cases/attach-file/attach-file.use-case.js'; +import { DetachFileUseCase } from './application/use-cases/detach-file/detach-file.use-case.js'; +import { IssueAttachmentExpanderService } from './application/services/issue-attachment-expander.service.js'; +import { IssueAttachmentFileOwnerService } from './application/services/issue-attachment-file-owner.service.js'; + +@Module({ + imports: [PrismaModule, IssueModule, FileModule], + controllers: [IssueAttachmentController], + providers: [ + ListIssueAttachmentsUseCase, + AttachFileUseCase, + DetachFileUseCase, + IssueAttachmentExpanderService, + IssueAttachmentFileOwnerService, + IssueAttachmentResolver, + { + // Domain видит только интерфейс IIssueAttachmentRepository, + // конкретную реализацию (Prisma) подставляет DI-контейнер здесь + provide: ISSUE_ATTACHMENT_REPOSITORY, + useClass: IssueAttachmentPrismaRepository, + }, + ], +}) +export class IssueAttachmentModule {} diff --git a/src/modules/issue-attachment/presentation/controllers/issue-attachment.controller.ts b/src/modules/issue-attachment/presentation/controllers/issue-attachment.controller.ts new file mode 100644 index 0000000..52b0e48 --- /dev/null +++ b/src/modules/issue-attachment/presentation/controllers/issue-attachment.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + NotFoundException, + Param, + ParseIntPipe, + Post, +} from '@nestjs/common'; +import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; +import { ListIssueAttachmentsUseCase } from '../../application/use-cases/list-issue-attachments/list-issue-attachments.use-case.js'; +import { AttachFileUseCase } from '../../application/use-cases/attach-file/attach-file.use-case.js'; +import { DetachFileUseCase } from '../../application/use-cases/detach-file/detach-file.use-case.js'; +import { ListIssueAttachmentsQuery } from '../../application/use-cases/list-issue-attachments/list-issue-attachments.query.js'; +import { AttachFileCommand } from '../../application/use-cases/attach-file/attach-file.command.js'; +import { DetachFileCommand } from '../../application/use-cases/detach-file/detach-file.command.js'; +import { AttachFileRequestDto } from '../dto/attach-file.dto.js'; +import { IssueAttachmentNotFoundException } from '../../domain/exceptions/issue-attachment-not-found.exception.js'; +import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js'; +import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js'; + +/** + * Вложения задачи. Сам файл загружается отдельно (POST /files), + * здесь — только привязка к задаче. Скачивание — GET /files/:id/download. + */ +@Controller('issues/:issueId/attachments') +export class IssueAttachmentController { + constructor( + private readonly listIssueAttachmentsUseCase: ListIssueAttachmentsUseCase, + private readonly attachFileUseCase: AttachFileUseCase, + private readonly detachFileUseCase: DetachFileUseCase, + ) {} + + @Get() + list(@Param('issueId', ParseIntPipe) issueId: number, @Session() session: UserSession) { + return this.handleErrors(() => + this.listIssueAttachmentsUseCase.execute(new ListIssueAttachmentsQuery(issueId, session.user.id)), + ); + } + + @Post() + attach( + @Param('issueId', ParseIntPipe) issueId: number, + @Body() dto: AttachFileRequestDto, + @Session() session: UserSession, + ) { + return this.handleErrors(() => + this.attachFileUseCase.execute(new AttachFileCommand(issueId, dto.fileId, session.user.id)), + ); + } + + @Delete(':fileId') + @HttpCode(HttpStatus.NO_CONTENT) + async detach( + @Param('issueId', ParseIntPipe) issueId: number, + @Param('fileId') fileId: string, + @Session() session: UserSession, + ) { + await this.handleErrors(() => + this.detachFileUseCase.execute(new DetachFileCommand(issueId, fileId, session.user.id)), + ); + } + + /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */ + private async handleErrors(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if ( + error instanceof IssueNotFoundException || + error instanceof FileNotFoundException || + error instanceof IssueAttachmentNotFoundException + ) { + throw new NotFoundException(error.message); + } + if (error instanceof FileForbiddenException) { + throw new ForbiddenException(error.message); + } + throw error; + } + } +} diff --git a/src/modules/issue-attachment/presentation/dto/attach-file.dto.ts b/src/modules/issue-attachment/presentation/dto/attach-file.dto.ts new file mode 100644 index 0000000..e09d403 --- /dev/null +++ b/src/modules/issue-attachment/presentation/dto/attach-file.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class AttachFileRequestDto { + /** id файла, загруженного через POST /files */ + @IsString() + @IsNotEmpty() + @MaxLength(64) + fileId: string; +} diff --git a/src/modules/issue-attachment/presentation/graphql/issue-attachment.type.ts b/src/modules/issue-attachment/presentation/graphql/issue-attachment.type.ts new file mode 100644 index 0000000..3ecdf3f --- /dev/null +++ b/src/modules/issue-attachment/presentation/graphql/issue-attachment.type.ts @@ -0,0 +1,14 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; +import { FileType } from '../../../file/presentation/graphql/file.type.js'; + +@ObjectType('IssueAttachment') +export class IssueAttachmentType { + @Field(() => Int) + issueId: number; + + @Field(() => FileType) + file: FileType; + + @Field(() => Date) + attachedAt: Date; +} diff --git a/src/modules/issue-attachment/presentation/resolvers/issue-attachment.resolver.ts b/src/modules/issue-attachment/presentation/resolvers/issue-attachment.resolver.ts new file mode 100644 index 0000000..ce5fbcc --- /dev/null +++ b/src/modules/issue-attachment/presentation/resolvers/issue-attachment.resolver.ts @@ -0,0 +1,23 @@ +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; +import { IssueDetailType } from '../../../issue/presentation/graphql/issue-detail.type.js'; +import { IssueWithUsersDto } from '../../../issue/application/dto/issue-with-users.dto.js'; +import { ListIssueAttachmentsUseCase } from '../../application/use-cases/list-issue-attachments/list-issue-attachments.use-case.js'; +import { ListIssueAttachmentsQuery } from '../../application/use-cases/list-issue-attachments/list-issue-attachments.query.js'; +import { IssueAttachmentDto } from '../../application/dto/issue-attachment.dto.js'; +import { IssueAttachmentType } from '../graphql/issue-attachment.type.js'; + +/** + * Добавляет поле `attachments` к IssueDetail, не трогая issue-модуль: + * issue о вложениях не знает, зависимость направлена только сюда. + * Только на детальном типе — в списке задач это давало бы N+1. + */ +@Resolver(() => IssueDetailType) +export class IssueAttachmentResolver { + constructor(private readonly listIssueAttachmentsUseCase: ListIssueAttachmentsUseCase) {} + + @ResolveField(() => [IssueAttachmentType]) + attachments(@Parent() issue: IssueWithUsersDto, @Session() session: UserSession): Promise { + return this.listIssueAttachmentsUseCase.execute(new ListIssueAttachmentsQuery(issue.id, session.user.id)); + } +}