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
+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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user