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',
|
||||
) {}
|
||||
}
|
||||
+20
-1
@@ -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) — атомарно с комментарием.
|
||||
|
||||
+12
-6
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user