comments implement

This commit is contained in:
vadim committed 2026-10-01 12:02:03 +03:00
1 parent 9e0bef2527
commit 4b6ecfbf5d
41 files changed
+1350 -13

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"singleQuote": true, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 120 "printWidth": 140
} }
+2
View File
@@ -6,6 +6,7 @@ import { ProjectModule } from './modules/project/project.module.js';
import { UserModule } from './modules/user/user.module.js'; import { UserModule } from './modules/user/user.module.js';
import { StatusModule } from './modules/status/status.module.js'; import { StatusModule } from './modules/status/status.module.js';
import { NotificationModule } from './modules/notification/notification.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 { AuthModule } from '@thallesp/nestjs-better-auth';
import { auth } from './lib/auth.js'; import { auth } from './lib/auth.js';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
@@ -46,6 +47,7 @@ import { join } from 'node:path';
UserModule, UserModule,
StatusModule, StatusModule,
NotificationModule, NotificationModule,
CommentModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],
@@ -1,6 +1,7 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { SSE_METADATA } from '@nestjs/common/constants.js';
import { PaginationMeta } from '../interfaces/pagination-meta.interface.js'; import { PaginationMeta } from '../interfaces/pagination-meta.interface.js';
export interface ResponseEnvelope<T> { export interface ResponseEnvelope<T> {
@@ -22,26 +23,23 @@ function isPaginatedResult<T>(value: unknown): value is PaginatedResult<T> {
} }
@Injectable() @Injectable()
export class TransformResponseInterceptor<T> implements NestInterceptor< export class TransformResponseInterceptor<T> implements NestInterceptor<T, T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
T, intercept(context: ExecutionContext, next: CallHandler<T>): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
// GraphQL уже оборачивает ответ в {data, errors} на уровне транспорта —
// конверт применяем только к HTTP (REST) контексту, чтобы не задваивать его.
if (context.getType() !== 'http') { if (context.getType() !== 'http') {
return next.handle(); 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(); const response = context.switchToHttp().getResponse();
return next.handle().pipe( return next.handle().pipe(
map((result) => { map((result) => {
// Пагинированные ответы ({data, meta}) разворачиваем на верхний
// уровень конверта, а не оборачиваем повторно как data.data.
if (isPaginatedResult<T>(result)) { if (isPaginatedResult<T>(result)) {
return { return {
data: result.data ?? null, 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,
) {}
}
@@ -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,
) {}
}
@@ -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,
) {}
}
@@ -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,
) {}
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
export class WatchIssueCommentsQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
) {}
}
@@ -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);
});
});
@@ -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));
}
}
+37
View File
@@ -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,
};
}
}
@@ -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;
}
}