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
+510
-57
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;
|
||||||
|
|
||||||
@@ -206,6 +206,7 @@ model Comment {
|
|||||||
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])
|
||||||
@@ -227,6 +228,7 @@ model File {
|
|||||||
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,
|
||||||
|
|||||||
+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 { 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[] = [],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
+33
-2
@@ -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
-3
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-2
@@ -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[],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
+29
-2
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+15
-4
@@ -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);
|
||||||
|
|||||||
@@ -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 })) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+64
-8
@@ -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];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.comment.update({
|
||||||
|
where: { id },
|
||||||
data: CommentPrismaMapper.toUpdateInput(comment),
|
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[];
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user