From 4b6ecfbf5dc05c85a4c6bb4f921e05bce2b773d6 Mon Sep 17 00:00:00 2001 From: Vadim Date: Thu, 1 Oct 2026 12:02:03 +0300 Subject: [PATCH] comments implement --- .prettierrc | 2 +- src/app.module.ts | 2 + .../transform-response.interceptor.ts | 22 ++- .../comment/application/dto/comment.dto.ts | 15 ++ .../comment-application.mapper.spec.ts | 49 +++++++ .../mappers/comment-application.mapper.ts | 19 +++ .../services/comment-access.service.ts | 39 ++++++ .../comment-author-expander.service.ts | 27 ++++ .../services/comment-events.service.ts | 28 ++++ .../create-comment/create-comment.command.ts | 7 + .../create-comment.use-case.spec.ts | 55 ++++++++ .../create-comment/create-comment.use-case.ts | 42 ++++++ .../delete-comment/delete-comment.command.ts | 6 + .../delete-comment.use-case.spec.ts | 58 ++++++++ .../delete-comment/delete-comment.use-case.ts | 27 ++++ .../list-comments/list-comments.query.ts | 8 ++ .../list-comments.use-case.spec.ts | 40 ++++++ .../list-comments/list-comments.use-case.ts | 33 +++++ .../application/use-cases/test-helpers.ts | 61 +++++++++ .../update-comment/update-comment.command.ts | 7 + .../update-comment.use-case.spec.ts | 73 ++++++++++ .../update-comment/update-comment.use-case.ts | 35 +++++ .../watch-issue-comments.query.ts | 6 + .../watch-issue-comments.use-case.spec.ts | 41 ++++++ .../watch-issue-comments.use-case.ts | 28 ++++ src/modules/comment/comment.module.ts | 37 +++++ .../domain/entities/comment.entity.spec.ts | 76 +++++++++++ .../comment/domain/entities/comment.entity.ts | 129 ++++++++++++++++++ .../exceptions/comment-forbidden.exception.ts | 6 + .../exceptions/comment-not-found.exception.ts | 6 + .../comment.repository.interface.ts | 16 +++ .../value-objects/comment-content.vo.spec.ts | 18 +++ .../value-objects/comment-content.vo.ts | 27 ++++ .../prisma/mappers/comment.mapper.ts | 36 +++++ .../repositories/comment.prisma-repository.ts | 63 +++++++++ .../controllers/comment.controller.ts | 101 ++++++++++++++ .../presentation/dto/create-comment.dto.ts | 9 ++ .../presentation/dto/list-comments.dto.ts | 10 ++ .../presentation/dto/update-comment.dto.ts | 9 ++ .../guards/issue-visible.guard.spec.ts | 44 ++++++ .../guards/issue-visible.guard.ts | 46 +++++++ 41 files changed, 1350 insertions(+), 13 deletions(-) create mode 100644 src/modules/comment/application/dto/comment.dto.ts create mode 100644 src/modules/comment/application/mappers/comment-application.mapper.spec.ts create mode 100644 src/modules/comment/application/mappers/comment-application.mapper.ts create mode 100644 src/modules/comment/application/services/comment-access.service.ts create mode 100644 src/modules/comment/application/services/comment-author-expander.service.ts create mode 100644 src/modules/comment/application/services/comment-events.service.ts create mode 100644 src/modules/comment/application/use-cases/create-comment/create-comment.command.ts create mode 100644 src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts create mode 100644 src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts create mode 100644 src/modules/comment/application/use-cases/delete-comment/delete-comment.command.ts create mode 100644 src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts create mode 100644 src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts create mode 100644 src/modules/comment/application/use-cases/list-comments/list-comments.query.ts create mode 100644 src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts create mode 100644 src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts create mode 100644 src/modules/comment/application/use-cases/test-helpers.ts create mode 100644 src/modules/comment/application/use-cases/update-comment/update-comment.command.ts create mode 100644 src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts create mode 100644 src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts create mode 100644 src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.query.ts create mode 100644 src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.spec.ts create mode 100644 src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.ts create mode 100644 src/modules/comment/comment.module.ts create mode 100644 src/modules/comment/domain/entities/comment.entity.spec.ts create mode 100644 src/modules/comment/domain/entities/comment.entity.ts create mode 100644 src/modules/comment/domain/exceptions/comment-forbidden.exception.ts create mode 100644 src/modules/comment/domain/exceptions/comment-not-found.exception.ts create mode 100644 src/modules/comment/domain/repositories/comment.repository.interface.ts create mode 100644 src/modules/comment/domain/value-objects/comment-content.vo.spec.ts create mode 100644 src/modules/comment/domain/value-objects/comment-content.vo.ts create mode 100644 src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts create mode 100644 src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts create mode 100644 src/modules/comment/presentation/controllers/comment.controller.ts create mode 100644 src/modules/comment/presentation/dto/create-comment.dto.ts create mode 100644 src/modules/comment/presentation/dto/list-comments.dto.ts create mode 100644 src/modules/comment/presentation/dto/update-comment.dto.ts create mode 100644 src/modules/comment/presentation/guards/issue-visible.guard.spec.ts create mode 100644 src/modules/comment/presentation/guards/issue-visible.guard.ts diff --git a/.prettierrc b/.prettierrc index 9db28a2..3e189a3 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { "singleQuote": true, "trailingComma": "all", - "printWidth": 120 + "printWidth": 140 } diff --git a/src/app.module.ts b/src/app.module.ts index 5bd8f5f..ad2c133 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { ProjectModule } from './modules/project/project.module.js'; import { UserModule } from './modules/user/user.module.js'; import { StatusModule } from './modules/status/status.module.js'; import { NotificationModule } from './modules/notification/notification.module.js'; +import { CommentModule } from './modules/comment/comment.module.js'; import { AuthModule } from '@thallesp/nestjs-better-auth'; import { auth } from './lib/auth.js'; import { ConfigModule } from '@nestjs/config'; @@ -46,6 +47,7 @@ import { join } from 'node:path'; UserModule, StatusModule, NotificationModule, + CommentModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/common/interceptors/transform-response.interceptor.ts b/src/common/interceptors/transform-response.interceptor.ts index fef5ee5..ca014e8 100644 --- a/src/common/interceptors/transform-response.interceptor.ts +++ b/src/common/interceptors/transform-response.interceptor.ts @@ -1,6 +1,7 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { SSE_METADATA } from '@nestjs/common/constants.js'; import { PaginationMeta } from '../interfaces/pagination-meta.interface.js'; export interface ResponseEnvelope { @@ -22,26 +23,23 @@ function isPaginatedResult(value: unknown): value is PaginatedResult { } @Injectable() -export class TransformResponseInterceptor implements NestInterceptor< - T, - T | ResponseEnvelope | PaginatedResponseEnvelope -> { - intercept( - context: ExecutionContext, - next: CallHandler, - ): Observable | PaginatedResponseEnvelope> { - // GraphQL уже оборачивает ответ в {data, errors} на уровне транспорта — - // конверт применяем только к HTTP (REST) контексту, чтобы не задваивать его. +export class TransformResponseInterceptor implements NestInterceptor | PaginatedResponseEnvelope> { + intercept(context: ExecutionContext, next: CallHandler): Observable | PaginatedResponseEnvelope> { if (context.getType() !== 'http') { return next.handle(); } + // SSE-обработчик возвращает поток MessageEvent ({type, data, id}) — его + // сериализует сам Nest. Обёртка в {data, code} спрятала бы type внутрь data, + // и клиентский addEventListener('') перестал бы срабатывать. + if (Reflect.getMetadata(SSE_METADATA, context.getHandler())) { + return next.handle(); + } + const response = context.switchToHttp().getResponse(); return next.handle().pipe( map((result) => { - // Пагинированные ответы ({data, meta}) разворачиваем на верхний - // уровень конверта, а не оборачиваем повторно как data.data. if (isPaginatedResult(result)) { return { data: result.data ?? null, diff --git a/src/modules/comment/application/dto/comment.dto.ts b/src/modules/comment/application/dto/comment.dto.ts new file mode 100644 index 0000000..d61cf97 --- /dev/null +++ b/src/modules/comment/application/dto/comment.dto.ts @@ -0,0 +1,15 @@ +import { UserDto } from '../../../user/application/dto/user.dto.js'; + +export class CommentDto { + id: string; + issueId: number; + authorId: string | null; + /** null — автор удалил аккаунт */ + author: UserDto | null; + /** null у удалённого комментария — текст наружу не отдаём */ + content: string | null; + editedAt: Date | null; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/modules/comment/application/mappers/comment-application.mapper.spec.ts b/src/modules/comment/application/mappers/comment-application.mapper.spec.ts new file mode 100644 index 0000000..a5d8caf --- /dev/null +++ b/src/modules/comment/application/mappers/comment-application.mapper.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { Comment } from '../../domain/entities/comment.entity.js'; +import { CommentApplicationMapper } from './comment-application.mapper.js'; +import { UserDto } from '../../../user/application/dto/user.dto.js'; + +const author: UserDto = { + id: 'user-1', + name: 'User 1', + email: 'user-1@example.com', + emailVerified: true, + image: null, + createdAt: 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') => + Comment.reconstitute({ + id: 'comment-1', + content: 'Secret', + issueId: 1, + authorId, + editedAt: null, + deletedAt, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + +describe('CommentApplicationMapper', () => { + it('expands the author', () => { + const dto = CommentApplicationMapper.toDto(buildComment(), new Map([[author.id, author]])); + + expect(dto.author).toEqual(author); + expect(dto.content).toBe('Secret'); + }); + + it('hides content of a deleted comment', () => { + const dto = CommentApplicationMapper.toDto(buildComment(new Date()), new Map([[author.id, author]])); + + expect(dto.content).toBeNull(); + expect(dto.deletedAt).toBeInstanceOf(Date); + }); + + it('returns null author when the author account is deleted', () => { + const dto = CommentApplicationMapper.toDto(buildComment(null, null), new Map()); + + expect(dto.authorId).toBeNull(); + expect(dto.author).toBeNull(); + }); +}); diff --git a/src/modules/comment/application/mappers/comment-application.mapper.ts b/src/modules/comment/application/mappers/comment-application.mapper.ts new file mode 100644 index 0000000..5f455b7 --- /dev/null +++ b/src/modules/comment/application/mappers/comment-application.mapper.ts @@ -0,0 +1,19 @@ +import { Comment } from '../../domain/entities/comment.entity.js'; +import { CommentDto } from '../dto/comment.dto.js'; +import { UserDto } from '../../../user/application/dto/user.dto.js'; + +export class CommentApplicationMapper { + static toDto(comment: Comment, usersById: ReadonlyMap): CommentDto { + return { + id: comment.id as string, + issueId: comment.issueId, + authorId: comment.authorId, + author: comment.authorId ? (usersById.get(comment.authorId) ?? null) : null, + content: comment.isDeleted ? null : comment.content, + editedAt: comment.editedAt, + deletedAt: comment.deletedAt, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; + } +} diff --git a/src/modules/comment/application/services/comment-access.service.ts b/src/modules/comment/application/services/comment-access.service.ts new file mode 100644 index 0000000..210e3a9 --- /dev/null +++ b/src/modules/comment/application/services/comment-access.service.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } 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 { Comment } from '../../domain/entities/comment.entity.js'; +import { CommentNotFoundException } from '../../domain/exceptions/comment-not-found.exception.js'; + +@Injectable() +export class CommentAccessService { + constructor( + @Inject(COMMENT_REPOSITORY) + private readonly commentRepository: ICommentRepository, + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + ) {} + + /** Видна ли задача пользователю (то же правило, что в IIssueRepository.findById) */ + async isIssueVisible(issueId: number, userId: string): Promise { + return (await this.issueRepository.findById(issueId, userId)) !== null; + } + + /** + * Возвращает неудалённый комментарий, если его задача видна пользователю. + * Иначе — CommentNotFoundException, чтобы не раскрывать существование + * комментариев к чужим задачам. + */ + async getVisibleComment(id: string, userId: string): Promise { + const comment = await this.commentRepository.findById(id); + if (!comment || comment.isDeleted) { + throw new CommentNotFoundException(id); + } + + const issue = await this.issueRepository.findById(comment.issueId, userId); + if (!issue) { + throw new CommentNotFoundException(id); + } + + return comment; + } +} diff --git a/src/modules/comment/application/services/comment-author-expander.service.ts b/src/modules/comment/application/services/comment-author-expander.service.ts new file mode 100644 index 0000000..b401914 --- /dev/null +++ b/src/modules/comment/application/services/comment-author-expander.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common'; +import { Comment } from '../../domain/entities/comment.entity.js'; +import { CommentDto } from '../dto/comment.dto.js'; +import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js'; +import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js'; +import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js'; + +/** Раскрывает автора комментария (или списка комментариев) в полноценного пользователя */ +@Injectable() +export class CommentAuthorExpanderService { + constructor(private readonly getUsersUseCase: GetUsersUseCase) {} + + async expand(comment: Comment): Promise { + const [dto] = await this.expandAll([comment]); + return dto!; + } + + async expandAll(comments: Comment[]): Promise { + if (comments.length === 0) return []; + + const userIds = [...new Set(comments.map((comment) => comment.authorId).filter((id): id is string => Boolean(id)))]; + const users = userIds.length > 0 ? await this.getUsersUseCase.execute(new GetUsersQuery(userIds)) : []; + const usersById = new Map(users.map((user) => [user.id, user])); + + return comments.map((comment) => CommentApplicationMapper.toDto(comment, usersById)); + } +} diff --git a/src/modules/comment/application/services/comment-events.service.ts b/src/modules/comment/application/services/comment-events.service.ts new file mode 100644 index 0000000..21714ff --- /dev/null +++ b/src/modules/comment/application/services/comment-events.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { Observable, Subject } from 'rxjs'; +import { CommentDto } from '../dto/comment.dto.js'; + +export type CommentEventType = 'comment.created' | 'comment.updated' | 'comment.deleted'; + +export interface CommentEvent { + type: CommentEventType; + comment: CommentDto; +} + +/** + * In-process шина событий comment-модуля для SSE-подписчиков + * (по аналогии с IssueEventsService). Работает в пределах одного + * инстанса приложения — для нескольких инстансов понадобится Redis pub/sub. + */ +@Injectable() +export class CommentEventsService { + private readonly events$ = new Subject(); + + emit(type: CommentEventType, comment: CommentDto): void { + this.events$.next({ type, comment }); + } + + onEvents(): Observable { + return this.events$.asObservable(); + } +} diff --git a/src/modules/comment/application/use-cases/create-comment/create-comment.command.ts b/src/modules/comment/application/use-cases/create-comment/create-comment.command.ts new file mode 100644 index 0000000..cbbcb07 --- /dev/null +++ b/src/modules/comment/application/use-cases/create-comment/create-comment.command.ts @@ -0,0 +1,7 @@ +export class CreateCommentCommand { + constructor( + public readonly issueId: number, + public readonly authorId: string, + public readonly content: string, + ) {} +} diff --git a/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts new file mode 100644 index 0000000..e0745d9 --- /dev/null +++ b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.spec.ts @@ -0,0 +1,55 @@ +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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { Comment } from '../../../domain/entities/comment.entity.js'; +import { CommentEventsService } from '../../services/comment-events.service.js'; +import { CreateCommentUseCase } from './create-comment.use-case.js'; +import { CreateCommentCommand } from './create-comment.command.js'; +import { buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js'; + +describe('CreateCommentUseCase', () => { + let commentRepository: ICommentRepository; + let issueRepository: IIssueRepository; + let commentEventsService: CommentEventsService; + let useCase: CreateCommentUseCase; + + beforeEach(() => { + commentRepository = mockCommentRepository(); + issueRepository = mockIssueRepository(); + commentEventsService = new CommentEventsService(); + useCase = new CreateCommentUseCase(commentRepository, issueRepository, mockAuthorExpander(), commentEventsService); + + vi.mocked(commentRepository.create).mockImplementation(async (comment: Comment) => + Comment.reconstitute({ + id: 'comment-1', + content: comment.content, + issueId: comment.issueId, + authorId: comment.authorId, + editedAt: null, + deletedAt: null, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }), + ); + }); + + it('creates a comment on a visible issue and emits comment.created', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + const received: unknown[] = []; + commentEventsService.onEvents().subscribe((event) => received.push(event)); + + const dto = await useCase.execute(new CreateCommentCommand(1, 'user-1', 'Hello')); + + expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-1'); + expect(dto).toMatchObject({ id: 'comment-1', issueId: 1, authorId: 'user-1', content: 'Hello' }); + expect(received).toEqual([{ type: 'comment.created', comment: dto }]); + }); + + it('throws IssueNotFoundException when the issue is not visible to the user', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new CreateCommentCommand(1, 'user-2', 'Hello'))).rejects.toThrow(IssueNotFoundException); + expect(commentRepository.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts new file mode 100644 index 0000000..bd474e4 --- /dev/null +++ b/src/modules/comment/application/use-cases/create-comment/create-comment.use-case.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } 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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { Comment } from '../../../domain/entities/comment.entity.js'; +import { CreateCommentCommand } from './create-comment.command.js'; +import { CommentDto } from '../../dto/comment.dto.js'; +import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js'; +import { CommentEventsService } from '../../services/comment-events.service.js'; + +@Injectable() +export class CreateCommentUseCase { + constructor( + @Inject(COMMENT_REPOSITORY) + private readonly commentRepository: ICommentRepository, + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + private readonly commentAuthorExpander: CommentAuthorExpanderService, + private readonly commentEventsService: CommentEventsService, + ) {} + + async execute(command: CreateCommentCommand): Promise { + // Комментировать можно только видимую пользователю задачу + const issue = await this.issueRepository.findById(command.issueId, command.authorId); + if (!issue) { + throw new IssueNotFoundException(command.issueId); + } + + const comment = Comment.create({ + issueId: command.issueId, + authorId: command.authorId, + content: command.content, + }); + + const saved = await this.commentRepository.create(comment); + const dto = await this.commentAuthorExpander.expand(saved); + + this.commentEventsService.emit('comment.created', dto); + + return dto; + } +} diff --git a/src/modules/comment/application/use-cases/delete-comment/delete-comment.command.ts b/src/modules/comment/application/use-cases/delete-comment/delete-comment.command.ts new file mode 100644 index 0000000..6039117 --- /dev/null +++ b/src/modules/comment/application/use-cases/delete-comment/delete-comment.command.ts @@ -0,0 +1,6 @@ +export class DeleteCommentCommand { + constructor( + public readonly id: string, + public readonly userId: string, + ) {} +} diff --git a/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts new file mode 100644 index 0000000..7c587ca --- /dev/null +++ b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.spec.ts @@ -0,0 +1,58 @@ +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 { Comment } from '../../../domain/entities/comment.entity.js'; +import { CommentNotFoundException } from '../../../domain/exceptions/comment-not-found.exception.js'; +import { CommentForbiddenException } from '../../../domain/exceptions/comment-forbidden.exception.js'; +import { CommentEventsService, type CommentEvent } from '../../services/comment-events.service.js'; +import { CommentAccessService } from '../../services/comment-access.service.js'; +import { DeleteCommentUseCase } from './delete-comment.use-case.js'; +import { DeleteCommentCommand } from './delete-comment.command.js'; +import { buildComment, buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js'; + +describe('DeleteCommentUseCase', () => { + let commentRepository: ICommentRepository; + let issueRepository: IIssueRepository; + let commentEventsService: CommentEventsService; + let useCase: DeleteCommentUseCase; + + beforeEach(() => { + commentRepository = mockCommentRepository(); + issueRepository = mockIssueRepository(); + commentEventsService = new CommentEventsService(); + useCase = new DeleteCommentUseCase( + commentRepository, + new CommentAccessService(commentRepository, issueRepository), + mockAuthorExpander(), + commentEventsService, + ); + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + }); + + it('soft-deletes the comment and emits comment.deleted without content', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(buildComment()); + const received: CommentEvent[] = []; + commentEventsService.onEvents().subscribe((event) => received.push(event)); + + await useCase.execute(new DeleteCommentCommand('comment-1', 'user-1')); + + const saved = vi.mocked(commentRepository.update).mock.calls[0]![0] as Comment; + expect(saved.isDeleted).toBe(true); + expect(received).toHaveLength(1); + expect(received[0]!.type).toBe('comment.deleted'); + expect(received[0]!.comment.content).toBeNull(); + }); + + it('throws CommentForbiddenException for a non-author', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(buildComment()); + + await expect(useCase.execute(new DeleteCommentCommand('comment-1', 'user-2'))).rejects.toThrow(CommentForbiddenException); + expect(commentRepository.update).not.toHaveBeenCalled(); + }); + + it('throws CommentNotFoundException when the comment does not exist', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new DeleteCommentCommand('missing', 'user-1'))).rejects.toThrow(CommentNotFoundException); + }); +}); diff --git a/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts new file mode 100644 index 0000000..88682db --- /dev/null +++ b/src/modules/comment/application/use-cases/delete-comment/delete-comment.use-case.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { COMMENT_REPOSITORY, type ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js'; +import { DeleteCommentCommand } from './delete-comment.command.js'; +import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js'; +import { CommentEventsService } from '../../services/comment-events.service.js'; +import { CommentAccessService } from '../../services/comment-access.service.js'; + +/** Мягкое удаление: проставляет deletedAt, запись остаётся в ленте */ +@Injectable() +export class DeleteCommentUseCase { + constructor( + @Inject(COMMENT_REPOSITORY) + private readonly commentRepository: ICommentRepository, + private readonly commentAccess: CommentAccessService, + private readonly commentAuthorExpander: CommentAuthorExpanderService, + private readonly commentEventsService: CommentEventsService, + ) {} + + async execute(command: DeleteCommentCommand): Promise { + const comment = await this.commentAccess.getVisibleComment(command.id, command.userId); + + comment.delete(command.userId); + await this.commentRepository.update(comment); + + this.commentEventsService.emit('comment.deleted', await this.commentAuthorExpander.expand(comment)); + } +} diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.query.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.query.ts new file mode 100644 index 0000000..10f1ecf --- /dev/null +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.query.ts @@ -0,0 +1,8 @@ +export class ListCommentsQuery { + constructor( + public readonly issueId: number, + public readonly userId: string, + public readonly limit: number, + public readonly cursor?: string, + ) {} +} diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts new file mode 100644 index 0000000..0b85d49 --- /dev/null +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts @@ -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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { ListCommentsUseCase } from './list-comments.use-case.js'; +import { ListCommentsQuery } from './list-comments.query.js'; +import { buildComment, buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js'; + +describe('ListCommentsUseCase', () => { + let commentRepository: ICommentRepository; + let issueRepository: IIssueRepository; + let useCase: ListCommentsUseCase; + + beforeEach(() => { + commentRepository = mockCommentRepository(); + issueRepository = mockIssueRepository(); + useCase = new ListCommentsUseCase(commentRepository, issueRepository, mockAuthorExpander()); + }); + + it('forwards pagination params and returns the next cursor', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + vi.mocked(commentRepository.findByIssue).mockResolvedValue({ + list: [buildComment({ id: 'comment-2' })], + nextCursor: 'comment-2', + }); + + const result = await useCase.execute(new ListCommentsQuery(1, 'user-1', 1, 'comment-1')); + + expect(commentRepository.findByIssue).toHaveBeenCalledWith({ issueId: 1, limit: 1, cursor: 'comment-1' }); + expect(result.list.map((comment) => comment.id)).toEqual(['comment-2']); + expect(result.nextCursor).toBe('comment-2'); + }); + + it('throws IssueNotFoundException when the issue is not visible to the user', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new ListCommentsQuery(1, 'user-2', 50))).rejects.toThrow(IssueNotFoundException); + expect(commentRepository.findByIssue).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts new file mode 100644 index 0000000..951bde3 --- /dev/null +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable } 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 { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { ListCommentsQuery } from './list-comments.query.js'; +import { CommentDto } from '../../dto/comment.dto.js'; +import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js'; + +@Injectable() +export class ListCommentsUseCase { + constructor( + @Inject(COMMENT_REPOSITORY) + private readonly commentRepository: ICommentRepository, + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + private readonly commentAuthorExpander: CommentAuthorExpanderService, + ) {} + + async execute(query: ListCommentsQuery): Promise<{ list: CommentDto[]; nextCursor: string | null }> { + const issue = await this.issueRepository.findById(query.issueId, query.userId); + if (!issue) { + throw new IssueNotFoundException(query.issueId); + } + + const { list, nextCursor } = await this.commentRepository.findByIssue({ + issueId: query.issueId, + limit: query.limit, + cursor: query.cursor, + }); + + return { list: await this.commentAuthorExpander.expandAll(list), nextCursor }; + } +} diff --git a/src/modules/comment/application/use-cases/test-helpers.ts b/src/modules/comment/application/use-cases/test-helpers.ts new file mode 100644 index 0000000..798956e --- /dev/null +++ b/src/modules/comment/application/use-cases/test-helpers.ts @@ -0,0 +1,61 @@ +import { 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 { Comment } from '../../domain/entities/comment.entity.js'; +import { Issue } from '../../../issue/domain/entities/issue.entity.js'; +import { CommentAuthorExpanderService } from '../services/comment-author-expander.service.js'; +import { CommentApplicationMapper } from '../mappers/comment-application.mapper.js'; + +export const buildComment = (overrides: Partial<{ id: string; issueId: number; authorId: string; deletedAt: Date | null }> = {}) => + Comment.reconstitute({ + id: overrides.id ?? 'comment-1', + content: 'Original', + issueId: overrides.issueId ?? 1, + authorId: overrides.authorId ?? 'user-1', + editedAt: null, + deletedAt: overrides.deletedAt ?? null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + +export const buildIssue = () => + Issue.reconstitute({ + id: 1, + key: 'ISSUE-1', + title: 'Issue', + content: null, + status: 'OPEN', + priority: 0, + assignee: null, + reporter: 'user-1', + projectId: 'project-1', + dueDate: null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + +export const mockCommentRepository = (): ICommentRepository => ({ + findById: vi.fn(), + findByIssue: vi.fn(), + create: vi.fn(), + update: vi.fn(), +}); + +export const mockIssueRepository = (): IIssueRepository => ({ + findById: vi.fn(), + findByCKey: vi.fn(), + findAll: vi.fn(), + countByStatus: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), +}); + +/** Экспандер без похода за пользователями: author всегда null */ +export const mockAuthorExpander = () => { + const toDto = (comment: Comment) => CommentApplicationMapper.toDto(comment, new Map()); + return { + expand: vi.fn(async (comment: Comment) => toDto(comment)), + expandAll: vi.fn(async (comments: Comment[]) => comments.map(toDto)), + } as unknown as CommentAuthorExpanderService; +}; diff --git a/src/modules/comment/application/use-cases/update-comment/update-comment.command.ts b/src/modules/comment/application/use-cases/update-comment/update-comment.command.ts new file mode 100644 index 0000000..308b41f --- /dev/null +++ b/src/modules/comment/application/use-cases/update-comment/update-comment.command.ts @@ -0,0 +1,7 @@ +export class UpdateCommentCommand { + constructor( + public readonly id: string, + public readonly userId: string, + public readonly content: string, + ) {} +} diff --git a/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts new file mode 100644 index 0000000..01dd9b8 --- /dev/null +++ b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts @@ -0,0 +1,73 @@ +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 { CommentNotFoundException } from '../../../domain/exceptions/comment-not-found.exception.js'; +import { CommentForbiddenException } from '../../../domain/exceptions/comment-forbidden.exception.js'; +import { CommentEventsService } from '../../services/comment-events.service.js'; +import { CommentAccessService } from '../../services/comment-access.service.js'; +import { UpdateCommentUseCase } from './update-comment.use-case.js'; +import { UpdateCommentCommand } from './update-comment.command.js'; +import { buildComment, buildIssue, mockAuthorExpander, mockCommentRepository, mockIssueRepository } from '../test-helpers.js'; + +describe('UpdateCommentUseCase', () => { + let commentRepository: ICommentRepository; + let issueRepository: IIssueRepository; + let commentEventsService: CommentEventsService; + let useCase: UpdateCommentUseCase; + let received: unknown[]; + + beforeEach(() => { + commentRepository = mockCommentRepository(); + issueRepository = mockIssueRepository(); + commentEventsService = new CommentEventsService(); + useCase = new UpdateCommentUseCase( + commentRepository, + new CommentAccessService(commentRepository, issueRepository), + mockAuthorExpander(), + commentEventsService, + ); + received = []; + commentEventsService.onEvents().subscribe((event) => received.push(event)); + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + }); + + it('updates the content and emits comment.updated', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(buildComment()); + + const dto = await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed')); + + expect(dto.content).toBe('Changed'); + expect(dto.editedAt).toBeInstanceOf(Date); + expect(commentRepository.update).toHaveBeenCalledTimes(1); + expect(received).toEqual([{ type: 'comment.updated', comment: dto }]); + }); + + it('skips saving and events when content did not change', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(buildComment()); + + await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original')); + + expect(commentRepository.update).not.toHaveBeenCalled(); + expect(received).toEqual([]); + }); + + it('throws CommentForbiddenException for a non-author', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(buildComment()); + + await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-2', 'Changed'))).rejects.toThrow(CommentForbiddenException); + expect(commentRepository.update).not.toHaveBeenCalled(); + }); + + it('throws CommentNotFoundException for a deleted comment', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(buildComment({ deletedAt: new Date() })); + + await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(CommentNotFoundException); + }); + + it('throws CommentNotFoundException when the issue is not visible to the user', async () => { + vi.mocked(commentRepository.findById).mockResolvedValue(buildComment()); + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(CommentNotFoundException); + }); +}); diff --git a/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts new file mode 100644 index 0000000..b30b127 --- /dev/null +++ b/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { COMMENT_REPOSITORY, type ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js'; +import { UpdateCommentCommand } from './update-comment.command.js'; +import { CommentDto } from '../../dto/comment.dto.js'; +import { CommentAuthorExpanderService } from '../../services/comment-author-expander.service.js'; +import { CommentEventsService } from '../../services/comment-events.service.js'; +import { CommentAccessService } from '../../services/comment-access.service.js'; + +@Injectable() +export class UpdateCommentUseCase { + constructor( + @Inject(COMMENT_REPOSITORY) + private readonly commentRepository: ICommentRepository, + private readonly commentAccess: CommentAccessService, + private readonly commentAuthorExpander: CommentAuthorExpanderService, + private readonly commentEventsService: CommentEventsService, + ) {} + + async execute(command: UpdateCommentCommand): Promise { + const comment = await this.commentAccess.getVisibleComment(command.id, command.userId); + + const changed = comment.edit(command.content, command.userId); + if (changed) { + await this.commentRepository.update(comment); + } + + const dto = await this.commentAuthorExpander.expand(comment); + + if (changed) { + this.commentEventsService.emit('comment.updated', dto); + } + + return dto; + } +} diff --git a/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.query.ts b/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.query.ts new file mode 100644 index 0000000..84986d2 --- /dev/null +++ b/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.query.ts @@ -0,0 +1,6 @@ +export class WatchIssueCommentsQuery { + constructor( + public readonly issueId: number, + public readonly userId: string, + ) {} +} diff --git a/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.spec.ts b/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.spec.ts new file mode 100644 index 0000000..2f70ecc --- /dev/null +++ b/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.spec.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { CommentEventsService, type CommentEvent } from '../../services/comment-events.service.js'; +import { CommentApplicationMapper } from '../../mappers/comment-application.mapper.js'; +import { WatchIssueCommentsUseCase } from './watch-issue-comments.use-case.js'; +import { WatchIssueCommentsQuery } from './watch-issue-comments.query.js'; +import { buildComment, buildIssue, mockIssueRepository } from '../test-helpers.js'; + +const dtoForIssue = (issueId: number) => CommentApplicationMapper.toDto(buildComment({ id: `comment-of-${issueId}`, issueId }), new Map()); + +describe('WatchIssueCommentsUseCase', () => { + let issueRepository: IIssueRepository; + let commentEventsService: CommentEventsService; + let useCase: WatchIssueCommentsUseCase; + + beforeEach(() => { + issueRepository = mockIssueRepository(); + commentEventsService = new CommentEventsService(); + useCase = new WatchIssueCommentsUseCase(issueRepository, commentEventsService); + }); + + it('streams only events of the requested issue', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + const received: CommentEvent[] = []; + + const events$ = await useCase.execute(new WatchIssueCommentsQuery(1, 'user-1')); + events$.subscribe((event) => received.push(event)); + + commentEventsService.emit('comment.created', dtoForIssue(1)); + commentEventsService.emit('comment.created', dtoForIssue(2)); + + expect(received.map((event) => event.comment.id)).toEqual(['comment-of-1']); + }); + + it('throws IssueNotFoundException when the issue is not visible to the user', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(null); + + await expect(useCase.execute(new WatchIssueCommentsQuery(1, 'user-2'))).rejects.toThrow(IssueNotFoundException); + }); +}); diff --git a/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.ts b/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.ts new file mode 100644 index 0000000..8970563 --- /dev/null +++ b/src/modules/comment/application/use-cases/watch-issue-comments/watch-issue-comments.use-case.ts @@ -0,0 +1,28 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { filter, type Observable } from 'rxjs'; +import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js'; +import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { WatchIssueCommentsQuery } from './watch-issue-comments.query.js'; +import { type CommentEvent, CommentEventsService } from '../../services/comment-events.service.js'; + +/** + * Поток событий комментариев одной задачи для SSE. Доступ к задаче + * проверяется один раз — при подключении. + */ +@Injectable() +export class WatchIssueCommentsUseCase { + constructor( + @Inject(ISSUE_REPOSITORY) + private readonly issueRepository: IIssueRepository, + private readonly commentEventsService: CommentEventsService, + ) {} + + async execute(query: WatchIssueCommentsQuery): Promise> { + const issue = await this.issueRepository.findById(query.issueId, query.userId); + if (!issue) { + throw new IssueNotFoundException(query.issueId); + } + + return this.commentEventsService.onEvents().pipe(filter((event) => event.comment.issueId === query.issueId)); + } +} diff --git a/src/modules/comment/comment.module.ts b/src/modules/comment/comment.module.ts new file mode 100644 index 0000000..b2c60cf --- /dev/null +++ b/src/modules/comment/comment.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js'; +import { IssueModule } from '../issue/issue.module.js'; +import { UserModule } from '../user/user.module.js'; +import { COMMENT_REPOSITORY } from './domain/repositories/comment.repository.interface.js'; +import { CommentPrismaRepository } from './infrastructure/persistence/prisma/repositories/comment.prisma-repository.js'; +import { CommentController } from './presentation/controllers/comment.controller.js'; +import { CreateCommentUseCase } from './application/use-cases/create-comment/create-comment.use-case.js'; +import { ListCommentsUseCase } from './application/use-cases/list-comments/list-comments.use-case.js'; +import { UpdateCommentUseCase } from './application/use-cases/update-comment/update-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 { CommentAccessService } from './application/services/comment-access.service.js'; +import { CommentAuthorExpanderService } from './application/services/comment-author-expander.service.js'; +import { CommentEventsService } from './application/services/comment-events.service.js'; + +@Module({ + imports: [PrismaModule, IssueModule, UserModule], + controllers: [CommentController], + providers: [ + CreateCommentUseCase, + ListCommentsUseCase, + UpdateCommentUseCase, + DeleteCommentUseCase, + WatchIssueCommentsUseCase, + CommentAccessService, + CommentAuthorExpanderService, + CommentEventsService, + { + // Domain видит только интерфейс ICommentRepository, + // конкретную реализацию (Prisma) подставляет DI-контейнер здесь + provide: COMMENT_REPOSITORY, + useClass: CommentPrismaRepository, + }, + ], +}) +export class CommentModule {} diff --git a/src/modules/comment/domain/entities/comment.entity.spec.ts b/src/modules/comment/domain/entities/comment.entity.spec.ts new file mode 100644 index 0000000..7d14ab2 --- /dev/null +++ b/src/modules/comment/domain/entities/comment.entity.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { Comment } from './comment.entity.js'; +import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js'; + +const buildComment = (overrides: Partial<{ authorId: string | null; deletedAt: Date | null }> = {}) => + Comment.reconstitute({ + id: 'comment-1', + content: 'Original', + issueId: 1, + authorId: overrides.authorId === undefined ? 'user-1' : overrides.authorId, + editedAt: null, + deletedAt: overrides.deletedAt ?? null, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + +describe('Comment', () => { + describe('create', () => { + it('creates a new comment without id and edit/delete marks', () => { + const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: ' Hello ' }); + + expect(comment.id).toBeNull(); + expect(comment.content).toBe('Hello'); + expect(comment.editedAt).toBeNull(); + expect(comment.isDeleted).toBe(false); + }); + + it('rejects empty content', () => { + expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: '' })).toThrow(); + }); + }); + + describe('edit', () => { + it('changes content and sets editedAt', () => { + const comment = buildComment(); + + expect(comment.edit('Changed', 'user-1')).toBe(true); + expect(comment.content).toBe('Changed'); + expect(comment.editedAt).toBeInstanceOf(Date); + }); + + it('is a no-op when content is the same', () => { + const comment = buildComment(); + + expect(comment.edit(' Original ', 'user-1')).toBe(false); + expect(comment.editedAt).toBeNull(); + }); + + it('forbids editing by someone other than the author', () => { + expect(() => buildComment().edit('Changed', 'user-2')).toThrow(CommentForbiddenException); + }); + + it('forbids editing when the author account is deleted', () => { + expect(() => buildComment({ authorId: null }).edit('Changed', 'user-1')).toThrow(CommentForbiddenException); + }); + + it('forbids editing a deleted comment', () => { + expect(() => buildComment({ deletedAt: new Date() }).edit('Changed', 'user-1')).toThrow(); + }); + }); + + describe('delete', () => { + it('soft-deletes the comment', () => { + const comment = buildComment(); + + comment.delete('user-1'); + + expect(comment.isDeleted).toBe(true); + expect(comment.deletedAt).toBeInstanceOf(Date); + }); + + it('forbids deletion by someone other than the author', () => { + expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException); + }); + }); +}); diff --git a/src/modules/comment/domain/entities/comment.entity.ts b/src/modules/comment/domain/entities/comment.entity.ts new file mode 100644 index 0000000..7fb3c3f --- /dev/null +++ b/src/modules/comment/domain/entities/comment.entity.ts @@ -0,0 +1,129 @@ +import { CommentContent } from '../value-objects/comment-content.vo.js'; +import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js'; + +interface CommentProps { + id: string | null; + content: CommentContent; + issueId: number; + /** null — автор удалил аккаунт */ + authorId: string | null; + editedAt: Date | null; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +/** + * Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt), + * чтобы в ленте можно было показать «комментарий удалён». + * + * `id` (uuid v7) генерируется базой, поэтому у нового комментария + * он равен null до сохранения в репозитории. + */ +export class Comment { + private constructor(private readonly props: CommentProps) {} + + /** Фабричный метод для создания НОВОГО комментария */ + static create(params: { issueId: number; authorId: string; content: string }): Comment { + const now = new Date(); + + return new Comment({ + id: null, + content: CommentContent.create(params.content), + issueId: params.issueId, + authorId: params.authorId, + editedAt: null, + deletedAt: null, + createdAt: now, + updatedAt: now, + }); + } + + /** Восстановление сущности из персистентного слоя */ + static reconstitute(props: { + id: string; + content: string; + issueId: number; + authorId: string | null; + editedAt: Date | null; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; + }): Comment { + return new Comment({ + ...props, + content: CommentContent.create(props.content), + }); + } + + get id(): string | null { + return this.props.id; + } + + get content(): string { + return this.props.content.value; + } + + get issueId(): number { + return this.props.issueId; + } + + get authorId(): string | null { + return this.props.authorId; + } + + get editedAt(): Date | null { + return this.props.editedAt; + } + + get deletedAt(): Date | null { + return this.props.deletedAt; + } + + get isDeleted(): boolean { + return this.props.deletedAt !== null; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + /** Меняет текст. Возвращает false, если текст не изменился */ + edit(content: string, userId: string): boolean { + this.assertCanModify(userId); + + const next = CommentContent.create(content); + if (next.equals(this.props.content)) { + return false; + } + + const now = new Date(); + this.props.content = next; + this.props.editedAt = now; + this.props.updatedAt = now; + return true; + } + + delete(userId: string): void { + this.assertCanModify(userId); + + const now = new Date(); + this.props.deletedAt = now; + this.props.updatedAt = now; + } + + /** Править и удалять комментарий может только его автор, и только пока он не удалён */ + private assertCanModify(userId: string): void { + if (this.isDeleted) { + throw new Error('Комментарий уже удалён'); + } + + if (this.props.authorId !== userId) { + throw new CommentForbiddenException(this.props.id ?? 'new'); + } + } +} diff --git a/src/modules/comment/domain/exceptions/comment-forbidden.exception.ts b/src/modules/comment/domain/exceptions/comment-forbidden.exception.ts new file mode 100644 index 0000000..4b29325 --- /dev/null +++ b/src/modules/comment/domain/exceptions/comment-forbidden.exception.ts @@ -0,0 +1,6 @@ +export class CommentForbiddenException extends Error { + constructor(id: string) { + super(`Нет прав на изменение комментария id=${id}`); + this.name = 'CommentForbiddenException'; + } +} diff --git a/src/modules/comment/domain/exceptions/comment-not-found.exception.ts b/src/modules/comment/domain/exceptions/comment-not-found.exception.ts new file mode 100644 index 0000000..a6c531f --- /dev/null +++ b/src/modules/comment/domain/exceptions/comment-not-found.exception.ts @@ -0,0 +1,6 @@ +export class CommentNotFoundException extends Error { + constructor(id: string) { + super(`Комментарий с id=${id} не найден`); + this.name = 'CommentNotFoundException'; + } +} diff --git a/src/modules/comment/domain/repositories/comment.repository.interface.ts b/src/modules/comment/domain/repositories/comment.repository.interface.ts new file mode 100644 index 0000000..1654f14 --- /dev/null +++ b/src/modules/comment/domain/repositories/comment.repository.interface.ts @@ -0,0 +1,16 @@ +import { Comment } from '../entities/comment.entity.js'; + +/** DI-токен, т.к. интерфейсы стираются при компиляции TS */ +export const COMMENT_REPOSITORY = Symbol('COMMENT_REPOSITORY'); + +export interface ICommentRepository { + /** Возвращает и мягко удалённые комментарии — решение принимает вызывающий */ + findById(id: string): Promise; + /** + * Комментарии задачи от старых к новым, включая удалённые. + * Курсорная пагинация: cursor — id последнего полученного комментария. + */ + findByIssue(params: { issueId: number; limit: number; cursor?: string }): Promise<{ list: Comment[]; nextCursor: string | null }>; + create(comment: Comment): Promise; + update(comment: Comment): Promise; +} diff --git a/src/modules/comment/domain/value-objects/comment-content.vo.spec.ts b/src/modules/comment/domain/value-objects/comment-content.vo.spec.ts new file mode 100644 index 0000000..26c04f5 --- /dev/null +++ b/src/modules/comment/domain/value-objects/comment-content.vo.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { CommentContent } from './comment-content.vo.js'; + +describe('CommentContent', () => { + it('trims the value', () => { + expect(CommentContent.create(' hello ').value).toBe('hello'); + }); + + it('rejects empty content', () => { + expect(() => CommentContent.create(' ')).toThrow('Комментарий не может быть пустым'); + }); + + it('rejects content longer than the limit', () => { + expect(() => CommentContent.create('a'.repeat(CommentContent.MAX_LENGTH + 1))).toThrow( + `Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`, + ); + }); +}); diff --git a/src/modules/comment/domain/value-objects/comment-content.vo.ts b/src/modules/comment/domain/value-objects/comment-content.vo.ts new file mode 100644 index 0000000..5fd686d --- /dev/null +++ b/src/modules/comment/domain/value-objects/comment-content.vo.ts @@ -0,0 +1,27 @@ +export class CommentContent { + static readonly MAX_LENGTH = 10000; + + private constructor(private readonly _value: string) {} + + static create(value: string): CommentContent { + const trimmed = value?.trim(); + + if (!trimmed) { + throw new Error('Комментарий не может быть пустым'); + } + + if (trimmed.length > CommentContent.MAX_LENGTH) { + throw new Error(`Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`); + } + + return new CommentContent(trimmed); + } + + get value(): string { + return this._value; + } + + equals(other: CommentContent): boolean { + return this._value === other.value; + } +} diff --git a/src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts b/src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts new file mode 100644 index 0000000..ea5a847 --- /dev/null +++ b/src/modules/comment/infrastructure/persistence/prisma/mappers/comment.mapper.ts @@ -0,0 +1,36 @@ +import type { Comment as PrismaComment, Prisma } from '../../../../../../generated/prisma/client.js'; +import { Comment } from '../../../../domain/entities/comment.entity.js'; + +export class CommentPrismaMapper { + static toDomain(record: PrismaComment): Comment { + return Comment.reconstitute({ + id: record.id, + content: record.content, + issueId: record.issueId, + authorId: record.authorId, + editedAt: record.editedAt, + deletedAt: record.deletedAt, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }); + } + + static toCreateInput(comment: Comment): Prisma.CommentUncheckedCreateInput { + return { + content: comment.content, + issueId: comment.issueId, + authorId: comment.authorId, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + }; + } + + static toUpdateInput(comment: Comment): Prisma.CommentUncheckedUpdateInput { + return { + content: comment.content, + editedAt: comment.editedAt, + deletedAt: comment.deletedAt, + updatedAt: comment.updatedAt, + }; + } +} diff --git a/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts b/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts new file mode 100644 index 0000000..08ab71f --- /dev/null +++ b/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js'; +import { Prisma } from '../../../../../../generated/prisma/client.js'; +import { Comment } from '../../../../domain/entities/comment.entity.js'; +import { ICommentRepository } from '../../../../domain/repositories/comment.repository.interface.js'; +import { CommentPrismaMapper } from '../mappers/comment.mapper.js'; +import { IssueNotFoundException } from '../../../../../issue/domain/exceptions/issue-not-found.exception.js'; + +const FOREIGN_KEY_VIOLATION = 'P2003'; + +@Injectable() +export class CommentPrismaRepository implements ICommentRepository { + constructor(private readonly prisma: PrismaAdapter) {} + + async findById(id: string): Promise { + const record = await this.prisma.comment.findUnique({ where: { id } }); + return record ? CommentPrismaMapper.toDomain(record) : null; + } + + async findByIssue(params: { issueId: number; limit: number; cursor?: string }): Promise<{ list: Comment[]; nextCursor: string | null }> { + const limit = Math.max(1, params.limit); + + // Берём на одну запись больше, чтобы понять, есть ли следующая страница. + // id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt. + const records = await this.prisma.comment.findMany({ + where: { issueId: params.issueId }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take: limit + 1, + ...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}), + }); + + const hasMore = records.length > limit; + const page = hasMore ? records.slice(0, limit) : records; + + return { + list: page.map(CommentPrismaMapper.toDomain), + nextCursor: hasMore ? page[page.length - 1]!.id : null, + }; + } + + async create(comment: Comment): Promise { + try { + const record = await this.prisma.comment.create({ + data: CommentPrismaMapper.toCreateInput(comment), + }); + + return CommentPrismaMapper.toDomain(record); + } catch (error) { + // Задачу удалили между проверкой доступа и вставкой + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) { + throw new IssueNotFoundException(comment.issueId); + } + throw error; + } + } + + async update(comment: Comment): Promise { + await this.prisma.comment.update({ + where: { id: comment.id as string }, + data: CommentPrismaMapper.toUpdateInput(comment), + }); + } +} diff --git a/src/modules/comment/presentation/controllers/comment.controller.ts b/src/modules/comment/presentation/controllers/comment.controller.ts new file mode 100644 index 0000000..b26fcef --- /dev/null +++ b/src/modules/comment/presentation/controllers/comment.controller.ts @@ -0,0 +1,101 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + HttpCode, + HttpStatus, + type MessageEvent, + NotFoundException, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Sse, + UseGuards, +} from '@nestjs/common'; +import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; +import { map, type Observable } from 'rxjs'; +import { CreateCommentUseCase } from '../../application/use-cases/create-comment/create-comment.use-case.js'; +import { ListCommentsUseCase } from '../../application/use-cases/list-comments/list-comments.use-case.js'; +import { UpdateCommentUseCase } from '../../application/use-cases/update-comment/update-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 { CreateCommentCommand } from '../../application/use-cases/create-comment/create-comment.command.js'; +import { ListCommentsQuery } from '../../application/use-cases/list-comments/list-comments.query.js'; +import { UpdateCommentCommand } from '../../application/use-cases/update-comment/update-comment.command.js'; +import { DeleteCommentCommand } from '../../application/use-cases/delete-comment/delete-comment.command.js'; +import { WatchIssueCommentsQuery } from '../../application/use-cases/watch-issue-comments/watch-issue-comments.query.js'; +import { CreateCommentRequestDto } from '../dto/create-comment.dto.js'; +import { UpdateCommentRequestDto } from '../dto/update-comment.dto.js'; +import { ListCommentsDto } from '../dto/list-comments.dto.js'; +import { CommentNotFoundException } from '../../domain/exceptions/comment-not-found.exception.js'; +import { CommentForbiddenException } from '../../domain/exceptions/comment-forbidden.exception.js'; +import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js'; +import { IssueVisibleGuard } from '../guards/issue-visible.guard.js'; + +@Controller() +export class CommentController { + constructor( + private readonly createCommentUseCase: CreateCommentUseCase, + private readonly listCommentsUseCase: ListCommentsUseCase, + private readonly updateCommentUseCase: UpdateCommentUseCase, + private readonly deleteCommentUseCase: DeleteCommentUseCase, + private readonly watchIssueCommentsUseCase: WatchIssueCommentsUseCase, + ) {} + + @Get('issues/:issueId/comments') + list(@Param('issueId', ParseIntPipe) issueId: number, @Query() query: ListCommentsDto, @Session() session: UserSession) { + return this.handleErrors(() => + this.listCommentsUseCase.execute(new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor)), + ); + } + + @Post('issues/:issueId/comments') + create(@Param('issueId', ParseIntPipe) issueId: number, @Body() dto: CreateCommentRequestDto, @Session() session: UserSession) { + return this.handleErrors(() => this.createCommentUseCase.execute(new CreateCommentCommand(issueId, session.user.id, dto.content))); + } + + /** + * SSE-поток комментариев одной задачи: клиент открывает его на странице + * задачи и закрывает при уходе. После переподключения клиенту стоит + * перезапросить список — события, пришедшие во время обрыва, не повторяются. + * Доступ проверяет IssueVisibleGuard — так клиент без доступа получает 404. + */ + @Sse('issues/:issueId/comments/events') + @UseGuards(IssueVisibleGuard) + events(@Param('issueId', ParseIntPipe) issueId: number, @Session() session: UserSession): Promise> { + return this.handleErrors(async () => { + const events$ = await this.watchIssueCommentsUseCase.execute(new WatchIssueCommentsQuery(issueId, session.user.id)); + return events$.pipe(map((event) => ({ type: event.type, data: event.comment }))); + }); + } + + @Patch('comments/:id') + update(@Param('id') id: string, @Body() dto: UpdateCommentRequestDto, @Session() session: UserSession) { + return this.handleErrors(() => this.updateCommentUseCase.execute(new UpdateCommentCommand(id, session.user.id, dto.content))); + } + + @Delete('comments/:id') + @HttpCode(HttpStatus.NO_CONTENT) + async delete(@Param('id') id: string, @Session() session: UserSession) { + await this.handleErrors(() => this.deleteCommentUseCase.execute(new DeleteCommentCommand(id, session.user.id))); + } + + /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */ + private async handleErrors(fn: () => Promise): Promise { + try { + return await fn(); + } catch (error) { + if (error instanceof CommentNotFoundException || error instanceof IssueNotFoundException) { + throw new NotFoundException(error.message); + } + if (error instanceof CommentForbiddenException) { + throw new ForbiddenException(error.message); + } + throw error; + } + } +} diff --git a/src/modules/comment/presentation/dto/create-comment.dto.ts b/src/modules/comment/presentation/dto/create-comment.dto.ts new file mode 100644 index 0000000..aabe716 --- /dev/null +++ b/src/modules/comment/presentation/dto/create-comment.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { CommentContent } from '../../domain/value-objects/comment-content.vo.js'; + +export class CreateCommentRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(CommentContent.MAX_LENGTH) + content: string; +} diff --git a/src/modules/comment/presentation/dto/list-comments.dto.ts b/src/modules/comment/presentation/dto/list-comments.dto.ts new file mode 100644 index 0000000..40b5c81 --- /dev/null +++ b/src/modules/comment/presentation/dto/list-comments.dto.ts @@ -0,0 +1,10 @@ +import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js'; + +export class ListCommentsDto { + @IntDecorator({ isOptional: true, min: 1, max: 100 }) + limit: number = 50; + + /** id последнего полученного комментария (nextCursor из предыдущего ответа) */ + @StringDecorator({ isOptional: true }) + cursor?: string; +} diff --git a/src/modules/comment/presentation/dto/update-comment.dto.ts b/src/modules/comment/presentation/dto/update-comment.dto.ts new file mode 100644 index 0000000..2a97cdf --- /dev/null +++ b/src/modules/comment/presentation/dto/update-comment.dto.ts @@ -0,0 +1,9 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { CommentContent } from '../../domain/value-objects/comment-content.vo.js'; + +export class UpdateCommentRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(CommentContent.MAX_LENGTH) + content: string; +} diff --git a/src/modules/comment/presentation/guards/issue-visible.guard.spec.ts b/src/modules/comment/presentation/guards/issue-visible.guard.spec.ts new file mode 100644 index 0000000..fcae112 --- /dev/null +++ b/src/modules/comment/presentation/guards/issue-visible.guard.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { BadRequestException, type ExecutionContext, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { CommentAccessService } from '../../application/services/comment-access.service.js'; +import { IssueVisibleGuard } from './issue-visible.guard.js'; + +const buildContext = (params: Record, userId?: string) => + ({ + switchToHttp: () => ({ + getRequest: () => ({ params, session: userId ? { user: { id: userId } } : undefined }), + }), + }) as unknown as ExecutionContext; + +const buildGuard = (visible: boolean) => { + const commentAccess = { isIssueVisible: vi.fn().mockResolvedValue(visible) } as unknown as CommentAccessService; + return { guard: new IssueVisibleGuard(commentAccess), commentAccess }; +}; + +describe('IssueVisibleGuard', () => { + it('allows access to a visible issue', async () => { + const { guard, commentAccess } = buildGuard(true); + + await expect(guard.canActivate(buildContext({ issueId: '1' }, 'user-1'))).resolves.toBe(true); + expect(commentAccess.isIssueVisible).toHaveBeenCalledWith(1, 'user-1'); + }); + + it('throws NotFoundException when the issue is not visible', async () => { + const { guard } = buildGuard(false); + + await expect(guard.canActivate(buildContext({ issueId: '1' }, 'user-2'))).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException for a non-numeric issueId', async () => { + const { guard, commentAccess } = buildGuard(true); + + await expect(guard.canActivate(buildContext({ issueId: 'abc' }, 'user-1'))).rejects.toThrow(BadRequestException); + expect(commentAccess.isIssueVisible).not.toHaveBeenCalled(); + }); + + it('throws UnauthorizedException without a session', async () => { + const { guard } = buildGuard(true); + + await expect(guard.canActivate(buildContext({ issueId: '1' }))).rejects.toThrow(UnauthorizedException); + }); +}); diff --git a/src/modules/comment/presentation/guards/issue-visible.guard.ts b/src/modules/comment/presentation/guards/issue-visible.guard.ts new file mode 100644 index 0000000..4409ea0 --- /dev/null +++ b/src/modules/comment/presentation/guards/issue-visible.guard.ts @@ -0,0 +1,46 @@ +import { + BadRequestException, + type CanActivate, + type ExecutionContext, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import type { UserSession } from '@thallesp/nestjs-better-auth'; +import { CommentAccessService } from '../../application/services/comment-access.service.js'; +import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js'; + +/** + * Проверяет доступ к задаче из `:issueId` ДО вызова обработчика. + * + * Нужен SSE-эндпоинтам: при наличии глобального interceptor'а Nest вызывает + * @Sse-обработчик уже после отправки заголовков 200, и ошибка из него доходит + * до клиента только как `event: error`. Guard же отвечает честным 404. + * + * Сессию в request кладёт глобальный AuthGuard, который выполняется раньше. + */ +@Injectable() +export class IssueVisibleGuard implements CanActivate { + constructor(private readonly commentAccess: CommentAccessService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ params: Record; session?: UserSession }>(); + + const userId = request.session?.user.id; + if (!userId) { + throw new UnauthorizedException(); + } + + // Пайпы (ParseIntPipe) выполняются после guard'ов — парсим сами + const issueId = Number(request.params.issueId); + if (!Number.isInteger(issueId)) { + throw new BadRequestException('Validation failed (numeric string is expected)'); + } + + if (!(await this.commentAccess.isIssueVisible(issueId, userId))) { + throw new NotFoundException(new IssueNotFoundException(issueId).message); + } + + return true; + } +}