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,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;
}
}
}