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