diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.query.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.query.ts index 10f1ecf..1211fb1 100644 --- a/src/modules/comment/application/use-cases/list-comments/list-comments.query.ts +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.query.ts @@ -1,8 +1,11 @@ +import type { CommentListDirection } from '../../../domain/repositories/comment.repository.interface.js'; + export class ListCommentsQuery { constructor( public readonly issueId: number, public readonly userId: string, public readonly limit: number, public readonly cursor?: string, + public readonly direction: CommentListDirection = 'forward', ) {} } diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts index 504d572..f18b50e 100644 --- a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.spec.ts @@ -32,11 +32,30 @@ describe('ListCommentsUseCase', () => { 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(commentRepository.findByIssue).toHaveBeenCalledWith({ + issueId: 1, + limit: 1, + cursor: 'comment-1', + direction: 'forward', + }); expect(result.list.map((comment) => comment.id)).toEqual(['comment-2']); expect(result.nextCursor).toBe('comment-2'); }); + it('forwards the backward direction for chat-style paging', async () => { + vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue()); + vi.mocked(commentRepository.findByIssue).mockResolvedValue({ list: [], nextCursor: null }); + + await useCase.execute(new ListCommentsQuery(1, 'user-1', 20, 'comment-5', 'backward')); + + expect(commentRepository.findByIssue).toHaveBeenCalledWith({ + issueId: 1, + limit: 20, + cursor: 'comment-5', + direction: 'backward', + }); + }); + it('throws IssueNotFoundException when the issue is not visible to the user', async () => { vi.mocked(issueRepository.findById).mockResolvedValue(null); diff --git a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts index e63bd7d..ecc3472 100644 --- a/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts +++ b/src/modules/comment/application/use-cases/list-comments/list-comments.use-case.ts @@ -32,6 +32,7 @@ export class ListCommentsUseCase { issueId: query.issueId, limit: query.limit, cursor: query.cursor, + direction: query.direction, }); return { list: await this.commentExpander.expandAll(list), nextCursor }; diff --git a/src/modules/comment/domain/repositories/comment.repository.interface.ts b/src/modules/comment/domain/repositories/comment.repository.interface.ts index 207c4cd..9ccff41 100644 --- a/src/modules/comment/domain/repositories/comment.repository.interface.ts +++ b/src/modules/comment/domain/repositories/comment.repository.interface.ts @@ -1,5 +1,9 @@ import { Comment } from '../entities/comment.entity.js'; +/** Направление пагинации комментариев: forward — от старых к новым, backward — от новых к старым */ +export const COMMENT_LIST_DIRECTIONS = ['forward', 'backward'] as const; +export type CommentListDirection = (typeof COMMENT_LIST_DIRECTIONS)[number]; + /** DI-токен, т.к. интерфейсы стираются при компиляции TS */ export const COMMENT_REPOSITORY = Symbol('COMMENT_REPOSITORY'); @@ -8,12 +12,16 @@ export interface ICommentRepository { findById(id: string): Promise; /** * Комментарии задачи от старых к новым, включая удалённые. - * Курсорная пагинация: cursor — id последнего полученного комментария. + * Курсорная пагинация. forward: cursor — id последнего полученного комментария, + * отдаются более новые. backward: cursor — id самого старого полученного, + * отдаются более старые (без cursor — последняя страница). Список всегда + * отсортирован от старых к новым. */ findByIssue(params: { issueId: number; limit: number; cursor?: string; + direction?: CommentListDirection; }): Promise<{ list: Comment[]; nextCursor: string | null }>; /** * Создание и обновление сохраняют и вложения (fileIds) — атомарно с комментарием. diff --git a/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts b/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts index f0999e6..d2770ff 100644 --- a/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts +++ b/src/modules/comment/infrastructure/persistence/prisma/repositories/comment.prisma-repository.ts @@ -2,7 +2,10 @@ 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 type { + CommentListDirection, + 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'; import { FileNotFoundException } from '../../../../../file/domain/exceptions/file-not-found.exception.js'; @@ -27,26 +30,29 @@ export class CommentPrismaRepository implements ICommentRepository { issueId: number; limit: number; cursor?: string; + direction?: CommentListDirection; }): Promise<{ list: Comment[]; nextCursor: string | null }> { const limit = Math.max(1, params.limit); + const order = params.direction === 'backward' ? 'desc' : 'asc'; // Берём на одну запись больше, чтобы понять, есть ли следующая страница. // id (uuid v7) — тай-брейкер для комментариев с одинаковым createdAt. const records = await this.prisma.comment.findMany({ where: { issueId: params.issueId }, include: COMMENT_INCLUDE, - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + orderBy: [{ createdAt: order }, { id: order }], take: limit + 1, ...(params.cursor ? { cursor: { id: params.cursor }, skip: 1 } : {}), }); const hasMore = records.length > limit; const page = hasMore ? records.slice(0, limit) : records; + // Курсор — последняя запись страницы в порядке выборки: для backward это самая старая + const nextCursor = hasMore ? page[page.length - 1]!.id : null; - return { - list: page.map(CommentPrismaMapper.toDomain), - nextCursor: hasMore ? page[page.length - 1]!.id : null, - }; + if (order === 'desc') page.reverse(); + + return { list: page.map(CommentPrismaMapper.toDomain), nextCursor }; } async create(comment: Comment): Promise { diff --git a/src/modules/comment/presentation/controllers/comment.controller.ts b/src/modules/comment/presentation/controllers/comment.controller.ts index 229cbc0..4aeaaa5 100644 --- a/src/modules/comment/presentation/controllers/comment.controller.ts +++ b/src/modules/comment/presentation/controllers/comment.controller.ts @@ -56,7 +56,7 @@ export class CommentController { ) { return this.handleErrors(() => this.listCommentsUseCase.execute( - new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor), + new ListCommentsQuery(issueId, session.user.id, Number(query.limit), query.cursor, query.direction), ), ); } diff --git a/src/modules/comment/presentation/dto/list-comments.dto.ts b/src/modules/comment/presentation/dto/list-comments.dto.ts index 40b5c81..fe72247 100644 --- a/src/modules/comment/presentation/dto/list-comments.dto.ts +++ b/src/modules/comment/presentation/dto/list-comments.dto.ts @@ -1,4 +1,9 @@ +import { IsIn, IsOptional } from 'class-validator'; import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js'; +import { + COMMENT_LIST_DIRECTIONS, + type CommentListDirection, +} from '../../domain/repositories/comment.repository.interface.js'; export class ListCommentsDto { @IntDecorator({ isOptional: true, min: 1, max: 100 }) @@ -7,4 +12,13 @@ export class ListCommentsDto { /** id последнего полученного комментария (nextCursor из предыдущего ответа) */ @StringDecorator({ isOptional: true }) cursor?: string; + + /** + * forward — от старых к новым, cursor — последний полученный (новые после него). + * backward — страницы от новых к старым (для чата), cursor — самый старый полученный. + * В обоих случаях список в ответе отсортирован от старых к новым. + */ + @IsOptional() + @IsIn(COMMENT_LIST_DIRECTIONS) + direction: CommentListDirection = 'forward'; }