comments implement
This commit is contained in:
1 parent
9e0bef2527
commit
4b6ecfbf5d
41 files changed
+1350
-13
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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<T> {
|
||||
@@ -22,26 +23,23 @@ function isPaginatedResult<T>(value: unknown): value is PaginatedResult<T> {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransformResponseInterceptor<T> implements NestInterceptor<
|
||||
T,
|
||||
T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>
|
||||
> {
|
||||
intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<T>,
|
||||
): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
|
||||
// GraphQL уже оборачивает ответ в {data, errors} на уровне транспорта —
|
||||
// конверт применяем только к HTTP (REST) контексту, чтобы не задваивать его.
|
||||
export class TransformResponseInterceptor<T> implements NestInterceptor<T, T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
|
||||
if (context.getType() !== 'http') {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
// SSE-обработчик возвращает поток MessageEvent ({type, data, id}) — его
|
||||
// сериализует сам Nest. Обёртка в {data, code} спрятала бы type внутрь data,
|
||||
// и клиентский addEventListener('<type>') перестал бы срабатывать.
|
||||
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<T>(result)) {
|
||||
return {
|
||||
data: result.data ?? null,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, UserDto>): 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<boolean> {
|
||||
return (await this.issueRepository.findById(issueId, userId)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает неудалённый комментарий, если его задача видна пользователю.
|
||||
* Иначе — CommentNotFoundException, чтобы не раскрывать существование
|
||||
* комментариев к чужим задачам.
|
||||
*/
|
||||
async getVisibleComment(id: string, userId: string): Promise<Comment> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<CommentDto> {
|
||||
const [dto] = await this.expandAll([comment]);
|
||||
return dto!;
|
||||
}
|
||||
|
||||
async expandAll(comments: Comment[]): Promise<CommentDto[]> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<CommentEvent>();
|
||||
|
||||
emit(type: CommentEventType, comment: CommentDto): void {
|
||||
this.events$.next({ type, comment });
|
||||
}
|
||||
|
||||
onEvents(): Observable<CommentEvent> {
|
||||
return this.events$.asObservable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class CreateCommentCommand {
|
||||
constructor(
|
||||
public readonly issueId: number,
|
||||
public readonly authorId: string,
|
||||
public readonly content: string,
|
||||
) {}
|
||||
}
|
||||
+55
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<CommentDto> {
|
||||
// Комментировать можно только видимую пользователю задачу
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteCommentCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+58
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class ListCommentsQuery {
|
||||
constructor(
|
||||
public readonly issueId: number,
|
||||
public readonly userId: string,
|
||||
public readonly limit: number,
|
||||
public readonly cursor?: string,
|
||||
) {}
|
||||
}
|
||||
+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 { 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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export class UpdateCommentCommand {
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly userId: string,
|
||||
public readonly content: string,
|
||||
) {}
|
||||
}
|
||||
+73
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<CommentDto> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export class WatchIssueCommentsQuery {
|
||||
constructor(
|
||||
public readonly issueId: number,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+41
@@ -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);
|
||||
});
|
||||
});
|
||||
+28
@@ -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<Observable<CommentEvent>> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class CommentForbiddenException extends Error {
|
||||
constructor(id: string) {
|
||||
super(`Нет прав на изменение комментария id=${id}`);
|
||||
this.name = 'CommentForbiddenException';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class CommentNotFoundException extends Error {
|
||||
constructor(id: string) {
|
||||
super(`Комментарий с id=${id} не найден`);
|
||||
this.name = 'CommentNotFoundException';
|
||||
}
|
||||
}
|
||||
@@ -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<Comment | null>;
|
||||
/**
|
||||
* Комментарии задачи от старых к новым, включая удалённые.
|
||||
* Курсорная пагинация: cursor — id последнего полученного комментария.
|
||||
*/
|
||||
findByIssue(params: { issueId: number; limit: number; cursor?: string }): Promise<{ list: Comment[]; nextCursor: string | null }>;
|
||||
create(comment: Comment): Promise<Comment>;
|
||||
update(comment: Comment): Promise<void>;
|
||||
}
|
||||
@@ -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} символов`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+63
@@ -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<Comment | null> {
|
||||
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<Comment> {
|
||||
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<void> {
|
||||
await this.prisma.comment.update({
|
||||
where: { id: comment.id as string },
|
||||
data: CommentPrismaMapper.toUpdateInput(comment),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<Observable<MessageEvent>> {
|
||||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<{ params: Record<string, string | undefined>; 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user