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:
vadimandClaude Opus 5.5 committed 2026-10-02 09:18:08 +03:00
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
View File
@@ -196,16 +196,17 @@ model NotificationMessage {
/// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL). /// Комментарий к задаче. При удалении автора комментарий остаётся (authorId -> NULL).
/// deletedAt — мягкое удаление, editedAt — время последней правки текста. /// deletedAt — мягкое удаление, editedAt — время последней правки текста.
model Comment { model Comment {
id String @id @default(uuid(7)) id String @id @default(uuid(7))
content String content String
issueId Int issueId Int
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade) issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
authorId String? authorId String?
author User? @relation(fields: [authorId], references: [id], onDelete: SetNull) author User? @relation(fields: [authorId], references: [id], onDelete: SetNull)
editedAt DateTime? @db.Timestamptz(3) editedAt DateTime? @db.Timestamptz(3)
deletedAt DateTime? @db.Timestamptz(3) deletedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3)
attachments CommentAttachment[]
@@index([issueId, createdAt]) @@index([issueId, createdAt])
@@index([authorId]) @@index([authorId])
@@ -226,7 +227,8 @@ model File {
uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull) uploader User? @relation(fields: [uploaderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
issueAttachments IssueAttachment[] issueAttachments IssueAttachment[]
commentAttachments CommentAttachment[]
@@unique([storageKey]) @@unique([storageKey])
@@index([uploaderId]) @@index([uploaderId])
@@ -247,3 +249,18 @@ model IssueAttachment {
@@index([fileId]) @@index([fileId])
@@map("issue_attachment") @@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 { UserDto } from '../../../user/application/dto/user.dto.js';
import { FileDto } from '../../../file/application/dto/file.dto.js';
export class CommentDto { export class CommentDto {
id: string; id: string;
@@ -8,6 +9,8 @@ export class CommentDto {
author: UserDto | null; author: UserDto | null;
/** null у удалённого комментария — текст наружу не отдаём */ /** null у удалённого комментария — текст наружу не отдаём */
content: string | null; content: string | null;
/** Пусто у удалённого комментария. Скачивание — GET /files/:id/download */
attachments: FileDto[];
editedAt: Date | null; editedAt: Date | null;
deletedAt: Date | null; deletedAt: Date | null;
createdAt: Date; createdAt: Date;
@@ -13,12 +13,13 @@ const author: UserDto = {
updatedAt: new Date('2026-01-01T00:00:00.000Z'), 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({ Comment.reconstitute({
id: 'comment-1', id: 'comment-1',
content: 'Secret', content: 'Secret',
issueId: 1, issueId: 1,
authorId, authorId,
fileIds,
editedAt: null, editedAt: null,
deletedAt, deletedAt,
createdAt: new Date('2026-01-01T00:00:00.000Z'), createdAt: new Date('2026-01-01T00:00:00.000Z'),
@@ -46,4 +47,28 @@ describe('CommentApplicationMapper', () => {
expect(dto.authorId).toBeNull(); expect(dto.authorId).toBeNull();
expect(dto.author).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 { Comment } from '../../domain/entities/comment.entity.js';
import { CommentDto } from '../dto/comment.dto.js'; import { CommentDto } from '../dto/comment.dto.js';
import { UserDto } from '../../../user/application/dto/user.dto.js'; import { UserDto } from '../../../user/application/dto/user.dto.js';
import { FileDto } from '../../../file/application/dto/file.dto.js';
export class CommentApplicationMapper { 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 { return {
id: comment.id as string, id: comment.id as string,
issueId: comment.issueId, issueId: comment.issueId,
authorId: comment.authorId, authorId: comment.authorId,
author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null, author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null,
content: comment.isDeleted ? null : comment.content, content: comment.isDeleted ? null : comment.content,
// Файл могли удалить между запросами — такое вложение просто не показываем
attachments: comment.isDeleted
? []
: comment.fileIds.flatMap((fileId) => {
const file = filesById.get(fileId);
return file ? [file] : [];
}),
editedAt: comment.editedAt, editedAt: comment.editedAt,
deletedAt: comment.deletedAt, deletedAt: comment.deletedAt,
createdAt: comment.createdAt, createdAt: comment.createdAt,
@@ -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;
}
}
@@ -4,11 +4,16 @@ import { CommentDto } from '../dto/comment.dto.js';
import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js'; import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.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 { 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() @Injectable()
export class CommentAuthorExpanderService { export class CommentExpanderService {
constructor(private readonly getUsersUseCase: GetUsersUseCase) {} constructor(
private readonly getUsersUseCase: GetUsersUseCase,
private readonly getFilesUseCase: GetFilesUseCase,
) {}
async expand(comment: Comment): Promise<CommentDto> { async expand(comment: Comment): Promise<CommentDto> {
const [dto] = await this.expandAll([comment]); const [dto] = await this.expandAll([comment]);
@@ -19,9 +24,15 @@ export class CommentAuthorExpanderService {
if (comments.length === 0) return []; if (comments.length === 0) return [];
const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))]; 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 fileIds = comments.flatMap((comment) => comment.fileIds);
const usersById = new Map(users.map((user) => [user.id, user]));
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 issueId: number,
public readonly authorId: string, public readonly authorId: string,
public readonly content: string, public readonly content: string,
/** Файлы, заранее загруженные автором через POST /files */
public readonly fileIds: string[] = [],
) {} ) {}
} }
@@ -6,7 +6,14 @@ import { Comment } from '../../../domain/entities/comment.entity.js';
import { CommentEventsService } from '../../services/comment-events.service.js'; import { CommentEventsService } from '../../services/comment-events.service.js';
import { CreateCommentUseCase } from './create-comment.use-case.js'; import { CreateCommentUseCase } from './create-comment.use-case.js';
import { CreateCommentCommand } from './create-comment.command.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', () => { describe('CreateCommentUseCase', () => {
let commentRepository: ICommentRepository; let commentRepository: ICommentRepository;
@@ -18,7 +25,13 @@ describe('CreateCommentUseCase', () => {
commentRepository = mockCommentRepository(); commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository(); issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService(); 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) => vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) =>
Comment.reconstitute({ Comment.reconstitute({
@@ -26,6 +39,7 @@ describe('CreateCommentUseCase', () => {
content: comment.content, content: comment.content,
issueId: comment.issueId, issueId: comment.issueId,
authorId: comment.authorId, authorId: comment.authorId,
fileIds: [...comment.fileIds],
editedAt: null, editedAt: null,
deletedAt: null, deletedAt: null,
createdAt: comment.createdAt, createdAt: comment.createdAt,
@@ -54,4 +68,21 @@ describe('CreateCommentUseCase', () => {
); );
expect(commentRepository.create).not.toHaveBeenCalled(); 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,8 +11,9 @@ import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issu
import { Comment } from '../../../domain/entities/comment.entity.js'; import { Comment } from '../../../domain/entities/comment.entity.js';
import { CreateCommentCommand } from './create-comment.command.js'; import { CreateCommentCommand } from './create-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.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 { CommentEventsService } from '../../services/comment-events.service.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
@Injectable() @Injectable()
export class CreateCommentUseCase { export class CreateCommentUseCase {
@@ -21,8 +22,9 @@ export class CreateCommentUseCase {
private readonly commentRepository: ICommentRepository, private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY) @Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository, private readonly issueRepository: IIssueRepository,
private readonly commentAuthorExpander: CommentAuthorExpanderService, private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService, private readonly commentEventsService: CommentEventsService,
private readonly fileAccess: FileAccessService,
) {} ) {}
async execute(command: CreateCommentCommand): Promise<CommentDto> { async execute(command: CreateCommentCommand): Promise<CommentDto> {
@@ -36,10 +38,16 @@ export class CreateCommentUseCase {
issueId: command.issueId, issueId: command.issueId,
authorId: command.authorId, authorId: command.authorId,
content: command.content, 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 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); this.commentEventsService.emit('comment.created', dto);
@@ -11,7 +11,7 @@ import { DeleteCommentCommand } from './delete-comment.command.js';
import { import {
buildComment, buildComment,
buildIssue, buildIssue,
mockAuthorExpander, mockCommentExpander,
mockCommentRepository, mockCommentRepository,
mockIssueRepository, mockIssueRepository,
} from '../test-helpers.js'; } from '../test-helpers.js';
@@ -29,7 +29,7 @@ describe('DeleteCommentUseCase', () => {
useCase = new DeleteCommentUseCase( useCase = new DeleteCommentUseCase(
commentRepository, commentRepository,
new CommentAccessService(commentRepository, issueRepository), new CommentAccessService(commentRepository, issueRepository),
mockAuthorExpander(), mockCommentExpander(),
commentEventsService, commentEventsService,
); );
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
@@ -49,6 +49,16 @@ describe('DeleteCommentUseCase', () => {
expect(received[0]!.comment.content).toBeNull(); 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 () => { it('throws CommentForbiddenException for a non-author', async () => {
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment()); vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
@@ -4,7 +4,7 @@ import {
type ICommentRepository, type ICommentRepository,
} from '../../../domain/repositories/comment.repository.interface.js'; } from '../../../domain/repositories/comment.repository.interface.js';
import { DeleteCommentCommand } from './delete-comment.command.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 { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js'; import { CommentAccessService } from '../../services/comment-access.service.js';
@@ -15,7 +15,7 @@ export class DeleteCommentUseCase {
@Inject(COMMENT_REPOSITORY) @Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository, private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService, private readonly commentAccess: CommentAccessService,
private readonly commentAuthorExpander: CommentAuthorExpanderService, private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService, private readonly commentEventsService: CommentEventsService,
) {} ) {}
@@ -25,6 +25,6 @@ export class DeleteCommentUseCase {
comment.delete(command.userId); comment.delete(command.userId);
await this.commentRepository.update(comment); 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));
} }
} }
@@ -7,7 +7,7 @@ import { ListCommentsQuery } from './list-comments.query.js';
import { import {
buildComment, buildComment,
buildIssue, buildIssue,
mockAuthorExpander, mockCommentExpander,
mockCommentRepository, mockCommentRepository,
mockIssueRepository, mockIssueRepository,
} from '../test-helpers.js'; } from '../test-helpers.js';
@@ -20,7 +20,7 @@ describe('ListCommentsUseCase', () => {
beforeEach(() => { beforeEach(() => {
commentRepository = mockCommentRepository(); commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository(); issueRepository = mockIssueRepository();
useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockAuthorExpander()); useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockCommentExpander());
}); });
it('forwards pagination params and returns the next cursor', async () => { 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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
import { ListCommentsQuery } from './list-comments.query.js'; import { ListCommentsQuery } from './list-comments.query.js';
import { CommentDto } from '../../dto/comment.dto.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() @Injectable()
export class ListCommentsUseCase { export class ListCommentsUseCase {
@@ -19,7 +19,7 @@ export class ListCommentsUseCase {
private readonly commentRepository: ICommentRepository, private readonly commentRepository: ICommentRepository,
@Inject(ISSUE_REPOSITORY) @Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository, private readonly issueRepository: IIssueRepository,
private readonly commentAuthorExpander: CommentAuthorExpanderService, private readonly commentExpander: CommentExpanderService,
) {} ) {}
async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> { async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> {
@@ -34,6 +34,6 @@ export class ListCommentsUseCase {
cursor: query.cursor, 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 type { IIssueRepository } from '../../../issue/domain/repositories/issue.repository.interface.js';
import { Comment } from '../../domain/entities/comment.entity.js'; import { Comment } from '../../domain/entities/comment.entity.js';
import { Issue } from '../../../issue/domain/entities/issue.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 { CommentApplicationMapper } from '../mappers/comment-application.mapper.js';
import type { FileAccessService } from '../../../file/application/services/file-access.service.js';
export const buildComment = ( export const buildComment = (
overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {}, overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {},
@@ -41,6 +42,8 @@ export const mockCommentRepository = (): ICommentRepository => ({
findByIssue: vi.fn(), findByIssue: vi.fn(),
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
findIssueIdsByAttachedFile: vi.fn(),
findReferencedFileIds: vi.fn(),
}); });
export const mockIssueRepository = (): IIssueRepository => ({ export const mockIssueRepository = (): IIssueRepository => ({
@@ -53,11 +56,20 @@ export const mockIssueRepository = (): IIssueRepository => ({
delete: vi.fn(), 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()); const toDto = (comment: Comment) => CommentApplicationMapper.toDto(comment, new Map());
return { return {
expand: vi.fn(async (comment: Comment) => toDto(comment)), expand: vi.fn(async (comment: Comment) => toDto(comment)),
expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)), 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 id: string,
public readonly userId: string, public readonly userId: string,
public readonly content: string, public readonly content: string,
/** Новый набор вложений целиком; undefined — не менять */
public readonly fileIds?: string[],
) {} ) {}
} }
@@ -10,10 +10,13 @@ import { UpdateCommentCommand } from './update-comment.command.js';
import { import {
buildComment, buildComment,
buildIssue, buildIssue,
mockAuthorExpander, mockCommentExpander,
mockCommentRepository, mockCommentRepository,
mockFileAccess,
mockIssueRepository, mockIssueRepository,
} from '../test-helpers.js'; } 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', () => { describe('UpdateCommentUseCase', () => {
let commentRepository: ICommentRepository; let commentRepository: ICommentRepository;
@@ -21,16 +24,19 @@ describe('UpdateCommentUseCase', () => {
let commentEventsService: CommentEventsService; let commentEventsService: CommentEventsService;
let useCase: UpdateCommentUseCase; let useCase: UpdateCommentUseCase;
let received: unknown[]; let received: unknown[];
let fileAccess: FileAccessService;
beforeEach(() => { beforeEach(() => {
commentRepository = mockCommentRepository(); commentRepository = mockCommentRepository();
issueRepository = mockIssueRepository(); issueRepository = mockIssueRepository();
commentEventsService = new CommentEventsService(); commentEventsService = new CommentEventsService();
fileAccess = mockFileAccess({ gone: new FileNotFoundException('gone') });
useCase = new UpdateCommentUseCase( useCase = new UpdateCommentUseCase(
commentRepository, commentRepository,
new CommentAccessService(commentRepository, issueRepository), new CommentAccessService(commentRepository, issueRepository),
mockAuthorExpander(), mockCommentExpander(),
commentEventsService, commentEventsService,
fileAccess,
); );
received = []; received = [];
commentEventsService.onEvents().subscribe((event) => received.push(event)); commentEventsService.onEvents().subscribe((event) => received.push(event));
@@ -82,4 +88,25 @@ describe('UpdateCommentUseCase', () => {
CommentNotFoundException, 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();
});
}); });
@@ -5,9 +5,10 @@ import {
} from '../../../domain/repositories/comment.repository.interface.js'; } from '../../../domain/repositories/comment.repository.interface.js';
import { UpdateCommentCommand } from './update-comment.command.js'; import { UpdateCommentCommand } from './update-comment.command.js';
import { CommentDto } from '../../dto/comment.dto.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 { CommentEventsService } from '../../services/comment-events.service.js';
import { CommentAccessService } from '../../services/comment-access.service.js'; import { CommentAccessService } from '../../services/comment-access.service.js';
import { FileAccessService } from '../../../../file/application/services/file-access.service.js';
@Injectable() @Injectable()
export class UpdateCommentUseCase { export class UpdateCommentUseCase {
@@ -15,19 +16,29 @@ export class UpdateCommentUseCase {
@Inject(COMMENT_REPOSITORY) @Inject(COMMENT_REPOSITORY)
private readonly commentRepository: ICommentRepository, private readonly commentRepository: ICommentRepository,
private readonly commentAccess: CommentAccessService, private readonly commentAccess: CommentAccessService,
private readonly commentAuthorExpander: CommentAuthorExpanderService, private readonly commentExpander: CommentExpanderService,
private readonly commentEventsService: CommentEventsService, private readonly commentEventsService: CommentEventsService,
private readonly fileAccess: FileAccessService,
) {} ) {}
async execute(command: UpdateCommentCommand): Promise<CommentDto> { async execute(command: UpdateCommentCommand): Promise<CommentDto> {
const comment = await this.commentAccess.getVisibleComment(command.id, command.userId); 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) { if (changed) {
await this.commentRepository.update(comment); await this.commentRepository.update(comment);
} }
const dto = await this.commentAuthorExpander.expand(comment); const dto = await this.commentExpander.expand(comment);
if (changed) { if (changed) {
this.commentEventsService.emit('comment.updated', dto); this.commentEventsService.emit('comment.updated', dto);
+6 -3
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js'; import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { IssueModule } from '../issue/issue.module.js'; import { IssueModule } from '../issue/issue.module.js';
import { UserModule } from '../user/user.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 { COMMENT_REPOSITORY } from './domain/repositories/comment.repository.interface.js';
import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js'; import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js';
import { CommentController } from './presentation/controllers/comment.controller.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 { 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 { WatchIssueCommentsUseCase } from './application/use-cases/watch-issue-comments/watch-issue-comments.use-case.js';
import { CommentAccessService } from './application/services/comment-access.service.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 { CommentEventsService } from './application/services/comment-events.service.js';
import { CommentAttachmentFileOwnerService } from './application/services/comment-attachment-file-owner.service.js';
@Module({ @Module({
imports: [PrismaModule, IssueModule, UserModule], imports: [PrismaModule, IssueModule, UserModule, FileModule],
controllers: [CommentController], controllers: [CommentController],
providers: [ providers: [
CreateCommentUseCase, CreateCommentUseCase,
@@ -24,8 +26,9 @@ import { CommentEventsService } from './application/services/comment-events.serv
DeleteCommentUseCase, DeleteCommentUseCase,
WatchIssueCommentsUseCase, WatchIssueCommentsUseCase,
CommentAccessService, CommentAccessService,
CommentAuthorExpanderService, CommentExpanderService,
CommentEventsService, CommentEventsService,
CommentAttachmentFileOwnerService,
{ {
// Domain видит только интерфейс ICommentRepository, // Domain видит только интерфейс ICommentRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь // конкретную реализацию (Prisma) подставляет DI-контейнер здесь
@@ -73,4 +73,50 @@ describe('Comment', () => {
expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException); 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; issueId: number;
/** null — автор удалил аккаунт */ /** null — автор удалил аккаунт */
authorId: string | null; authorId: string | null;
/** Прикреплённые файлы (file-модуль), по времени загрузки */
fileIds: string[];
editedAt: Date | null; editedAt: Date | null;
deletedAt: Date | null; deletedAt: Date | null;
createdAt: Date; createdAt: Date;
@@ -17,14 +19,19 @@ interface CommentProps {
* Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt), * Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt),
* чтобы в ленте можно было показать «комментарий удалён». * чтобы в ленте можно было показать «комментарий удалён».
* *
* Вложения — часть комментария: задаются при создании и правке,
* снимаются при удалении (тогда осиротевшие файлы удалит GC file-модуля).
*
* `id` (uuid v7) генерируется базой, поэтому у нового комментария * `id` (uuid v7) генерируется базой, поэтому у нового комментария
* он равен null до сохранения в репозитории. * он равен null до сохранения в репозитории.
*/ */
export class Comment { export class Comment {
static readonly MAX_ATTACHMENTS = 10;
private constructor(private readonly props: CommentProps) {} 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(); const now = new Date();
return new Comment({ return new Comment({
@@ -32,6 +39,7 @@ export class Comment {
content: CommentContent.create(params.content), content: CommentContent.create(params.content),
issueId: params.issueId, issueId: params.issueId,
authorId: params.authorId, authorId: params.authorId,
fileIds: Comment.normalizeFileIds(params.fileIds ?? []),
editedAt: null, editedAt: null,
deletedAt: null, deletedAt: null,
createdAt: now, createdAt: now,
@@ -45,6 +53,7 @@ export class Comment {
content: string; content: string;
issueId: number; issueId: number;
authorId: string | null; authorId: string | null;
fileIds?: string[];
editedAt: Date | null; editedAt: Date | null;
deletedAt: Date | null; deletedAt: Date | null;
createdAt: Date; createdAt: Date;
@@ -53,6 +62,7 @@ export class Comment {
return new Comment({ return new Comment({
...props, ...props,
content: CommentContent.create(props.content), content: CommentContent.create(props.content),
fileIds: [...(props.fileIds ?? [])],
}); });
} }
@@ -72,6 +82,10 @@ export class Comment {
return this.props.authorId; return this.props.authorId;
} }
get fileIds(): readonly string[] {
return this.props.fileIds;
}
get editedAt(): Date | null { get editedAt(): Date | null {
return this.props.editedAt; return this.props.editedAt;
} }
@@ -92,17 +106,27 @@ export class Comment {
return this.props.updatedAt; return this.props.updatedAt;
} }
/** Меняет текст. Возвращает false, если текст не изменился */ /**
edit(content: string, userId: string): boolean { * Меняет текст и, если передан fileIds, — набор вложений (целиком).
* Возвращает false, если ничего не изменилось.
*/
edit(content: string, userId: string, fileIds?: string[]): boolean {
this.assertCanModify(userId); this.assertCanModify(userId);
const next = CommentContent.create(content); const nextContent = CommentContent.create(content);
if (next.equals(this.props.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; return false;
} }
const now = new Date(); const now = new Date();
this.props.content = next; this.props.content = nextContent;
this.props.fileIds = nextFileIds;
this.props.editedAt = now; this.props.editedAt = now;
this.props.updatedAt = now; this.props.updatedAt = now;
return true; return true;
@@ -112,10 +136,24 @@ export class Comment {
this.assertCanModify(userId); this.assertCanModify(userId);
const now = new Date(); const now = new Date();
this.props.fileIds = [];
this.props.deletedAt = now; this.props.deletedAt = now;
this.props.updatedAt = 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 { private assertCanModify(userId: string): void {
if (this.isDeleted) { if (this.isDeleted) {
@@ -15,6 +15,14 @@ export interface ICommentRepository {
limit: number; limit: number;
cursor?: string; cursor?: string;
}): Promise<{ list: Comment[]; nextCursor: string | null }>; }): Promise<{ list: Comment[]; nextCursor: string | null }>;
/**
* Создание и обновление сохраняют и вложения (fileIds) — атомарно с комментарием.
* FileNotFoundException, если файл удалили между проверкой и записью.
*/
create(comment: Comment): Promise<Comment>; create(comment: Comment): Promise<Comment>;
update(comment: Comment): Promise<void>; 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 type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js';
import { Comment } from '../../../../domain/entities/comment.entity.js'; import { Comment } from '../../../../domain/entities/comment.entity.js';
/** Комментарий вместе с id прикреплённых файлов (см. COMMENT_INCLUDE в репозитории) */
export type PrismaCommentWithAttachments = PrismaComment & { attachments: { fileId: string }[] };
export class CommentPrismaMapper { export class CommentPrismaMapper {
static toDomain(record: PrismaComment): Comment { static toDomain(record: PrismaCommentWithAttachments): Comment {
return Comment.reconstitute({ return Comment.reconstitute({
id: record.id, id: record.id,
content: record.content, content: record.content,
issueId: record.issueId, issueId: record.issueId,
authorId: record.authorId, authorId: record.authorId,
fileIds: record.attachments.map((attachment) => attachment.fileId),
editedAt: record.editedAt, editedAt: record.editedAt,
deletedAt: record.deletedAt, deletedAt: record.deletedAt,
createdAt: record.createdAt, createdAt: record.createdAt,
@@ -22,6 +26,7 @@ export class CommentPrismaMapper {
authorId: comment.authorId, authorId: comment.authorId,
createdAt: comment.createdAt, createdAt: comment.createdAt,
updatedAt: comment.updatedAt, updatedAt: comment.updatedAt,
attachments: { create: comment.fileIds.map((fileId) => ({ fileId })) },
}; };
} }
@@ -5,15 +5,21 @@ import { Comment } from '../../../../domain/entities/comment.entity.js';
import { ICommentRepository } from '../../../../domain/repositories/comment.repository.interface.js'; import { ICommentRepository } from '../../../../domain/repositories/comment.repository.interface.js';
import { CommentPrismaMapper } from '../mappers/comment.mapper.js'; import { CommentPrismaMapper } from '../mappers/comment.mapper.js';
import { IssueNotFoundException } from '../../../../../issue/domain/exceptions/issue-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';
const FOREIGN_KEY_VIOLATION = 'P2003'; const FOREIGN_KEY_VIOLATION = 'P2003';
/** Вложения подгружаются вместе с комментарием, по времени загрузки файла (uuid v7) */
const COMMENT_INCLUDE = {
attachments: { select: { fileId: true }, orderBy: { fileId: 'asc' } },
} satisfies Prisma.CommentInclude;
@Injectable() @Injectable()
export class CommentPrismaRepository implements ICommentRepository { export class CommentPrismaRepository implements ICommentRepository {
constructor(private readonly prisma: PrismaAdapter) {} constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string): Promise<Comment | null> { 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; return record ? CommentPrismaMapper.toDomain(record) : null;
} }
@@ -28,6 +34,7 @@ export class CommentPrismaRepository implements ICommentRepository {
// id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt. // id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt.
const records = await this.prisma.comment.findMany({ const records = await this.prisma.comment.findMany({
where: { issueId: params.issueId }, where: { issueId: params.issueId },
include: COMMENT_INCLUDE,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: limit + 1, take: limit + 1,
...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}), ...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}),
@@ -44,24 +51,73 @@ export class CommentPrismaRepository implements ICommentRepository {
async create(comment: Comment): Promise<Comment> { async create(comment: Comment): Promise<Comment> {
try { try {
// Вложения создаются вложенной записью — одним атомарным запросом
const record = await this.prisma.comment.create({ const record = await this.prisma.comment.create({
data: CommentPrismaMapper.toCreateInput(comment), data: CommentPrismaMapper.toCreateInput(comment),
include: COMMENT_INCLUDE,
}); });
return CommentPrismaMapper.toDomain(record); return CommentPrismaMapper.toDomain(record);
} catch (error) { } catch (error) {
// Задачу удалили между проверкой доступа и вставкой // Задачу или файл удалили между проверкой доступа и вставкой
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) { throw this.mapForeignKeyError(error, comment) ?? error;
throw new IssueNotFoundException(comment.issueId);
}
throw error;
} }
} }
async update(comment: Comment): Promise<void> { async update(comment: Comment): Promise<void> {
await this.prisma.comment.update({ const id = comment.id as string;
where: { id: comment.id as string }, const fileIds = [...comment.fileIds];
data: CommentPrismaMapper.toUpdateInput(comment),
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 { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js';
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js'; import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
import { IssueVisibleGuard } from '../guards/issue-visible.guard.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() @Controller()
export class CommentController { export class CommentController {
@@ -66,7 +68,7 @@ export class CommentController {
@Session() session: UserSession, @Session() session: UserSession,
) { ) {
return this.handleErrors(() => 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') @Patch('comments/:id')
update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) { update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) {
return this.handleErrors(() => 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 { try {
return await fn(); return await fn();
} catch (error) { } catch (error) {
if (error instanceof CommentNotFoundException || error instanceof IssueNotFoundException) { if (
error instanceof CommentNotFoundException ||
error instanceof IssueNotFoundException ||
error instanceof FileNotFoundException
) {
throw new NotFoundException(error.message); throw new NotFoundException(error.message);
} }
if (error instanceof CommentForbiddenException) { if (error instanceof CommentForbiddenException || error instanceof FileForbiddenException) {
throw new ForbiddenException(error.message); throw new ForbiddenException(error.message);
} }
throw error; 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 { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
import { Comment } from '../../domain/entities/comment.entity.js';
export class CreateCommentRequestDto { export class CreateCommentRequestDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH) @MaxLength(CommentContent.MAX_LENGTH)
content: string; 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 { CommentContent } from '../../domain/value-objects/comment-content.vo.js';
import { Comment } from '../../domain/entities/comment.entity.js';
export class UpdateCommentRequestDto { export class UpdateCommentRequestDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(CommentContent.MAX_LENGTH) @MaxLength(CommentContent.MAX_LENGTH)
content: string; content: string;
/** id файлов, загруженных через POST /files — набор целиком; не передан — не менять */
@IsOptional()
@IsArray()
@ArrayMaxSize(Comment.MAX_ATTACHMENTS)
@IsString({ each: true })
@MaxLength(64, { each: true })
fileIds?: string[];
} }