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:
vadimandClaude Opus 5.5 committed 2026-10-02 09:18:02 +03:00
1 parent f32c6e348d
commit 423afa658d
28 files changed
+828 -16

No files matched your search

@@ -0,0 +1,8 @@
import { FileDto } from '../../../file/application/dto/file.dto.js';
export class IssueAttachmentDto {
issueId: number;
file: FileDto;
/** Когда файл прикреплён к задаче (может отличаться от file.createdAt) */
attachedAt: Date;
}
@@ -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') }]);
});
});
@@ -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 }] : [];
});
}
}
@@ -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);
});
});
@@ -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,
) {}
}
@@ -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();
},
);
});
@@ -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,
) {}
}
@@ -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();
});
});
@@ -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);
}
}
}
@@ -0,0 +1,6 @@
export class ListIssueAttachmentsQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
) {}
}
@@ -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();
});
});
@@ -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';
}
}
@@ -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>;
}
@@ -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,
};
}
}
@@ -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));
}
}