sort comments
Gitea Actions Demo / Deploy (push) Successful in 1m50s

This commit is contained in:
vadim committed 2026-10-02 21:43:37 +03:00
1 parent d0fcc93a8d
commit 5607e97533
7 files changed
+60 -9

No files matched your search

@@ -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',
) {}
}
@@ -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);
@@ -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 };
@@ -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<Comment | null>;
/**
* Комментарии задачи от старых к новым, включая удалённые.
* Курсорная пагинация: 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) — атомарно с комментарием.
@@ -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<Comment> {
@@ -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),
),
);
}
@@ -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';
}