Add attachments to comments
- CommentAttachment link table (comment <-> file) + migration - fileIds in create/update comment; update replaces the whole set, synced in one transaction with the comment - CommentDto.attachments; empty for deleted comments - Soft delete drops attachments so GC can collect the files - Max 10 files per comment, ordered by upload time (uuid v7) - Registers comments as a file owner - Rename CommentAuthorExpanderService -> CommentExpanderService (expands authors and files) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
423afa658d
commit
60fb39f98f
28 files changed
+522
-69
No files matched your search
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "comment_attachment" (
|
||||
"commentId" TEXT NOT NULL,
|
||||
"fileId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "comment_attachment_pkey" PRIMARY KEY ("commentId","fileId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "comment_attachment_fileId_idx" ON "comment_attachment"("fileId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comment_attachment" ADD CONSTRAINT "comment_attachment_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "comment_attachment" ADD CONSTRAINT "comment_attachment_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
+28
-11
@@ -196,16 +196,17 @@ model NotificationMessage {
|
||||
/// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL).
|
||||
/// deletedAt — мягкое удаление, editedAt — время последней правки текста.
|
||||
model Comment {
|
||||
id String @id @default(uuid(7))
|
||||
content String
|
||||
issueId Int
|
||||
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||
authorId String?
|
||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
editedAt DateTime? @db.Timestamptz(3)
|
||||
deletedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
id String @id @default(uuid(7))
|
||||
content String
|
||||
issueId Int
|
||||
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||
authorId String?
|
||||
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
|
||||
editedAt DateTime? @db.Timestamptz(3)
|
||||
deletedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||
attachments CommentAttachment[]
|
||||
|
||||
@@index([issueId, createdAt])
|
||||
@@index([authorId])
|
||||
@@ -226,7 +227,8 @@ model File {
|
||||
uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
issueAttachments IssueAttachment[]
|
||||
issueAttachments IssueAttachment[]
|
||||
commentAttachments CommentAttachment[]
|
||||
|
||||
@@unique([storageKey])
|
||||
@@index([uploaderId])
|
||||
@@ -247,3 +249,18 @@ model IssueAttachment {
|
||||
@@index([fileId])
|
||||
@@map("issue_attachment")
|
||||
}
|
||||
|
||||
/// Вложение комментария — связь комментария с файлом. Управляется вместе
|
||||
/// с комментарием (fileIds в create/update); при мягком удалении комментария
|
||||
/// связи снимаются, чтобы файлы забрал GC.
|
||||
model CommentAttachment {
|
||||
commentId String
|
||||
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
fileId String
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
@@id([commentId, fileId])
|
||||
@@index([fileId])
|
||||
@@map("comment_attachment")
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||
import { FileDto } from '../../../file/application/dto/file.dto.js';
|
||||
|
||||
export class CommentDto {
|
||||
id: string;
|
||||
@@ -8,6 +9,8 @@ export class CommentDto {
|
||||
author: UserDto | null;
|
||||
/** null у удалённого комментария — текст наружу не отдаём */
|
||||
content: string | null;
|
||||
/** Пусто у удалённого комментария. Скачивание — GET /files/:id/download */
|
||||
attachments: FileDto[];
|
||||
editedAt: Date | null;
|
||||
deletedAt: Date | null;
|
||||
createdAt: Date;
|
||||
|
||||
@@ -13,12 +13,13 @@ const author: UserDto = {
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const buildComment = (deletedAt: Date | null = null, authorId: string | null = 'user-1') =>
|
||||
const buildComment = (deletedAt: Date | null = null, authorId: string | null = 'user-1', fileIds: string[] = []) =>
|
||||
Comment.reconstitute({
|
||||
id: 'comment-1',
|
||||
content: 'Secret',
|
||||
issueId: 1,
|
||||
authorId,
|
||||
fileIds,
|
||||
editedAt: null,
|
||||
deletedAt,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
@@ -46,4 +47,28 @@ describe('CommentApplicationMapper', () => {
|
||||
expect(dto.authorId).toBeNull();
|
||||
expect(dto.author).toBeNull();
|
||||
});
|
||||
|
||||
it('expands attachments and skips files that no longer exist', () => {
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
fileName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
uploaderId: 'user-1',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
const dto = CommentApplicationMapper.toDto(
|
||||
buildComment(null, 'user-1', ['file-1', 'gone']),
|
||||
new Map(),
|
||||
new Map([[file.id, file]]),
|
||||
);
|
||||
|
||||
expect(dto.attachments).toEqual([file]);
|
||||
});
|
||||
|
||||
it('hides attachments of a deleted comment', () => {
|
||||
const dto = CommentApplicationMapper.toDto(buildComment(new Date(), 'user-1', ['file-1']), new Map(), new Map());
|
||||
|
||||
expect(dto.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||
import { CommentDto } from '../dto/comment.dto.js';
|
||||
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||
import { FileDto } from '../../../file/application/dto/file.dto.js';
|
||||
|
||||
export class CommentApplicationMapper {
|
||||
static toDto(comment: Comment, usersById: ReadonlyMap<string, UserDto>): CommentDto {
|
||||
static toDto(
|
||||
comment: Comment,
|
||||
usersById: ReadonlyMap<string, UserDto>,
|
||||
filesById: ReadonlyMap<string, FileDto> = new Map(),
|
||||
): CommentDto {
|
||||
return {
|
||||
id: comment.id as string,
|
||||
issueId: comment.issueId,
|
||||
authorId: comment.authorId,
|
||||
author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null,
|
||||
content: comment.isDeleted ? null : comment.content,
|
||||
// Файл могли удалить между запросами — такое вложение просто не показываем
|
||||
attachments: comment.isDeleted
|
||||
? []
|
||||
: comment.fileIds.flatMap((fileId) => {
|
||||
const file = filesById.get(fileId);
|
||||
return file ? [file] : [];
|
||||
}),
|
||||
editedAt: comment.editedAt,
|
||||
deletedAt: comment.deletedAt,
|
||||
createdAt: comment.createdAt,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ICommentRepository } from '../../domain/repositories/comment.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 { CommentAttachmentFileOwnerService } from './comment-attachment-file-owner.service.js';
|
||||
import { buildIssue, mockCommentRepository, mockIssueRepository } from '../use-cases/test-helpers.js';
|
||||
|
||||
describe('CommentAttachmentFileOwnerService', () => {
|
||||
let commentRepository: ICommentRepository;
|
||||
let issueRepository: IIssueRepository;
|
||||
let registry: FileOwnerRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
commentRepository = mockCommentRepository();
|
||||
issueRepository = mockIssueRepository();
|
||||
registry = new FileOwnerRegistry();
|
||||
new CommentAttachmentFileOwnerService(commentRepository, issueRepository, registry).onModuleInit();
|
||||
});
|
||||
|
||||
it('registers itself, so GC keeps files attached to comments', async () => {
|
||||
vi.mocked(commentRepository.findReferencedFileIds).mockResolvedValue(['a']);
|
||||
|
||||
expect(await registry.findReferenced(['a', 'b'])).toEqual(new Set(['a']));
|
||||
});
|
||||
|
||||
it('makes a file visible to users who see the issue of its comment', async () => {
|
||||
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-2')).toBe(true);
|
||||
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-2');
|
||||
});
|
||||
|
||||
it('hides a file when the issue is not visible', async () => {
|
||||
vi.mocked(commentRepository.findIssueIdsByAttachedFile).mockResolvedValue([1]);
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||
|
||||
expect(await registry.isVisibleThroughAnyOwner('file-1', 'user-3')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
|
||||
import { COMMENT_REPOSITORY, type ICommentRepository } from '../../domain/repositories/comment.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 CommentAttachmentFileOwnerService implements FileOwner, OnModuleInit {
|
||||
readonly name = 'comment';
|
||||
|
||||
constructor(
|
||||
@Inject(COMMENT_REPOSITORY)
|
||||
private readonly commentRepository: ICommentRepository,
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly fileOwners: FileOwnerRegistry,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.fileOwners.register(this);
|
||||
}
|
||||
|
||||
findReferenced(fileIds: string[]): Promise<string[]> {
|
||||
return this.commentRepository.findReferencedFileIds(fileIds);
|
||||
}
|
||||
|
||||
/** Файл виден, если видна задача хоть одного неудалённого комментария с ним */
|
||||
async isVisibleThrough(fileId: string, userId: string): Promise<boolean> {
|
||||
const issueIds = await this.commentRepository.findIssueIdsByAttachedFile(fileId);
|
||||
|
||||
for (const issueId of issueIds) {
|
||||
if (await this.issueRepository.findById(issueId, userId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+17
-6
@@ -4,11 +4,16 @@ import { CommentDto } from '../dto/comment.dto.js';
|
||||
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.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';
|
||||
|
||||
/** Раскрывает автора комментария (или списка комментариев) в полноценного пользователя */
|
||||
/** Раскрывает автора и вложения комментария (или списка комментариев) */
|
||||
@Injectable()
|
||||
export class CommentAuthorExpanderService {
|
||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
||||
export class CommentExpanderService {
|
||||
constructor(
|
||||
private readonly getUsersUseCase: GetUsersUseCase,
|
||||
private readonly getFilesUseCase: GetFilesUseCase,
|
||||
) {}
|
||||
|
||||
async expand(comment: Comment): Promise<CommentDto> {
|
||||
const [dto] = await this.expandAll([comment]);
|
||||
@@ -19,9 +24,15 @@ export class CommentAuthorExpanderService {
|
||||
if (comments.length === 0) return [];
|
||||
|
||||
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))];
|
||||
const users = userIds.length > 0 ? await this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [];
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
const fileIds = comments.flatMap((comment) => comment.fileIds);
|
||||
|
||||
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById));
|
||||
const [users, files] = await Promise.all([
|
||||
userIds.length > 0 ? this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : [],
|
||||
fileIds.length > 0 ? this.getFilesUseCase.execute(new GetFilesQuery(fileIds)) : [],
|
||||
]);
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
const filesById = new Map(files.map((file) => [file.id, file]));
|
||||
|
||||
return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById, filesById));
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,7 @@ export class CreateCommentCommand {
|
||||
public readonly issueId: number,
|
||||
public readonly authorId: string,
|
||||
public readonly content: string,
|
||||
/** Файлы, заранее загруженные автором через POST /files */
|
||||
public readonly fileIds: string[] = [],
|
||||
) {}
|
||||
}
|
||||
+33
-2
@@ -6,7 +6,14 @@ import { Comment } from '../../../domain/entities/comment.entity.js';
|
||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||
import { CreateCommentUseCase } from './create-comment.use-case.js';
|
||||
import { CreateCommentCommand } from './create-comment.command.js';
|
||||
import { buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js';
|
||||
import { FileForbiddenException } from '../../../../file/domain/exceptions/file-forbidden.exception.js';
|
||||
import {
|
||||
buildIssue,
|
||||
mockCommentExpander,
|
||||
mockCommentRepository,
|
||||
mockFileAccess,
|
||||
mockIssueRepository,
|
||||
} from '../test-helpers.js';
|
||||
|
||||
describe('CreateCommentUseCase', () => {
|
||||
let commentRepository: ICommentRepository;
|
||||
@@ -18,7 +25,13 @@ describe('CreateCommentUseCase', () => {
|
||||
commentRepository = mockCommentRepository();
|
||||
issueRepository = mockIssueRepository();
|
||||
commentEventsService = new CommentEventsService();
|
||||
useCase = new CreateCommentUseCase(commentRepository, issueRepository, mockAuthorExpander(), commentEventsService);
|
||||
useCase = new CreateCommentUseCase(
|
||||
commentRepository,
|
||||
issueRepository,
|
||||
mockCommentExpander(),
|
||||
commentEventsService,
|
||||
mockFileAccess({ foreign: new FileForbiddenException('foreign') }),
|
||||
);
|
||||
|
||||
vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) =>
|
||||
Comment.reconstitute({
|
||||
@@ -26,6 +39,7 @@ describe('CreateCommentUseCase', () => {
|
||||
content: comment.content,
|
||||
issueId: comment.issueId,
|
||||
authorId: comment.authorId,
|
||||
fileIds: [...comment.fileIds],
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: comment.createdAt,
|
||||
@@ -54,4 +68,21 @@ describe('CreateCommentUseCase', () => {
|
||||
);
|
||||
expect(commentRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves own files as attachments', async () => {
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello', ['b', 'a', 'b']));
|
||||
|
||||
expect(vi.mocked(commentRepository.create).mock.calls[0]![0].fileIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('does not create the comment when a file is not attachable', async () => {
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await expect(useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello', ['a', 'foreign']))).rejects.toThrow(
|
||||
FileForbiddenException,
|
||||
);
|
||||
expect(commentRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+11
-3
@@ -11,8 +11,9 @@ import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issu
|
||||
import { Comment } from '../../../domain/entities/comment.entity.js';
|
||||
import { CreateCommentCommand } from './create-comment.command.js';
|
||||
import { CommentDto } from '../../dto/comment.dto.js';
|
||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
||||
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateCommentUseCase {
|
||||
@@ -21,8 +22,9 @@ export class CreateCommentUseCase {
|
||||
private readonly commentRepository: ICommentRepository,
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
||||
private readonly commentExpander: CommentExpanderService,
|
||||
private readonly commentEventsService: CommentEventsService,
|
||||
private readonly fileAccess: FileAccessService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateCommentCommand): Promise<CommentDto> {
|
||||
@@ -36,10 +38,16 @@ export class CreateCommentUseCase {
|
||||
issueId: command.issueId,
|
||||
authorId: command.authorId,
|
||||
content: command.content,
|
||||
fileIds: command.fileIds,
|
||||
});
|
||||
|
||||
// Прикреплять можно только свои файлы (см. FileAccessService.getAttachableFile)
|
||||
for (const fileId of comment.fileIds) {
|
||||
await this.fileAccess.getAttachableFile(fileId, command.authorId);
|
||||
}
|
||||
|
||||
const saved = await this.commentRepository.create(comment);
|
||||
const dto = await this.commentAuthorExpander.expand(saved);
|
||||
const dto = await this.commentExpander.expand(saved);
|
||||
|
||||
this.commentEventsService.emit('comment.created', dto);
|
||||
|
||||
|
||||
+12
-2
@@ -11,7 +11,7 @@ import { DeleteCommentCommand } from './delete-comment.command.js';
|
||||
import {
|
||||
buildComment,
|
||||
buildIssue,
|
||||
mockAuthorExpander,
|
||||
mockCommentExpander,
|
||||
mockCommentRepository,
|
||||
mockIssueRepository,
|
||||
} from '../test-helpers.js';
|
||||
@@ -29,7 +29,7 @@ describe('DeleteCommentUseCase', () => {
|
||||
useCase = new DeleteCommentUseCase(
|
||||
commentRepository,
|
||||
new CommentAccessService(commentRepository, issueRepository),
|
||||
mockAuthorExpander(),
|
||||
mockCommentExpander(),
|
||||
commentEventsService,
|
||||
);
|
||||
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||
@@ -49,6 +49,16 @@ describe('DeleteCommentUseCase', () => {
|
||||
expect(received[0]!.comment.content).toBeNull();
|
||||
});
|
||||
|
||||
it('drops attachments so GC can collect the files', async () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['file-1']);
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(comment);
|
||||
|
||||
await useCase.execute(new DeleteCommentCommand('comment-1', 'user-1'));
|
||||
|
||||
expect(vi.mocked(commentRepository.update).mock.calls[0]![0].fileIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('throws CommentForbiddenException for a non-author', async () => {
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
type ICommentRepository,
|
||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import { DeleteCommentCommand } from './delete-comment.command.js';
|
||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
||||
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||
import { CommentAccessService } from '../../services/comment-access.service.js';
|
||||
|
||||
@@ -15,7 +15,7 @@ export class DeleteCommentUseCase {
|
||||
@Inject(COMMENT_REPOSITORY)
|
||||
private readonly commentRepository: ICommentRepository,
|
||||
private readonly commentAccess: CommentAccessService,
|
||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
||||
private readonly commentExpander: CommentExpanderService,
|
||||
private readonly commentEventsService: CommentEventsService,
|
||||
) {}
|
||||
|
||||
@@ -25,6 +25,6 @@ export class DeleteCommentUseCase {
|
||||
comment.delete(command.userId);
|
||||
await this.commentRepository.update(comment);
|
||||
|
||||
this.commentEventsService.emit('comment.deleted', await this.commentAuthorExpander.expand(comment));
|
||||
this.commentEventsService.emit('comment.deleted', await this.commentExpander.expand(comment));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { ListCommentsQuery } from './list-comments.query.js';
|
||||
import {
|
||||
buildComment,
|
||||
buildIssue,
|
||||
mockAuthorExpander,
|
||||
mockCommentExpander,
|
||||
mockCommentRepository,
|
||||
mockIssueRepository,
|
||||
} from '../test-helpers.js';
|
||||
@@ -20,7 +20,7 @@ describe('ListCommentsUseCase', () => {
|
||||
beforeEach(() => {
|
||||
commentRepository = mockCommentRepository();
|
||||
issueRepository = mockIssueRepository();
|
||||
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockAuthorExpander());
|
||||
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockCommentExpander());
|
||||
});
|
||||
|
||||
it('forwards pagination params and returns the next cursor', async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||
import { ListCommentsQuery } from './list-comments.query.js';
|
||||
import { CommentDto } from '../../dto/comment.dto.js';
|
||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
||||
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class ListCommentsUseCase {
|
||||
@@ -19,7 +19,7 @@ export class ListCommentsUseCase {
|
||||
private readonly commentRepository: ICommentRepository,
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
||||
private readonly commentExpander: CommentExpanderService,
|
||||
) {}
|
||||
|
||||
async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> {
|
||||
@@ -34,6 +34,6 @@ export class ListCommentsUseCase {
|
||||
cursor: query.cursor,
|
||||
});
|
||||
|
||||
return { list: await this.commentAuthorExpander.expandAll(list), nextCursor };
|
||||
return { list: await this.commentExpander.expandAll(list), nextCursor };
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import type { ICommentRepository } from '../../domain/repositories/comment.repos
|
||||
import type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
|
||||
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||
import { Issue } from '../../../issue/domain/entities/issue.entity.js';
|
||||
import { CommentAuthorExpanderService } from '../services/comment-author-expander.service.js';
|
||||
import { CommentExpanderService } from '../services/comment-expander.service.js';
|
||||
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
|
||||
import type { FileAccessService } from '../../../file/application/services/file-access.service.js';
|
||||
|
||||
export const buildComment = (
|
||||
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
|
||||
@@ -41,6 +42,8 @@ export const mockCommentRepository = (): ICommentRepository => ({
|
||||
findByIssue: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findIssueIdsByAttachedFile: vi.fn(),
|
||||
findReferencedFileIds: vi.fn(),
|
||||
});
|
||||
|
||||
export const mockIssueRepository = (): IIssueRepository => ({
|
||||
@@ -53,11 +56,20 @@ export const mockIssueRepository = (): IIssueRepository => ({
|
||||
delete: vi.fn(),
|
||||
});
|
||||
|
||||
/** Экспандер без похода за пользователями: author всегда null */
|
||||
export const mockAuthorExpander = () => {
|
||||
/** Все файлы прикрепляемы, кроме перечисленных — для них бросается заданная ошибка */
|
||||
export const mockFileAccess = (rejections: Record<string, Error> = {}) =>
|
||||
({
|
||||
getAttachableFile: vi.fn(async (fileId: string) => {
|
||||
if (rejections[fileId]) throw rejections[fileId];
|
||||
return { id: fileId };
|
||||
}),
|
||||
}) as unknown as FileAccessService;
|
||||
|
||||
/** Экспандер без похода за пользователями и файлами: author всегда null, attachments пустые */
|
||||
export const mockCommentExpander = () => {
|
||||
const toDto = (comment: Comment) => CommentApplicationMapper.toDto(comment, new Map());
|
||||
return {
|
||||
expand: vi.fn(async (comment: Comment) => toDto(comment)),
|
||||
expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)),
|
||||
} as unknown as CommentAuthorExpanderService;
|
||||
} as unknown as CommentExpanderService;
|
||||
};
|
||||
@@ -3,5 +3,7 @@ export class UpdateCommentCommand {
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
public readonly content: string,
|
||||
/** Новый набор вложений целиком; undefined — не менять */
|
||||
public readonly fileIds?: string[],
|
||||
) {}
|
||||
}
|
||||
+29
-2
@@ -10,10 +10,13 @@ import { UpdateCommentCommand } from './update-comment.command.js';
|
||||
import {
|
||||
buildComment,
|
||||
buildIssue,
|
||||
mockAuthorExpander,
|
||||
mockCommentExpander,
|
||||
mockCommentRepository,
|
||||
mockFileAccess,
|
||||
mockIssueRepository,
|
||||
} from '../test-helpers.js';
|
||||
import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js';
|
||||
import type { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||
|
||||
describe('UpdateCommentUseCase', () => {
|
||||
let commentRepository: ICommentRepository;
|
||||
@@ -21,16 +24,19 @@ describe('UpdateCommentUseCase', () => {
|
||||
let commentEventsService: CommentEventsService;
|
||||
let useCase: UpdateCommentUseCase;
|
||||
let received: unknown[];
|
||||
let fileAccess: FileAccessService;
|
||||
|
||||
beforeEach(() => {
|
||||
commentRepository = mockCommentRepository();
|
||||
issueRepository = mockIssueRepository();
|
||||
commentEventsService = new CommentEventsService();
|
||||
fileAccess = mockFileAccess({ gone: new FileNotFoundException('gone') });
|
||||
useCase = new UpdateCommentUseCase(
|
||||
commentRepository,
|
||||
new CommentAccessService(commentRepository, issueRepository),
|
||||
mockAuthorExpander(),
|
||||
mockCommentExpander(),
|
||||
commentEventsService,
|
||||
fileAccess,
|
||||
);
|
||||
received = [];
|
||||
commentEventsService.onEvents().subscribe((event) => received.push(event));
|
||||
@@ -82,4 +88,25 @@ describe('UpdateCommentUseCase', () => {
|
||||
CommentNotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces attachments, checking only newly added files', async () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['kept', 'removed']);
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(comment);
|
||||
|
||||
await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['kept', 'new']));
|
||||
|
||||
expect(fileAccess.getAttachableFile).toHaveBeenCalledExactlyOnceWith('new', 'user-1');
|
||||
expect(vi.mocked(commentRepository.update).mock.calls[0]![0].fileIds).toEqual(['kept', 'new']);
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not save when an added file is gone', async () => {
|
||||
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
||||
|
||||
await expect(
|
||||
useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['gone'])),
|
||||
).rejects.toThrow(FileNotFoundException);
|
||||
expect(commentRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+15
-4
@@ -5,9 +5,10 @@ import {
|
||||
} from '../../../domain/repositories/comment.repository.interface.js';
|
||||
import { UpdateCommentCommand } from './update-comment.command.js';
|
||||
import { CommentDto } from '../../dto/comment.dto.js';
|
||||
import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js';
|
||||
import { CommentExpanderService } from '../../services/comment-expander.service.js';
|
||||
import { CommentEventsService } from '../../services/comment-events.service.js';
|
||||
import { CommentAccessService } from '../../services/comment-access.service.js';
|
||||
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateCommentUseCase {
|
||||
@@ -15,19 +16,29 @@ export class UpdateCommentUseCase {
|
||||
@Inject(COMMENT_REPOSITORY)
|
||||
private readonly commentRepository: ICommentRepository,
|
||||
private readonly commentAccess: CommentAccessService,
|
||||
private readonly commentAuthorExpander: CommentAuthorExpanderService,
|
||||
private readonly commentExpander: CommentExpanderService,
|
||||
private readonly commentEventsService: CommentEventsService,
|
||||
private readonly fileAccess: FileAccessService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateCommentCommand): Promise<CommentDto> {
|
||||
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId);
|
||||
const attachedBefore = new Set(comment.fileIds);
|
||||
|
||||
const changed = comment.edit(command.content, command.userId, command.fileIds);
|
||||
|
||||
// Проверяем только добавленные файлы: уже прикреплённые проверены при прикреплении
|
||||
for (const fileId of comment.fileIds) {
|
||||
if (!attachedBefore.has(fileId)) {
|
||||
await this.fileAccess.getAttachableFile(fileId, command.userId);
|
||||
}
|
||||
}
|
||||
|
||||
const changed = comment.edit(command.content, command.userId);
|
||||
if (changed) {
|
||||
await this.commentRepository.update(comment);
|
||||
}
|
||||
|
||||
const dto = await this.commentAuthorExpander.expand(comment);
|
||||
const dto = await this.commentExpander.expand(comment);
|
||||
|
||||
if (changed) {
|
||||
this.commentEventsService.emit('comment.updated', dto);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||
import { IssueModule } from '../issue/issue.module.js';
|
||||
import { UserModule } from '../user/user.module.js';
|
||||
import { FileModule } from '../file/file.module.js';
|
||||
import { COMMENT_REPOSITORY } from './domain/repositories/comment.repository.interface.js';
|
||||
import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js';
|
||||
import { CommentController } from './presentation/controllers/comment.controller.js';
|
||||
@@ -11,11 +12,12 @@ import { UpdateCommentUseCase } from './application/use-cases/update-comment/upd
|
||||
import { DeleteCommentUseCase } from './application/use-cases/delete-comment/delete-comment.use-case.js';
|
||||
import { WatchIssueCommentsUseCase } from './application/use-cases/watch-issue-comments/watch-issue-comments.use-case.js';
|
||||
import { CommentAccessService } from './application/services/comment-access.service.js';
|
||||
import { CommentAuthorExpanderService } from './application/services/comment-author-expander.service.js';
|
||||
import { CommentExpanderService } from './application/services/comment-expander.service.js';
|
||||
import { CommentEventsService } from './application/services/comment-events.service.js';
|
||||
import { CommentAttachmentFileOwnerService } from './application/services/comment-attachment-file-owner.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, IssueModule, UserModule],
|
||||
imports: [PrismaModule, IssueModule, UserModule, FileModule],
|
||||
controllers: [CommentController],
|
||||
providers: [
|
||||
CreateCommentUseCase,
|
||||
@@ -24,8 +26,9 @@ import { CommentEventsService } from './application/services/comment-events.serv
|
||||
DeleteCommentUseCase,
|
||||
WatchIssueCommentsUseCase,
|
||||
CommentAccessService,
|
||||
CommentAuthorExpanderService,
|
||||
CommentExpanderService,
|
||||
CommentEventsService,
|
||||
CommentAttachmentFileOwnerService,
|
||||
{
|
||||
// Domain видит только интерфейс ICommentRepository,
|
||||
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||
|
||||
@@ -73,4 +73,50 @@ describe('Comment', () => {
|
||||
expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachments', () => {
|
||||
it('creates a comment with deduplicated files', () => {
|
||||
const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds: ['a', 'b', 'a'] });
|
||||
|
||||
expect(comment.fileIds).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('limits the number of files', () => {
|
||||
const fileIds = Array.from({ length: Comment.MAX_ATTACHMENTS + 1 }, (_, i) => `f${i}`);
|
||||
|
||||
expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds })).toThrow();
|
||||
});
|
||||
|
||||
it('treats a changed file set as an edit, even with the same text', () => {
|
||||
const comment = buildComment();
|
||||
|
||||
expect(comment.edit('Original', 'user-1', ['a'])).toBe(true);
|
||||
expect(comment.fileIds).toEqual(['a']);
|
||||
expect(comment.editedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('keeps files when fileIds is not passed', () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['a']);
|
||||
|
||||
expect(comment.edit('Changed', 'user-1')).toBe(true);
|
||||
expect(comment.fileIds).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('reports no change for the same text and files', () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['a', 'b']);
|
||||
|
||||
expect(comment.edit('Original', 'user-1', ['a', 'b'])).toBe(false);
|
||||
});
|
||||
|
||||
it('drops files on delete so GC can collect them', () => {
|
||||
const comment = buildComment();
|
||||
comment.edit('Original', 'user-1', ['a']);
|
||||
|
||||
comment.delete('user-1');
|
||||
|
||||
expect(comment.fileIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@ interface CommentProps {
|
||||
issueId: number;
|
||||
/** null — автор удалил аккаунт */
|
||||
authorId: string | null;
|
||||
/** Прикреплённые файлы (file-модуль), по времени загрузки */
|
||||
fileIds: string[];
|
||||
editedAt: Date | null;
|
||||
deletedAt: Date | null;
|
||||
createdAt: Date;
|
||||
@@ -17,14 +19,19 @@ interface CommentProps {
|
||||
* Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt),
|
||||
* чтобы в ленте можно было показать «комментарий удалён».
|
||||
*
|
||||
* Вложения — часть комментария: задаются при создании и правке,
|
||||
* снимаются при удалении (тогда осиротевшие файлы удалит GC file-модуля).
|
||||
*
|
||||
* `id` (uuid v7) генерируется базой, поэтому у нового комментария
|
||||
* он равен null до сохранения в репозитории.
|
||||
*/
|
||||
export class Comment {
|
||||
static readonly MAX_ATTACHMENTS = 10;
|
||||
|
||||
private constructor(private readonly props: CommentProps) {}
|
||||
|
||||
/** Фабричный метод для создания НОВОГО комментария */
|
||||
static create(params: { issueId: number; authorId: string; content: string }): Comment {
|
||||
static create(params: { issueId: number; authorId: string; content: string; fileIds?: string[] }): Comment {
|
||||
const now = new Date();
|
||||
|
||||
return new Comment({
|
||||
@@ -32,6 +39,7 @@ export class Comment {
|
||||
content: CommentContent.create(params.content),
|
||||
issueId: params.issueId,
|
||||
authorId: params.authorId,
|
||||
fileIds: Comment.normalizeFileIds(params.fileIds ?? []),
|
||||
editedAt: null,
|
||||
deletedAt: null,
|
||||
createdAt: now,
|
||||
@@ -45,6 +53,7 @@ export class Comment {
|
||||
content: string;
|
||||
issueId: number;
|
||||
authorId: string | null;
|
||||
fileIds?: string[];
|
||||
editedAt: Date | null;
|
||||
deletedAt: Date | null;
|
||||
createdAt: Date;
|
||||
@@ -53,6 +62,7 @@ export class Comment {
|
||||
return new Comment({
|
||||
...props,
|
||||
content: CommentContent.create(props.content),
|
||||
fileIds: [...(props.fileIds ?? [])],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +82,10 @@ export class Comment {
|
||||
return this.props.authorId;
|
||||
}
|
||||
|
||||
get fileIds(): readonly string[] {
|
||||
return this.props.fileIds;
|
||||
}
|
||||
|
||||
get editedAt(): Date | null {
|
||||
return this.props.editedAt;
|
||||
}
|
||||
@@ -92,17 +106,27 @@ export class Comment {
|
||||
return this.props.updatedAt;
|
||||
}
|
||||
|
||||
/** Меняет текст. Возвращает false, если текст не изменился */
|
||||
edit(content: string, userId: string): boolean {
|
||||
/**
|
||||
* Меняет текст и, если передан fileIds, — набор вложений (целиком).
|
||||
* Возвращает false, если ничего не изменилось.
|
||||
*/
|
||||
edit(content: string, userId: string, fileIds?: string[]): boolean {
|
||||
this.assertCanModify(userId);
|
||||
|
||||
const next = CommentContent.create(content);
|
||||
if (next.equals(this.props.content)) {
|
||||
const nextContent = CommentContent.create(content);
|
||||
const nextFileIds = fileIds === undefined ? this.props.fileIds : Comment.normalizeFileIds(fileIds);
|
||||
|
||||
const contentChanged = !nextContent.equals(this.props.content);
|
||||
const filesChanged =
|
||||
nextFileIds.length !== this.props.fileIds.length || nextFileIds.some((id, i) => id !== this.props.fileIds[i]);
|
||||
|
||||
if (!contentChanged && !filesChanged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
this.props.content = next;
|
||||
this.props.content = nextContent;
|
||||
this.props.fileIds = nextFileIds;
|
||||
this.props.editedAt = now;
|
||||
this.props.updatedAt = now;
|
||||
return true;
|
||||
@@ -112,10 +136,24 @@ export class Comment {
|
||||
this.assertCanModify(userId);
|
||||
|
||||
const now = new Date();
|
||||
this.props.fileIds = [];
|
||||
this.props.deletedAt = now;
|
||||
this.props.updatedAt = now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Без дублей и в порядке загрузки: id файлов — uuid v7, их строковый порядок
|
||||
* совпадает с временем создания. Так порядок одинаков в ответе на create
|
||||
* и при чтении из БД, а сравнение наборов в edit не зависит от порядка у клиента.
|
||||
*/
|
||||
private static normalizeFileIds(fileIds: string[]): string[] {
|
||||
const unique = [...new Set(fileIds)].sort();
|
||||
if (unique.length > Comment.MAX_ATTACHMENTS) {
|
||||
throw new Error(`К комментарию можно прикрепить не больше ${Comment.MAX_ATTACHMENTS} файлов`);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
/** Править и удалять комментарий может только его автор, и только пока он не удалён */
|
||||
private assertCanModify(userId: string): void {
|
||||
if (this.isDeleted) {
|
||||
|
||||
@@ -15,6 +15,14 @@ export interface ICommentRepository {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}): Promise<{ list: Comment[]; nextCursor: string | null }>;
|
||||
/**
|
||||
* Создание и обновление сохраняют и вложения (fileIds) — атомарно с комментарием.
|
||||
* FileNotFoundException, если файл удалили между проверкой и записью.
|
||||
*/
|
||||
create(comment: Comment): Promise<Comment>;
|
||||
update(comment: Comment): Promise<void>;
|
||||
/** Задачи неудалённых комментариев, к которым прикреплён файл */
|
||||
findIssueIdsByAttachedFile(fileId: string): Promise<number[]>;
|
||||
/** Подмножество fileIds, прикреплённых хотя бы к одному комментарию */
|
||||
findReferencedFileIds(fileIds: string[]): Promise<string[]>;
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js';
|
||||
import { Comment } from '../../../../domain/entities/comment.entity.js';
|
||||
|
||||
/** Комментарий вместе с id прикреплённых файлов (см. COMMENT_INCLUDE в репозитории) */
|
||||
export type PrismaCommentWithAttachments = PrismaComment & { attachments: { fileId: string }[] };
|
||||
|
||||
export class CommentPrismaMapper {
|
||||
static toDomain(record: PrismaComment): Comment {
|
||||
static toDomain(record: PrismaCommentWithAttachments): Comment {
|
||||
return Comment.reconstitute({
|
||||
id: record.id,
|
||||
content: record.content,
|
||||
issueId: record.issueId,
|
||||
authorId: record.authorId,
|
||||
fileIds: record.attachments.map((attachment) => attachment.fileId),
|
||||
editedAt: record.editedAt,
|
||||
deletedAt: record.deletedAt,
|
||||
createdAt: record.createdAt,
|
||||
@@ -22,6 +26,7 @@ export class CommentPrismaMapper {
|
||||
authorId: comment.authorId,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
attachments: { create: comment.fileIds.map((fileId) => ({ fileId })) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+65
-9
@@ -5,15 +5,21 @@ import { Comment } from '../../../../domain/entities/comment.entity.js';
|
||||
import { ICommentRepository } from '../../../../domain/repositories/comment.repository.interface.js';
|
||||
import { CommentPrismaMapper } from '../mappers/comment.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';
|
||||
|
||||
/** Вложения подгружаются вместе с комментарием, по времени загрузки файла (uuid v7) */
|
||||
const COMMENT_INCLUDE = {
|
||||
attachments: { select: { fileId: true }, orderBy: { fileId: 'asc' } },
|
||||
} satisfies Prisma.CommentInclude;
|
||||
|
||||
@Injectable()
|
||||
export class CommentPrismaRepository implements ICommentRepository {
|
||||
constructor(private readonly prisma: PrismaAdapter) {}
|
||||
|
||||
async findById(id: string): Promise<Comment | null> {
|
||||
const record = await this.prisma.comment.findUnique({ where: { id } });
|
||||
const record = await this.prisma.comment.findUnique({ where: { id }, include: COMMENT_INCLUDE });
|
||||
return record ? CommentPrismaMapper.toDomain(record) : null;
|
||||
}
|
||||
|
||||
@@ -28,6 +34,7 @@ export class CommentPrismaRepository implements ICommentRepository {
|
||||
// id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt.
|
||||
const records = await this.prisma.comment.findMany({
|
||||
where: { issueId: params.issueId },
|
||||
include: COMMENT_INCLUDE,
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
take: limit + 1,
|
||||
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
|
||||
@@ -44,24 +51,73 @@ export class CommentPrismaRepository implements ICommentRepository {
|
||||
|
||||
async create(comment: Comment): Promise<Comment> {
|
||||
try {
|
||||
// Вложения создаются вложенной записью — одним атомарным запросом
|
||||
const record = await this.prisma.comment.create({
|
||||
data: CommentPrismaMapper.toCreateInput(comment),
|
||||
include: COMMENT_INCLUDE,
|
||||
});
|
||||
|
||||
return CommentPrismaMapper.toDomain(record);
|
||||
} catch (error) {
|
||||
// Задачу удалили между проверкой доступа и вставкой
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) {
|
||||
throw new IssueNotFoundException(comment.issueId);
|
||||
}
|
||||
throw error;
|
||||
// Задачу или файл удалили между проверкой доступа и вставкой
|
||||
throw this.mapForeignKeyError(error, comment) ?? error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(comment: Comment): Promise<void> {
|
||||
await this.prisma.comment.update({
|
||||
where: { id: comment.id as string },
|
||||
data: CommentPrismaMapper.toUpdateInput(comment),
|
||||
const id = comment.id as string;
|
||||
const fileIds = [...comment.fileIds];
|
||||
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.comment.update({
|
||||
where: { id },
|
||||
data: CommentPrismaMapper.toUpdateInput(comment),
|
||||
});
|
||||
|
||||
// Синхронизируем набор вложений: лишние снимаем, новые добавляем
|
||||
await tx.commentAttachment.deleteMany({ where: { commentId: id, fileId: { notIn: fileIds } } });
|
||||
if (fileIds.length > 0) {
|
||||
await tx.commentAttachment.createMany({
|
||||
data: fileIds.map((fileId) => ({ commentId: id, fileId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
throw this.mapForeignKeyError(error, comment) ?? error;
|
||||
}
|
||||
}
|
||||
|
||||
async findIssueIdsByAttachedFile(fileId: string): Promise<number[]> {
|
||||
const records = await this.prisma.comment.findMany({
|
||||
where: { deletedAt: null, attachments: { some: { fileId } } },
|
||||
select: { issueId: true },
|
||||
distinct: ['issueId'],
|
||||
});
|
||||
return records.map((record) => record.issueId);
|
||||
}
|
||||
|
||||
async findReferencedFileIds(fileIds: string[]): Promise<string[]> {
|
||||
if (fileIds.length === 0) return [];
|
||||
|
||||
const records = await this.prisma.commentAttachment.findMany({
|
||||
where: { fileId: { in: fileIds } },
|
||||
select: { fileId: true },
|
||||
distinct: ['fileId'],
|
||||
});
|
||||
return records.map((record) => record.fileId);
|
||||
}
|
||||
|
||||
/** Какой FK нарушен — по имени ограничения в meta (comment_attachment_fileId_fkey и т.п.) */
|
||||
private mapForeignKeyError(error: unknown, comment: Comment): Error | null {
|
||||
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== FOREIGN_KEY_VIOLATION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (JSON.stringify(error.meta ?? {}).includes('fileId')) {
|
||||
return new FileNotFoundException(comment.fileIds.join(', '));
|
||||
}
|
||||
return new IssueNotFoundException(comment.issueId);
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import { CommentNotFoundException } from '../../domain/exceptions/comment-not-fo
|
||||
import { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js';
|
||||
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueVisibleGuard } from '../guards/issue-visible.guard.js';
|
||||
import { FileNotFoundException } from '../../../file/domain/exceptions/file-not-found.exception.js';
|
||||
import { FileForbiddenException } from '../../../file/domain/exceptions/file-forbidden.exception.js';
|
||||
|
||||
@Controller()
|
||||
export class CommentController {
|
||||
@@ -66,7 +68,7 @@ export class CommentController {
|
||||
@Session() session: UserSession,
|
||||
) {
|
||||
return this.handleErrors(() =>
|
||||
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content)),
|
||||
this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content, dto.fileIds)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,7 +95,7 @@ export class CommentController {
|
||||
@Patch('comments/:id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
|
||||
return this.handleErrors(() =>
|
||||
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content)),
|
||||
this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content, dto.fileIds)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,10 +110,14 @@ export class CommentController {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof CommentNotFoundException || error instanceof IssueNotFoundException) {
|
||||
if (
|
||||
error instanceof CommentNotFoundException ||
|
||||
error instanceof IssueNotFoundException ||
|
||||
error instanceof FileNotFoundException
|
||||
) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
if (error instanceof CommentForbiddenException) {
|
||||
if (error instanceof CommentForbiddenException || error instanceof FileForbiddenException) {
|
||||
throw new ForbiddenException(error.message);
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
|
||||
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||
|
||||
export class CreateCommentRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CommentContent.MAX_LENGTH)
|
||||
content: string;
|
||||
|
||||
/** id файлов, загруженных через POST /files */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
fileIds?: string[];
|
||||
}
|
||||
@@ -1,9 +1,18 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
|
||||
import { Comment } from '../../domain/entities/comment.entity.js';
|
||||
|
||||
export class UpdateCommentRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(CommentContent.MAX_LENGTH)
|
||||
content: string;
|
||||
|
||||
/** id файлов, загруженных через POST /files — набор целиком; не передан — не менять */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
fileIds?: string[];
|
||||
}
|
||||
Reference in new issue
Block a user