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
+828
-16
No files matched your search
+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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user