Add issue attachments
- IssueAttachment link table (issue <-> file) + migration - GET/POST /issues/:issueId/attachments, DELETE .../attachments/:fileId - Only own uploaded files can be attached; attach is idempotent - Registers issues as a file owner: attached files stay visible to issue participants and are not collected by GC - IssueDetail.attachments GraphQL field via @ResolveField, issue module untouched Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f32c6e348d
commit
423afa658d
28 files changed
+812
No files matched your search
@@ -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;
|
||||
|
||||
@@ -95,6 +95,7 @@ model Issue {
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FileDto } from '../../../file/application/dto/file.dto.js';
|
||||
|
||||
export class IssueAttachmentDto {
|
||||
issueId: number;
|
||||
file: FileDto;
|
||||
/** Когда файл прикреплён к задаче (может отличаться от file.createdAt) */
|
||||
attachedAt: Date;
|
||||
}
|
||||
+15
@@ -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') }]);
|
||||
});
|
||||
});
|
||||
+24
@@ -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<IssueAttachmentDto[]> {
|
||||
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 }] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
});
|
||||
});
|
||||
+51
@@ -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<string[]> {
|
||||
return this.attachmentRepository.findReferencedFileIds(fileIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Файл виден, если видна хоть одна задача, к которой он прикреплён.
|
||||
* Видимость проверяем через IIssueRepository.findById, чтобы правило
|
||||
* (reporter/assignee) жило в одном месте. Обычно файл прикреплён к одной задаче.
|
||||
*/
|
||||
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
|
||||
const issueIds = await this.attachmentRepository.findIssueIdsByFile(fileId);
|
||||
|
||||
for (const issueId of issueIds) {
|
||||
if (await this.issueRepository.findById(issueId, userId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class AttachFileCommand {
|
||||
constructor(
|
||||
public readonly issueId: number,
|
||||
public readonly fileId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+55
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
+43
@@ -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<IssueAttachmentDto> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class DetachFileCommand {
|
||||
constructor(
|
||||
public readonly issueId: number,
|
||||
public readonly fileId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+45
@@ -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();
|
||||
});
|
||||
});
|
||||
+36
@@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export class ListIssueAttachmentsQuery {
|
||||
constructor(
|
||||
public readonly issueId: number,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+45
@@ -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();
|
||||
});
|
||||
});
|
||||
+33
@@ -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<IssueAttachmentDto[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class IssueAttachmentNotFoundException extends Error {
|
||||
constructor(issueId: number, fileId: string) {
|
||||
super(`Вложение fileId=${fileId} у задачи id=${issueId} не найдено`);
|
||||
this.name = 'IssueAttachmentNotFoundException';
|
||||
}
|
||||
}
|
||||
+21
@@ -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<IssueAttachment[]>;
|
||||
/** Задачи, к которым прикреплён файл */
|
||||
findIssueIdsByFile(fileId: string): Promise<number[]>;
|
||||
/** Подмножество fileIds, прикреплённых хотя бы к одной задаче */
|
||||
findReferencedFileIds(fileIds: string[]): Promise<string[]>;
|
||||
/**
|
||||
* Идемпотентно: повторное прикрепление возвращает существующее вложение.
|
||||
* Бросает IssueNotFoundException / FileNotFoundException, если задачу
|
||||
* или файл удалили между проверкой и вставкой.
|
||||
*/
|
||||
create(attachment: IssueAttachment): Promise<IssueAttachment>;
|
||||
/** false — такого вложения не было */
|
||||
delete(issueId: number, fileId: string): Promise<boolean>;
|
||||
}
|
||||
+20
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+70
@@ -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<IssueAttachment[]> {
|
||||
const records = await this.prisma.issueAttachment.findMany({
|
||||
where: { issueId },
|
||||
orderBy: [{ createdAt: 'asc' }, { fileId: 'asc' }],
|
||||
});
|
||||
return records.map(IssueAttachmentPrismaMapper.toDomain);
|
||||
}
|
||||
|
||||
async findIssueIdsByFile(fileId: string): Promise<number[]> {
|
||||
const records = await this.prisma.issueAttachment.findMany({
|
||||
where: { fileId },
|
||||
select: { issueId: true },
|
||||
});
|
||||
return records.map((record) => record.issueId);
|
||||
}
|
||||
|
||||
async findReferencedFileIds(fileIds: string[]): Promise<string[]> {
|
||||
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<IssueAttachment> {
|
||||
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<boolean> {
|
||||
const { count } = await this.prisma.issueAttachment.deleteMany({ where: { issueId, fileId } });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class AttachFileRequestDto {
|
||||
/** id файла, загруженного через POST /files */
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
fileId: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<IssueAttachmentDto[]> {
|
||||
return this.listIssueAttachmentsUseCase.execute(new ListIssueAttachmentsQuery(issue.id, session.user.id));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user