diff --git a/libs/database/src/constants.ts b/libs/database/src/constants.ts index 087de13c..a8e8b10c 100644 --- a/libs/database/src/constants.ts +++ b/libs/database/src/constants.ts @@ -1,2 +1,35 @@ +import { + eq, + gt, + gte, + ilike, + inArray, + isNotNull, + isNull, + lt, + lte, + ne, + not, + type SQL, +} from 'drizzle-orm'; + +import type { FilterOperator } from './interfaces'; +import type { PgColumn } from 'drizzle-orm/pg-core'; + +export const FILTER_MAP: Record SQL> = { + eq: (col, val) => eq(col, val), + ne: (col, val) => ne(col, val), + gt: (col, val) => gt(col, val), + gte: (col, val) => gte(col, val), + lt: (col, val) => lt(col, val), + lte: (col, val) => lte(col, val), + like: (col, val) => ilike(col, `%${val}%`), + ilike: (col, val) => ilike(col, `%${val}%`), + in: (col, val) => inArray(col, Array.isArray(val) ? val : []), + notIn: (col, val) => not(inArray(col, Array.isArray(val) ? val : [])), + isNull: (col) => isNull(col), + isNotNull: (col) => isNotNull(col), +}; + export const DATABASE_SERVICE = 'DATABASE_SERVICE'; export const SQL_CLIENT = 'SQL_CLIENT'; diff --git a/libs/database/src/index.ts b/libs/database/src/index.ts index da99ae2c..f4512e91 100644 --- a/libs/database/src/index.ts +++ b/libs/database/src/index.ts @@ -1,4 +1,5 @@ export * from './database.module'; export { DATABASE_SERVICE } from './constants'; -export type { DatabaseService } from './interfaces'; +export type { DatabaseService, CursorResult, PaginatedResult } from './interfaces'; export { DatabaseHealthService } from './database-health.service'; +export { paginateCursor, paginateOffset } from './pagination'; diff --git a/libs/database/src/interfaces/index.ts b/libs/database/src/interfaces/index.ts index 5e3751a4..bb559a84 100644 --- a/libs/database/src/interfaces/index.ts +++ b/libs/database/src/interfaces/index.ts @@ -1 +1,2 @@ export * from './module.interface'; +export type * from './paginate.interface'; diff --git a/libs/database/src/interfaces/paginate.interface.ts b/libs/database/src/interfaces/paginate.interface.ts new file mode 100644 index 00000000..34d74cba --- /dev/null +++ b/libs/database/src/interfaces/paginate.interface.ts @@ -0,0 +1,70 @@ +import type { PgColumn } from 'drizzle-orm/pg-core'; + +export type FilterOperator = + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'like' + | 'ilike' + | 'in' + | 'notIn' + | 'isNull' + | 'isNotNull'; + +export interface FilterCondition { + column: TColumn; + operator: FilterOperator; + value?: unknown; +} + +export interface SearchConfig { + columns: PgColumn[]; + value: string; +} + +export interface SortConfig { + column: TColumn; + order: 'asc' | 'desc'; +} + +export interface OffsetOptions { + page?: number; + limit?: number; + offset?: number; + sort?: SortConfig; + filters?: FilterCondition[]; + search?: SearchConfig; +} + +export interface CursorOptions { + column: PgColumn; + cursor?: string; + limit?: number; + sort?: SortConfig; + filters?: FilterCondition[]; + search?: SearchConfig; +} + +export interface PaginatedResult { + items: T[]; + meta: { + hasNextPage: boolean; + hasPrevPage: boolean; + total: number; + totalPages: number; + page: number; + limit: number; + }; +} + +export interface CursorResult { + items: T[]; + meta: { + next: string | null; + limit: number; + hasNext: boolean; + }; +} diff --git a/libs/database/src/pagination/cursor.paginate.ts b/libs/database/src/pagination/cursor.paginate.ts new file mode 100644 index 00000000..2b57bb3a --- /dev/null +++ b/libs/database/src/pagination/cursor.paginate.ts @@ -0,0 +1,77 @@ +import { lt, gt, and } from 'drizzle-orm'; + +import { applyOrder, buildConditions } from './utils'; + +import type { CursorOptions, CursorResult } from '../interfaces'; +import type { PgSelect } from 'drizzle-orm/pg-core'; + +/** + * Выполняет курсорную пагинацию на основе уникального поля `id`. + * + * **Важно:** Эта реализация работает только с простым курсором — значением поля `id` + * последнего элемента предыдущей страницы. Составные курсоры и сортировка + * по не-unique полям (где возможны дубликаты) **не поддерживаются**. + * + * @template TRow - Тип строки результата (выводится из запроса) + * + * @param {PgSelect} query - Drizzle ORM динамический запрос (`.$dynamic()`) + * @param {CursorOptions} options - Параметры пагинации + * @param {PgColumn} options.column - Колонка для сортировки и курсора (должна быть уникальной, обычно `id`) + * @param {SortConfig} [options.sort] - Объект `{ column, order }` (по умолчанию `{ column: options.column, order: 'asc' }`) + * @param {number} [options.limit=25] - Количество записей на странице (максимум 50) + * @param {string} [options.cursor] - Значение `id` последнего элемента предыдущей страницы + * @param {FilterCondition[]} [options.filters] - Дополнительные условия фильтрации + * @param {{ columns: PgColumn[]; value: string }} [options.search] - Поисковый запрос + * + * @returns {Promise>} Объект `{ items, meta: { next, hasNext, limit } }` + * + * @example + * ```ts + * const result = await paginateCursor(query, { + * column: schema.projects.id, + * sort: { column: schema.projects.id, order: 'asc' }, + * limit: 20, + * cursor: query.cursor, + * }); + * ``` + */ +export async function paginateCursor( + query: PgSelect, + options: CursorOptions, +): Promise> { + const MAX_LIMIT = 50; + const DEFAULT_LIMIT = 25; + const limit = Math.min(Math.max(1, options.limit ?? DEFAULT_LIMIT), MAX_LIMIT) + 1; + + const sort = options.sort ?? { column: options.column, order: 'asc' as const }; + if (!sort.column) { + throw new Error('Sort column is required for cursor pagination'); + } + + const conditions = buildConditions(options); + + if (options.cursor) { + conditions.push( + sort.order === 'desc' + ? lt(sort.column, options.cursor) + : gt(sort.column, options.cursor), + ); + } + + const orderByClause = applyOrder(sort); + const filteredQuery = conditions.length > 0 ? query.where(and(...conditions)) : query; + + const items = await filteredQuery.orderBy(orderByClause).limit(limit); + const hasNext = items.length === limit; + + if (hasNext) { + items.pop(); + } + + const next = hasNext && items.length > 0 ? String(items[items.length - 1]?.['id']) : null; + + return { + items: items as TRow[], + meta: { next, hasNext, limit: limit - 1 }, + }; +} diff --git a/libs/database/src/pagination/index.ts b/libs/database/src/pagination/index.ts new file mode 100644 index 00000000..411c181b --- /dev/null +++ b/libs/database/src/pagination/index.ts @@ -0,0 +1,2 @@ +export { paginateCursor } from './cursor.paginate'; +export { paginateOffset } from './offest.paginate'; diff --git a/libs/database/src/pagination/offest.paginate.ts b/libs/database/src/pagination/offest.paginate.ts new file mode 100644 index 00000000..e7710733 --- /dev/null +++ b/libs/database/src/pagination/offest.paginate.ts @@ -0,0 +1,40 @@ +import { and, count } from 'drizzle-orm'; + +import { applyOrder, buildConditions, withFallback } from './utils'; + +import type { PaginatedResult, DatabaseService, OffsetOptions } from '../interfaces'; +import type { PgSelect } from 'drizzle-orm/pg-core'; + +export async function paginateOffset( + db: DatabaseService, + query: PgSelect, + options: OffsetOptions = {}, +): Promise> { + const page = withFallback(options.page, 1); + const limit = Math.min(withFallback(options.limit, 20), 100); + const offset = withFallback(options.offset, (page - 1) * limit); + + const conditions = buildConditions(options); + const orderBy = options.sort ? [applyOrder(options.sort)] : []; + const filtered = conditions.length > 0 ? query.where(and(...conditions)) : query; + + const [data, [totalRow]] = await Promise.all([ + (orderBy.length > 0 ? filtered.orderBy(...orderBy) : filtered).limit(limit).offset(offset), + db.select({ count: count() }).from(filtered.as('_count')), + ]); + + const total = Number(totalRow?.count ?? 0); + const totalPages = Math.ceil(total / limit); + + return { + items: data as TRow[], + meta: { + hasNextPage: page < totalPages, + hasPrevPage: page > 1, + total, + totalPages, + page, + limit, + }, + }; +} diff --git a/libs/database/src/pagination/utils.ts b/libs/database/src/pagination/utils.ts new file mode 100644 index 00000000..1736f6e6 --- /dev/null +++ b/libs/database/src/pagination/utils.ts @@ -0,0 +1,50 @@ +import { asc, desc, type SQL } from 'drizzle-orm'; + +import { FILTER_MAP } from '../constants'; + +import type { FilterCondition, SortConfig } from '../interfaces'; +import type { PgColumn } from 'drizzle-orm/pg-core'; + +export const applyFilter = ({ column, operator, value }: FilterCondition): SQL => + (FILTER_MAP[operator] ?? FILTER_MAP.eq)(column, value); + +export const applyOrder = ({ column, order }: SortConfig): SQL => + order === 'desc' ? desc(column) : asc(column); + +export const withFallback = (value: T | undefined, fallback: T): T => value ?? fallback; + +export const buildConditions = (options: { + filters?: FilterCondition[]; + search?: { columns: PgColumn[]; value: string }; +}): SQL[] => { + const conditions: SQL[] = []; + if (options.filters?.length) { + conditions.push(...options.filters.map(applyFilter)); + } + // if (options.search?.value && options.search.columns?.length) { + // const searchConditions = options.search.columns.map((col) => + // ilike(col, `%${options.search.value}%`), + // ); + // conditions.push( + // searchConditions.length === 1 ? searchConditions[0] : or(...searchConditions), + // ); + // } + return conditions; +}; + +export const getColumnName = (column: PgColumn | string): string => + typeof column === 'string' ? column : column.name || 'id'; + +export const encode = (value: unknown): string => { + if (value === null) { + return ''; + } + return Buffer.from(JSON.stringify(value)).toString('base64'); +}; + +export const decode = (cursor: string): unknown => { + if (!cursor) { + return null; + } + return JSON.parse(Buffer.from(cursor, 'base64').toString('utf-8')); +}; diff --git a/src/area/application/dtos/state.dto.ts b/src/area/application/dtos/state.dto.ts index 7821306f..2ca3aaec 100644 --- a/src/area/application/dtos/state.dto.ts +++ b/src/area/application/dtos/state.dto.ts @@ -1,5 +1,5 @@ import { STATE_CATEGORIES, STATE_TYPES } from '@core/area/domain/entities'; -import { createSortingSchema, PaginationBaseSchema, ActionResponseSchema } from '@shared/schemas'; +import { createSortingSchema, CursorQuerySchema, ActionResponseSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -147,16 +147,7 @@ export const QueryParamsSchema = z category: z.string().optional().describe('Фильтр по категории'), overdue: z.boolean().optional().default(false).describe('Только просроченные'), }) - .extend(PaginationBaseSchema.shape) - .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape) - .transform((data) => { - if (data.page > 1 && data.offset === 0) { - return { - ...data, - offset: (data.page - 1) * (data.limit || 20), - }; - } - return data; - }); + .extend(CursorQuerySchema.shape) + .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape); export class QueryParamsDto extends createZodDto(QueryParamsSchema) {} diff --git a/src/auth/infrastructure/persistence/repositories/identity.repository.ts b/src/auth/infrastructure/persistence/repositories/identity.repository.ts index 311f44a7..b2fd85bc 100644 --- a/src/auth/infrastructure/persistence/repositories/identity.repository.ts +++ b/src/auth/infrastructure/persistence/repositories/identity.repository.ts @@ -12,7 +12,7 @@ export class IdentitiyRepository implements IIdentityRepository { private readonly db: DatabaseService, ) {} - public readonly create = async (data: typeof schema.userIdentities.$inferInsert) => { + public create = async (data: typeof schema.userIdentities.$inferInsert) => { const [result] = await this.db.insert(schema.userIdentities).values(data).returning(); if (!result) { @@ -22,7 +22,7 @@ export class IdentitiyRepository implements IIdentityRepository { return result; }; - public readonly delete = async (id: string) => { + public delete = async (id: string) => { const result = await this.db .delete(schema.userIdentities) .where(eq(schema.userIdentities.id, id)); @@ -30,13 +30,13 @@ export class IdentitiyRepository implements IIdentityRepository { return result.count.valueOf() > 0; }; - public readonly findAllByUserId = async (userId: string) => + public findAllByUserId = async (userId: string) => this.db .select() .from(schema.userIdentities) .where(eq(schema.userIdentities.userId, userId)); - public readonly findByProvider = async ( + public findByProvider = async ( provider: 'google' | 'yandex' | 'github', providerUserId: string, ) => { diff --git a/src/issue/application/dtos/issue.dto.ts b/src/issue/application/dtos/issue.dto.ts index de129afd..0cee3ce8 100644 --- a/src/issue/application/dtos/issue.dto.ts +++ b/src/issue/application/dtos/issue.dto.ts @@ -4,7 +4,7 @@ import { z } from 'zod/v4'; import { ActionResponseSchema, createSortingSchema, - PaginationBaseSchema, + CursorQuerySchema, } from '../../../shared/schemas'; import { ISSUE_TYPE_LIST, PRIORITY_LIST } from '../../domain/entities'; @@ -221,7 +221,7 @@ export const IssueFiltersQuerySchema = IssueQuerySchema.extend({ .optional() .describe('Метки через запятую (AND — задача должна иметь все указанные)'), }) - .extend(PaginationBaseSchema.shape) + .extend(CursorQuerySchema.shape) .extend(createSortingSchema(['position', 'createdAt', 'priority']).shape) .describe('Query параметры для получения списка задач с фильтрацией'); diff --git a/src/issue/application/issue.facade.ts b/src/issue/application/issue.facade.ts index 8d107edd..b3d57c98 100644 --- a/src/issue/application/issue.facade.ts +++ b/src/issue/application/issue.facade.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { AssignIssueDto, CreateIssueDto, - IssueQueryDto, + IssueFiltersQueryDto, MoveIssueDto, UpdateIssueDto, } from './dtos'; @@ -37,7 +37,7 @@ export class IssueFacade { public getOne = async (id: string, slug: string, userId: string) => this.getOneIssueQ.execute(id, slug, userId); - public getAll = async (query: IssueQueryDto, userId: string) => + public getAll = async (query: IssueFiltersQueryDto, userId: string) => this.getAllIssueQ.execute(query, userId); public update = async ( diff --git a/src/issue/application/use-cases/base/find-all.query.ts b/src/issue/application/use-cases/base/find-all.query.ts index 0d340abf..1dd334cd 100644 --- a/src/issue/application/use-cases/base/find-all.query.ts +++ b/src/issue/application/use-cases/base/find-all.query.ts @@ -3,7 +3,7 @@ import { CheckVisibilityOrThrowQuery } from '@core/project/application/use-cases import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { Inject, Injectable } from '@nestjs/common'; -import { IssueQueryDto } from '../../dtos'; +import { IssueFiltersQueryDto } from '../../dtos'; import { IssueMapper } from '../../mappers'; @Injectable() @@ -15,7 +15,7 @@ export class FindAllIssueQuery { private readonly projectPolicy: ProjectAccessPolicy, ) {} - async execute(query: IssueQueryDto, userId: string) { + async execute(query: IssueFiltersQueryDto, userId: string) { const visibility = await this.projectVisibility.execute(query.slug); if (visibility === 'private') { diff --git a/src/project/application/controllers/projects/controller.ts b/src/project/application/controllers/projects/controller.ts index e48dacd4..d7a8eb57 100644 --- a/src/project/application/controllers/projects/controller.ts +++ b/src/project/application/controllers/projects/controller.ts @@ -1,7 +1,7 @@ import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; -import { CreateProjectDto, CreateShareTokenDto, UpdateProjectDto } from '../../dtos'; +import { CreateProjectDto, CreateShareTokenDto, ProjectQuery, UpdateProjectDto } from '../../dtos'; import { ProjectFacade } from '../../project.facade'; import { @@ -21,8 +21,12 @@ export class ProjectsController { @Get() @FindAllProjectsSwagger() - async findAll(@Param('teamId') teamId: string, @GetUserId() userId: string) { - return this.facade.getTeamProjects(teamId, userId); + async findAll( + @Param('teamId') teamId: string, + @GetUserId() userId: string, + @Query() query: ProjectQuery, + ) { + return this.facade.getTeamProjects(teamId, userId, query); } @Get(':slug') diff --git a/src/project/application/controllers/projects/swagger.ts b/src/project/application/controllers/projects/swagger.ts index 76d3f662..8a6bf58a 100644 --- a/src/project/application/controllers/projects/swagger.ts +++ b/src/project/application/controllers/projects/swagger.ts @@ -4,6 +4,7 @@ import { } from '@core/project/application/dtos/project.dto'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiOperation, ApiBody, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { ApiCursorPagination, ApiSearchFilter } from '@shared/decorators'; import { ApiValidationError, ApiUnauthorized, @@ -69,19 +70,14 @@ export const FindAllProjectsSwagger = () => 'Сортировка по полю sequence (по возрастанию).', ].join('\n\n'), }), + ApiCursorPagination(), + ApiSearchFilter(), ApiParam({ name: 'teamId', description: 'ID команды', type: 'string', example: 'clv123456', }), - ApiQuery({ - name: 'search', - description: 'Поиск по названию проекта', - type: 'string', - required: false, - example: 'маркетинг', - }), ApiQuery({ name: 'status', description: 'Фильтр по статусу проекта', @@ -97,7 +93,6 @@ export const FindAllProjectsSwagger = () => }), ApiForbidden('У вас нет доступа к этой команде'), ApiUnauthorized(), - SetMetadata(ZOD_RESPONSE_TOKEN, ProjectListResponse), ); diff --git a/src/project/application/dtos/member.dto.ts b/src/project/application/dtos/member.dto.ts index d0b89141..71e47818 100644 --- a/src/project/application/dtos/member.dto.ts +++ b/src/project/application/dtos/member.dto.ts @@ -1,4 +1,4 @@ -import { createPaginationSchema } from '@shared/schemas'; +import { createCursorResponseSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -41,7 +41,7 @@ const MemberResponseSchema = ProjectMemberSchema.omit({ user: MemberUserSchema, }); -export const ProjectMemberListResponseSchema = createPaginationSchema(MemberResponseSchema); +export const ProjectMemberListResponseSchema = createCursorResponseSchema(MemberResponseSchema); export const AddProjectMemberSchema = z .object({ diff --git a/src/project/application/dtos/project.dto.ts b/src/project/application/dtos/project.dto.ts index 79d2d2ca..1051d801 100644 --- a/src/project/application/dtos/project.dto.ts +++ b/src/project/application/dtos/project.dto.ts @@ -1,5 +1,10 @@ import { PROJECT_STATUSES, PROJECT_VISIBILITIES } from '@core/project/domain/entities'; -import { createPaginationSchema, ActionResponseSchema } from '@shared/schemas'; +import { + createCursorResponseSchema, + ActionResponseSchema, + SearchFilterSchema, + CursorQuerySchema, +} from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -163,7 +168,7 @@ export const ProjectListItemSchema = z.object({ role: ProjectMemberRoleSchema.describe('Роль текущего пользователя в проекте'), }); -export const ProjectListResponseSchema = createPaginationSchema(ProjectListItemSchema); +export const ProjectListResponseSchema = createCursorResponseSchema(ProjectListItemSchema); export const ProjectDetailResponseSchema = z.object({ id: z.string().describe('ID проекта'), @@ -214,6 +219,12 @@ export const ProjectDetailResponseSchema = z.object({ }).describe('Настройки проекта'), }); +export const ProjectQuerySearch = z + .object({ status: ProjectStatusSchema.optional() }) + .extend(CursorQuerySchema.shape) + .extend(SearchFilterSchema.shape); + +export class ProjectQuery extends createZodDto(ProjectQuerySearch) {} export class CreateProjectDto extends createZodDto(CreateProjectSchema) {} export class UpdateProjectDto extends createZodDto(UpdateProjectSchema) {} export class CreateProjectResponse extends createZodDto(CreateProjectsResponseSchema) {} diff --git a/src/project/application/project.facade.ts b/src/project/application/project.facade.ts index 9fbbe3db..63b57f26 100644 --- a/src/project/application/project.facade.ts +++ b/src/project/application/project.facade.ts @@ -4,6 +4,7 @@ import { AddProjectMemberDto, CreateProjectDto, CreateShareTokenDto, + ProjectQuery, UpdateProjectDto, UpdateProjectMemberDto, } from './dtos'; @@ -75,8 +76,8 @@ export class ProjectFacade { return this.getDetailQ.execute(slug, teamId, userId, token); } - public async getTeamProjects(teamId: string, userId: string) { - return this.findByTeamQ.execute(teamId, userId); + public async getTeamProjects(teamId: string, userId: string, query: ProjectQuery) { + return this.findByTeamQ.execute(teamId, userId, query); } public async getMembers(teamId: string, userId: string) { diff --git a/src/project/application/use-cases/project/find-by-team.query.ts b/src/project/application/use-cases/project/find-by-team.query.ts index 2b0e04ce..5cc39a26 100644 --- a/src/project/application/use-cases/project/find-by-team.query.ts +++ b/src/project/application/use-cases/project/find-by-team.query.ts @@ -2,6 +2,7 @@ import { ProjectAccessPolicy } from '@core/project/domain/policy'; import { IProjectRepository } from '@core/project/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; +import { ProjectQuery } from '../../dtos'; import { ProjectMapper } from '../../mappers'; @Injectable() @@ -12,22 +13,14 @@ export class FindProjectsByTeamQuery { private readonly policy: ProjectAccessPolicy, ) {} - public async execute(teamId: string, userId: string) { + public async execute(teamId: string, userId: string, query: ProjectQuery) { const { team, member } = await this.policy.ensureTeamAccess(teamId, userId, 'viewer'); - const projects = await this.projectsRepo.findByTeam(team.id); - const items = projects.map((p) => ProjectMapper.toListResponse(p, member)); + const { items, meta } = await this.projectsRepo.findByTeam(team.id, query); + const data = items.map((p) => ProjectMapper.toListResponse(p, member)); return { - // TODO: реализовать полноценную пагинацию для проектов команды. - items, - meta: { - total: items.length + 1, - totalPages: items.length ? items.length + 1 : 1, - page: 1, - limit: 10, - hasPrevPage: false, - hasNextPage: false, - }, + items: data, + meta, }; } } diff --git a/src/project/domain/repository/project.repository.interface.ts b/src/project/domain/repository/project.repository.interface.ts index 144547dc..0d88ce61 100644 --- a/src/project/domain/repository/project.repository.interface.ts +++ b/src/project/domain/repository/project.repository.interface.ts @@ -1,14 +1,13 @@ +import type { CursorResult } from '../../../../libs/database/src'; +import type { ProjectQuery } from '../../application/dtos'; import type { NewProject, NewProjectShare, Project } from '../entities'; export interface IProjectRepository { - create( - userId: string, - data: NewProject, - ): Promise<{ readonly result: boolean; readonly slug: string }>; + create(userId: string, data: NewProject): Promise<{ result: boolean; slug: string }>; update(teamId: string, projectId: string, data: Partial): Promise; delete(teamId: string, projectId: string): Promise; findOne(projectId: string, teamId?: string): Promise; - findByTeam(teamId: string): Promise; + findByTeam(teamId: string, query?: ProjectQuery): Promise>; createShare(data: NewProjectShare): Promise; findBySlug(slug: string, teamId?: string): Promise; diff --git a/src/project/infrastructure/persistence/repositories/project.repository.ts b/src/project/infrastructure/persistence/repositories/project.repository.ts index 3a248714..e0f3a279 100644 --- a/src/project/infrastructure/persistence/repositories/project.repository.ts +++ b/src/project/infrastructure/persistence/repositories/project.repository.ts @@ -1,7 +1,8 @@ -import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { DATABASE_SERVICE, DatabaseService, paginateCursor } from '@libs/database'; import { Injectable, Inject } from '@nestjs/common'; import { and, count, eq, gt, isNull, or } from 'drizzle-orm'; +import { ProjectQuery } from '../../../application/dtos'; import { IProjectRepository } from '../../../domain/repository'; import * as schema from '../models'; @@ -14,7 +15,7 @@ export class ProjectRepository implements IProjectRepository { private readonly db: DatabaseService, ) {} - public readonly create = async (userId: string, data: NewProject) => { + public create = async (userId: string, data: NewProject) => { const result = await this.db.transaction(async (tx) => { const project = await tx .insert(schema.projects) @@ -40,11 +41,7 @@ export class ProjectRepository implements IProjectRepository { return result; }; - public readonly update = async ( - teamId: string, - projectId: string, - data: Partial, - ) => { + public update = async (teamId: string, projectId: string, data: Partial) => { const result = await this.db .update(schema.projects) .set({ ...data, updatedAt: new Date().toISOString() }) @@ -60,7 +57,7 @@ export class ProjectRepository implements IProjectRepository { return result.length > 0; }; - public readonly delete = async (teamId: string, projectId: string) => { + public delete = async (teamId: string, projectId: string) => { const result = await this.db .update(schema.projects) .set({ @@ -80,7 +77,7 @@ export class ProjectRepository implements IProjectRepository { return result.length > 0; }; - public readonly findOne = async (id: string, teamId?: string) => { + public findOne = async (id: string, teamId?: string) => { const [project] = await this.db .select() .from(schema.projects) @@ -95,7 +92,7 @@ export class ProjectRepository implements IProjectRepository { return project || null; }; - public readonly findBySlug = async (slug: string, teamId?: string) => { + public findBySlug = async (slug: string, teamId?: string) => { const [project] = await this.db .select() .from(schema.projects) @@ -110,13 +107,23 @@ export class ProjectRepository implements IProjectRepository { return project || null; }; - public readonly findByTeam = async (teamId: string) => - this.db + public findByTeam = async (teamId: string, query: ProjectQuery) => { + const q = this.db .select() .from(schema.projects) - .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))); + .where(and(eq(schema.projects.teamId, teamId), isNull(schema.projects.deletedAt))) + .$dynamic(); + + return paginateCursor(q, { + column: schema.projects.id, + sort: { column: schema.projects.id, order: 'asc' }, + limit: query.limit, + search: { columns: [schema.projects.name], value: query.search ?? '' }, + cursor: query.cursor, + }); + }; - public readonly createShare = async (data: NewProjectShare) => { + public createShare = async (data: NewProjectShare) => { const [result] = await this.db .insert(schema.projectShares) .values(data) @@ -132,7 +139,7 @@ export class ProjectRepository implements IProjectRepository { return !!result; }; - public readonly hasValidShareToken = async (id: string, token: string) => { + public hasValidShareToken = async (id: string, token: string) => { const [result] = await this.db .select() .from(schema.projectShares) @@ -151,7 +158,7 @@ export class ProjectRepository implements IProjectRepository { return !!result; }; - public readonly revokeAllShares = async (projectId: string) => { + public revokeAllShares = async (projectId: string) => { const result = await this.db .delete(schema.projectShares) .where(eq(schema.projectShares.projectId, projectId)) @@ -160,7 +167,7 @@ export class ProjectRepository implements IProjectRepository { return result.length > 0; }; - public readonly countByTeam = async (teamId: string) => { + public countByTeam = async (teamId: string) => { const [result] = await this.db .select({ count: count() }) .from(schema.projects) diff --git a/src/shared/authorization/authorization.spec.ts b/src/shared/authorization/authorization.spec.ts index 0f10cd8e..5c46e133 100644 --- a/src/shared/authorization/authorization.spec.ts +++ b/src/shared/authorization/authorization.spec.ts @@ -22,7 +22,7 @@ describe('AuthorizationService - Permissions Matrix', () => { role, status: 'active', joinedAt: null, - firstName: null, + firstName: 'test', lastName: null, middleName: null, avatarUrl: null, diff --git a/src/shared/decorators/query-list.decorator.ts b/src/shared/decorators/query-list.decorator.ts index 4bec7b56..7ae69b9d 100644 --- a/src/shared/decorators/query-list.decorator.ts +++ b/src/shared/decorators/query-list.decorator.ts @@ -40,6 +40,25 @@ export const ApiPagination = () => }), ); +export const ApiCursorPagination = () => + applyDecorators( + ApiQuery({ + name: 'cursor', + required: false, + type: String, + description: + 'Курсор последнего элемента предыдущей страницы (base64url). Если не указан — первая страница.', + example: 'eyJpZCI6NDJ9', + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Количество записей на странице (макс. 100)', + example: 20, + }), + ); + export const ApiSorting = (options: SortableFields) => applyDecorators( ApiQuery({ diff --git a/src/shared/guards/bearer.guard.ts b/src/shared/guards/bearer.guard.ts index 1542ce8f..251064e6 100644 --- a/src/shared/guards/bearer.guard.ts +++ b/src/shared/guards/bearer.guard.ts @@ -65,8 +65,14 @@ export class BearerAuthGuard extends AuthGuard('bearer') { return !!(isPublic || query.token); } - private getAuthDetails(err: unknown, info: any) { - const message = info?.message || (err instanceof Error ? err.message : null); + private getAuthDetails(err: unknown, info: unknown) { + const infoMessage = + info && typeof info === 'object' && 'message' in info + ? (info as { message: string }).message + : null; + + const errMessage = err instanceof Error ? err.message : null; + const message = infoMessage || errMessage; return message ? [{ target: 'auth', reason: message }] : []; } diff --git a/src/shared/schemas/pagination.schema.ts b/src/shared/schemas/pagination.schema.ts index 53006cbc..65a3734d 100644 --- a/src/shared/schemas/pagination.schema.ts +++ b/src/shared/schemas/pagination.schema.ts @@ -1,7 +1,16 @@ import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; -export const PaginationBaseSchema = z.object({ +const LimitSchema = z.coerce + .number() + .int() + .min(1, 'Лимит должен быть не менее 1') + .max(100, 'Лимит не может превышать 100') + .optional() + .default(20) + .describe('Количество записей на странице'); + +export const OffsetQueryBaseSchema = z.object({ page: z.coerce .number() .int() @@ -9,7 +18,6 @@ export const PaginationBaseSchema = z.object({ .optional() .default(1) .describe('Номер страницы (начиная с 1)'), - offset: z.coerce .number() .int() @@ -17,18 +25,10 @@ export const PaginationBaseSchema = z.object({ .optional() .default(0) .describe('Смещение для пагинации (альтернатива page)'), - - limit: z.coerce - .number() - .int() - .min(1, 'Лимит должен быть не менее 1') - .max(100, 'Лимит не может превышать 100') - .optional() - .default(20) - .describe('Количество записей на странице'), + limit: LimitSchema, }); -export const PaginationSchema = PaginationBaseSchema.transform((data) => { +export const OffsetQuerySchema = OffsetQueryBaseSchema.transform((data) => { if (data.page > 1 && data.offset === 0) { return { ...data, @@ -38,7 +38,7 @@ export const PaginationSchema = PaginationBaseSchema.transform((data) => { return data; }); -export const paginationResponseSchema = z.object({ +export const OffsetMetaSchema = z.object({ hasNextPage: z .boolean() .describe('Флаг наличия следующей страницы. True, если текущая страница не последняя.'), @@ -59,10 +59,47 @@ export const paginationResponseSchema = z.object({ limit: z.number().int().positive().describe('Количество элементов на одну страницу.'), }); -export const createPaginationSchema = (itemSchema: T) => +export const createOffsetResponseSchema = (item: T) => + z.object({ items: z.array(item), meta: OffsetMetaSchema }); + +export class OffsetQuery extends createZodDto(OffsetQuerySchema) {} + +export const CursorQuerySchema = z.object({ + cursor: z + .string() + .optional() + .describe('Курсор последнего элемента предыдущей страницы (base64url)'), + limit: LimitSchema, +}); + +export const CursorMetaSchema = z.object({ + next: z.string().nullable().describe('Курсор следующей страницы'), + hasNext: z.boolean().describe('Есть ли следующая страница'), + limit: z.number().int().positive().describe('Количество элементов на одну страницу.'), +}); + +export const createCursorResponseSchema = (item: T) => + z.object({ items: z.array(item), meta: CursorMetaSchema }); + +export class CursorQuery extends createZodDto(CursorQuerySchema) {} + +export const PaginationModeSchema = z.discriminatedUnion('mode', [ z.object({ - items: z.array(itemSchema), - meta: paginationResponseSchema, - }); + mode: z.literal('offset'), + page: z.coerce.number().int().positive().optional().default(1), + offset: z.coerce.number().int().min(0).optional().default(0), + limit: LimitSchema, + }), + z.object({ + mode: z.literal('cursor'), + cursor: z.string().optional(), + limit: LimitSchema, + }), +]); + +export const PaginationQuerySchema = z.discriminatedUnion('type', [ + OffsetQuerySchema, + CursorQuerySchema, +]); -export class PaginationQuery extends createZodDto(PaginationSchema) {} +export type PaginationQuery = z.infer; diff --git a/src/shared/utils/image-builder.util.ts b/src/shared/utils/image-builder.util.ts index 328b112a..01c28cf7 100644 --- a/src/shared/utils/image-builder.util.ts +++ b/src/shared/utils/image-builder.util.ts @@ -1,19 +1,28 @@ import { dirname } from 'node:path'; +import type { ConfigService } from '@nestjs/config'; + export class ImageHelper { - public static buildResponsiveUrls(cdn: string, path?: string | null) { + private static cdnBase(cfg: ConfigService) { + const bucket = cfg.get('S3_BUCKET_NAME'); + return cfg.get('DOMAIN') + ? `https://cdn.${cfg.get('DOMAIN')}/${bucket}` + : `${cfg.get('S3_ENDPOINT')}/${bucket}`; + } + + static responsive(cfg: ConfigService, path?: string | null) { if (!path) { return null; } const folder = dirname(path); - const base = `${cdn}/${folder}`; + const base = `${this.cdnBase(cfg)}/${folder}`; return { small: `${base}/sm.webp`, medium: `${base}/md.webp`, large: `${base}/lg.webp`, - original: `${cdn}/${path}`, + original: `${this.cdnBase(cfg)}/${path}`, }; } } diff --git a/src/team/application/controllers/members/controller.ts b/src/team/application/controllers/members/controller.ts index 42b75c30..81aa5b66 100644 --- a/src/team/application/controllers/members/controller.ts +++ b/src/team/application/controllers/members/controller.ts @@ -1,6 +1,6 @@ -import { UpdateMemberDto } from '@core/team/application/dtos'; +import { TeamMembersQuery, UpdateMemberDto } from '@core/team/application/dtos'; import { TeamFacade } from '@core/team/application/team.facade'; -import { Body, Delete, Get, Param, Patch } from '@nestjs/common'; +import { Body, Delete, Get, Param, Patch, Query } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; import { GetMembersSwagger, RemoveMemberSwagger, UpdateMemberSwagger } from './swagger'; @@ -11,8 +11,8 @@ export class TeamMembersController { @Get('members') @GetMembersSwagger() - async getMembers(@Param('teamId') teamId: string) { - return this.facade.getMembers(teamId); + async getMembers(@Param('teamId') teamId: string, @Query() query: TeamMembersQuery) { + return this.facade.getMembers(teamId, query); } @Patch('members/:userId') diff --git a/src/team/application/controllers/members/swagger.ts b/src/team/application/controllers/members/swagger.ts index c1a90760..a8af7a80 100644 --- a/src/team/application/controllers/members/swagger.ts +++ b/src/team/application/controllers/members/swagger.ts @@ -6,6 +6,7 @@ import { } from '@core/team/application/dtos'; import { applyDecorators, SetMetadata } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { ApiCursorPagination, ApiSearchFilter } from '@shared/decorators'; import { ApiForbidden, ApiNotFound, ApiUnauthorized } from '@shared/error'; import { ZOD_RESPONSE_TOKEN } from '@shared/interceptors'; import { ActionResponse } from '@shared/schemas'; @@ -48,6 +49,8 @@ export const GetMembersSwagger = () => applyDecorators( ApiOperation({ summary: 'Получить список всех участников команды' }), ApiParam({ name: 'teamId', description: 'Уникальный идентификатор команды' }), + ApiCursorPagination(), + ApiSearchFilter(), ApiResponse({ status: 200, description: 'Список участников получен', diff --git a/src/team/application/dtos/invitation.dto.ts b/src/team/application/dtos/invitation.dto.ts index 23b93af7..2d2e87db 100644 --- a/src/team/application/dtos/invitation.dto.ts +++ b/src/team/application/dtos/invitation.dto.ts @@ -1,4 +1,3 @@ -import { createPaginationSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -37,9 +36,7 @@ export const TeamInvitationSchema = z.object({ export class TeamInvitationResponse extends createZodDto(TeamInvitationSchema) {} -export class TeamInvitationsResponse extends createZodDto( - createPaginationSchema(TeamInvitationSchema), -) {} +export class TeamInvitationsResponse extends createZodDto(z.array(TeamInvitationSchema)) {} export interface TeamInvite { readonly teamId: string; diff --git a/src/team/application/dtos/member.dto.ts b/src/team/application/dtos/member.dto.ts index 8b324f0f..f8b0e1c9 100644 --- a/src/team/application/dtos/member.dto.ts +++ b/src/team/application/dtos/member.dto.ts @@ -1,4 +1,9 @@ -import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; +import { + AvatarResponseSchema, + createCursorResponseSchema, + CursorQuerySchema, + SearchFilterSchema, +} from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -51,7 +56,7 @@ export const TeamMemberResponseSchema = z.object({ export class TeamMemberResponse extends createZodDto(TeamMemberResponseSchema) {} export class TeamMembersResponse extends createZodDto( - createPaginationSchema(TeamMemberResponseSchema), + createCursorResponseSchema(TeamMemberResponseSchema), ) {} export const UserInviteSchema = z.object({ @@ -74,4 +79,11 @@ export const UserInviteSchema = z.object({ export class UserInviteResponse extends createZodDto(UserInviteSchema) {} -export class UserInvitesResponse extends createZodDto(createPaginationSchema(UserInviteSchema)) {} +export class UserInvitesResponse extends createZodDto(UserInviteSchema) {} + +export const TeamMembersQuerySchema = z + .object({}) + .extend(CursorQuerySchema.shape) + .extend(SearchFilterSchema.shape); + +export class TeamMembersQuery extends createZodDto(TeamMembersQuerySchema) {} diff --git a/src/team/application/dtos/team.dto.ts b/src/team/application/dtos/team.dto.ts index 45c92086..ebd1ae07 100644 --- a/src/team/application/dtos/team.dto.ts +++ b/src/team/application/dtos/team.dto.ts @@ -58,12 +58,8 @@ export const TeamResponseSchema = z.object({ id: z.string().describe('Уникальный ID команды'), name: z.string().describe('Название команды'), description: z.string().nullable().describe('Описание команды'), - avatarUrl: z - .string() - .url() - .nullable() - .describe('URL аватара команды или null, если аватар отсутствует'), - coverUrl: z.string().nullable().describe('URL обложки команды'), + avatar: AvatarResponseSchema, + cover: AvatarResponseSchema, ownerId: z.string().nullable().describe('ID владельца команды'), createdAt: z .string() diff --git a/src/team/application/mappers/member.mapper.ts b/src/team/application/mappers/member.mapper.ts index 97bcfc0e..1448b602 100644 --- a/src/team/application/mappers/member.mapper.ts +++ b/src/team/application/mappers/member.mapper.ts @@ -1,15 +1,16 @@ import { ImageHelper } from '@shared/utils'; import type { RawMemberRow, RawMemberTeams } from '../../domain/repository'; +import type { ConfigService } from '@nestjs/config'; export class TeamMemberMapper { - public static toDetail(row: RawMemberRow, cdn: string) { + public static toDetail(row: RawMemberRow, cfg: ConfigService) { const { firstName, lastName, middleName, avatarUrl, userId, ...rest } = row; const fullName = [lastName, firstName, middleName].filter(Boolean).join(' ') || 'Unknown User'; - const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + const avatar = ImageHelper.responsive(cfg, avatarUrl); return { id: userId, @@ -23,14 +24,14 @@ export class TeamMemberMapper { }; } - public static toList(rows: readonly RawMemberRow[], cdn: string) { - return rows.map((row) => this.toDetail(row, cdn)); + public static toList(rows: readonly RawMemberRow[], cfg: ConfigService) { + return rows.map((row) => this.toDetail(row, cfg)); } - public static toUserTeam(data: RawMemberTeams, cdn: string) { + public static toUserTeam(data: RawMemberTeams, cfg: ConfigService) { const { role, avatarUrl, ...row } = data; - const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + const avatar = ImageHelper.responsive(cfg, avatarUrl); return { id: row.id, diff --git a/src/team/application/team.facade.ts b/src/team/application/team.facade.ts index c86cb18e..3a6b21f6 100644 --- a/src/team/application/team.facade.ts +++ b/src/team/application/team.facade.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { CreateTeamDto, InviteMemberDto, + TeamMembersQuery, UpdateInvitationDto, UpdateMemberDto, UpdateTeamDto, @@ -46,7 +47,8 @@ export class TeamFacade { public deleteTeam = (teamId: string, userId: string) => this.deleteTeamUc.execute(teamId, userId); - public getMembers = (teamId: string) => this.getTeamMembersQ.execute(teamId); + public getMembers = (teamId: string, query?: TeamMembersQuery) => + this.getTeamMembersQ.execute(teamId, query); public updateMember = (teamId: string, curr: string, target: string, dto: UpdateMemberDto) => this.updateMemberUc.execute(teamId, curr, target, dto); diff --git a/src/team/application/use-cases/base/find-team.query.ts b/src/team/application/use-cases/base/find-team.query.ts index 7de46a2b..8bcd530b 100644 --- a/src/team/application/use-cases/base/find-team.query.ts +++ b/src/team/application/use-cases/base/find-team.query.ts @@ -1,4 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { BaseException } from '@shared/error'; +import { ImageHelper } from '@shared/utils'; import { ITeamRepository } from '../../../domain/repository'; @@ -7,10 +10,31 @@ export class FindTeamQuery { constructor( @Inject('ITeamRepository') private readonly repository: ITeamRepository, + private readonly cfg: ConfigService, ) {} async execute(teamId: string) { - //TODO: add avatarURL handling - return this.repository.findById(teamId); + const team = await this.repository.findById(teamId); + + if (!team) { + throw new BaseException( + { + code: 'NOT_FOUND', + message: 'Команда не найдена', + }, + HttpStatus.NOT_FOUND, + ); + } + + const { avatarUrl, coverUrl, ...other } = team; + + const avatar = ImageHelper.responsive(this.cfg, avatarUrl); + const cover = ImageHelper.responsive(this.cfg, coverUrl); + + return { + ...other, + cover, + avatar, + }; } } diff --git a/src/team/application/use-cases/base/get-my-teams.use-case.ts b/src/team/application/use-cases/base/get-my-teams.use-case.ts index 6c88f1d7..5f7a0373 100644 --- a/src/team/application/use-cases/base/get-my-teams.use-case.ts +++ b/src/team/application/use-cases/base/get-my-teams.use-case.ts @@ -14,16 +14,7 @@ export class GetMyTeamsUseCase { async execute(userId: string) { const teams = await this.teamRepo.findByUser(userId); - const cdn = this.getCdnBaseUrl(); - return teams.map((t) => TeamMemberMapper.toUserTeam(t, cdn)); - } - - private getCdnBaseUrl(): string { - const domain = this.cfg.get('DOMAIN'); - const bucket = this.cfg.get('S3_BUCKET_NAME'); - const endpoint = this.cfg.get('S3_ENDPOINT'); - - return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; + return teams.map((t) => TeamMemberMapper.toUserTeam(t, this.cfg)); } } diff --git a/src/team/application/use-cases/invitions/get-invitations.query.ts b/src/team/application/use-cases/invitions/get-invitations.query.ts index f226041a..0f7c88cf 100644 --- a/src/team/application/use-cases/invitions/get-invitations.query.ts +++ b/src/team/application/use-cases/invitions/get-invitations.query.ts @@ -37,18 +37,7 @@ export class GetInvitationsQuery { const codes = await this.cacheService.getCollection(teamKey); if (!codes.length) { - return { - // TODO: реализовать полноценную пагинацию для инвайтов команды. - items: [], - meta: { - total: 0, - totalPages: 0, - page: 1, - limit: 10, - hasPrevPage: false, - hasNextPage: false, - }, - }; + return []; } const results = await this.cacheService.getMany(codes.map(this.INVITES_KEY)); @@ -76,18 +65,7 @@ export class GetInvitationsQuery { .catch((e) => console.error('Cleanup error:', e)); } - return { - // TODO: реализовать полноценную пагинацию для инвайтов команды. - items: active, - meta: { - total: active.length, - totalPages: active.length ? 1 : 0, - page: 1, - limit: 10, - hasPrevPage: false, - hasNextPage: false, - }, - }; + return active; } private validateAccess(member: RawMemberRow) { diff --git a/src/team/application/use-cases/invitions/get-my-invites.use-case.ts b/src/team/application/use-cases/invitions/get-my-invites.use-case.ts index 92e431c0..63384d55 100644 --- a/src/team/application/use-cases/invitions/get-my-invites.use-case.ts +++ b/src/team/application/use-cases/invitions/get-my-invites.use-case.ts @@ -16,18 +16,7 @@ export class GetMyInvitesUseCase { const codes = await this.cacheService.getCollection(userKey); if (!codes.length) { - return { - // TODO: реализовать полноценную пагинацию для инвайтов пользователя. - items: [], - meta: { - total: 0, - totalPages: 0, - page: 1, - limit: 10, - hasPrevPage: false, - hasNextPage: false, - }, - }; + return []; } const inviteKeys = codes.map((c) => `inv:code:${c}`); @@ -55,16 +44,6 @@ export class GetMyInvitesUseCase { }); } - return { - items: active, - meta: { - total: active.length, - totalPages: active.length ? 1 : 0, - page: 1, - limit: active.length, - hasPrevPage: false, - hasNextPage: false, - }, - }; + return active; } } diff --git a/src/team/application/use-cases/invitions/send-invitation.use-case.ts b/src/team/application/use-cases/invitions/send-invitation.use-case.ts index 5800646b..59f4bdf9 100644 --- a/src/team/application/use-cases/invitions/send-invitation.use-case.ts +++ b/src/team/application/use-cases/invitions/send-invitation.use-case.ts @@ -18,6 +18,7 @@ import { TeamInvitationEvent } from '../../../domain/events'; import { ITeamRepository, RawMemberRow } from '../../../domain/repository'; import { InviteMemberDto, type TeamInvite } from '../../dtos'; +import type { Team } from '../../../domain/entities'; import type { TeamRole } from '@shared/entities'; @Injectable() @@ -135,11 +136,10 @@ export class SendInvitationUseCase { } } - private buildInviteData(team: any, inviter: any, dto: InviteMemberDto): TeamInvite { + private buildInviteData(team: Team, inviter: RawMemberRow, dto: InviteMemberDto): TeamInvite { const expiresAt = new Date(Date.now() + this.INVITE_TTL * 1000); - const cdn = this.getCdnBaseUrl(); - const images = ImageHelper.buildResponsiveUrls(cdn, team.avatarUrl); + const images = ImageHelper.responsive(this.cfg, team.avatarUrl); return { teamId: team.id, @@ -174,12 +174,4 @@ export class SendInvitationUseCase { removeOnComplete: true, }); } - - private getCdnBaseUrl(): string { - const domain = this.cfg.get('DOMAIN'); - const bucket = this.cfg.get('S3_BUCKET_NAME'); - const endpoint = this.cfg.get('S3_ENDPOINT'); - - return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; - } } diff --git a/src/team/application/use-cases/members/get-team-members.query.ts b/src/team/application/use-cases/members/get-team-members.query.ts index f0a33ead..2e2fcf71 100644 --- a/src/team/application/use-cases/members/get-team-members.query.ts +++ b/src/team/application/use-cases/members/get-team-members.query.ts @@ -4,6 +4,7 @@ import { BaseException } from '@shared/error'; import { TeamMemberMapper } from '../../../application/mappers'; import { ITeamRepository } from '../../../domain/repository'; +import { TeamMembersQuery } from '../../dtos'; @Injectable() export class GetTeamMembersQuery { @@ -13,7 +14,7 @@ export class GetTeamMembersQuery { private readonly cfg: ConfigService, ) {} - async execute(teamId: string) { + async execute(teamId: string, query?: TeamMembersQuery) { const team = await this.teamRepo.findById(teamId); if (!team) { @@ -22,29 +23,13 @@ export class GetTeamMembersQuery { HttpStatus.NOT_FOUND, ); } - const cdn = this.getCdnBaseUrl(); - const members = await this.teamRepo.findMembers(team.id); - const data = TeamMemberMapper.toList(members, cdn); + + const { items, meta } = await this.teamRepo.findMembers(team.id, query); + const data = TeamMemberMapper.toList(items, this.cfg); return { - // TODO: реализовать полноценную пагинацию для участников команды. items: data, - meta: { - total: data.length, - totalPages: data.length ? 1 : 0, - page: 1, - limit: data.length, - hasPrevPage: false, - hasNextPage: false, - }, + meta, }; } - - private getCdnBaseUrl(): string { - const domain = this.cfg.get('DOMAIN'); - const bucket = this.cfg.get('S3_BUCKET_NAME'); - const endpoint = this.cfg.get('S3_ENDPOINT'); - - return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; - } } diff --git a/src/team/domain/entities/team.domain.ts b/src/team/domain/entities/team.domain.ts index 4ee5e26b..5a7d4750 100644 --- a/src/team/domain/entities/team.domain.ts +++ b/src/team/domain/entities/team.domain.ts @@ -8,5 +8,5 @@ export type TeamMember = InferSelectModel; export type NewTeamMember = InferInsertModel; export type TeamWithMembers = Team & { - readonly members: readonly TeamMember[]; + members: TeamMember[]; }; diff --git a/src/team/domain/repository/team.repository.interface.ts b/src/team/domain/repository/team.repository.interface.ts index e6bafbaf..f48a12e1 100644 --- a/src/team/domain/repository/team.repository.interface.ts +++ b/src/team/domain/repository/team.repository.interface.ts @@ -1,27 +1,28 @@ -import type { TeamRole, TeamMemberStatus } from '../../infrastructure/persistence/models'; +import type { TeamMembersQuery } from '../../application/dtos'; import type { Team, NewTeam, NewTeamMember } from '../entities'; +import type { CursorResult } from '@libs/database'; -type TResponse = { readonly success: boolean; readonly teamId: string }; +type TResponse = { success: boolean; teamId: string }; export type RawMemberRow = { - readonly userId: string; - readonly role: TeamRole; - readonly status: TeamMemberStatus; - readonly joinedAt: string | null; - readonly firstName: string | null; - readonly lastName: string | null; - readonly middleName: string | null; - readonly avatarUrl: string | null; - readonly email?: string; + userId: string; + role: 'owner' | 'admin' | 'member' | 'viewer'; + status: string; + joinedAt: string | null; + firstName: string; + lastName: string | null; + middleName: string | null; + avatarUrl: string | null; + email?: string; }; export type RawMemberTeams = { - readonly id: string; - readonly name: string; - readonly description: string | null; - readonly avatarUrl: string | null; - readonly role: string; - readonly joinedAt: string | null; + id: string; + name: string; + description: string | null; + avatarUrl: string | null; + role: string; + joinedAt: string | null; }; export interface ITeamRepository { @@ -30,7 +31,7 @@ export interface ITeamRepository { remove(id: string, userId: string): Promise; findMember(teamId: string, userId: string): Promise; - findMembers(teamId: string): Promise; + findMembers(teamId: string, query?: TeamMembersQuery): Promise>; findById(teamId: string): Promise; findByUser(userId: string): Promise; diff --git a/src/team/infrastructure/persistence/repositories/team.repository.ts b/src/team/infrastructure/persistence/repositories/team.repository.ts index fdf4e4d1..313da2f0 100644 --- a/src/team/infrastructure/persistence/repositories/team.repository.ts +++ b/src/team/infrastructure/persistence/repositories/team.repository.ts @@ -1,9 +1,10 @@ import * as scUsers from '@core/user/infrastructure/persistence/models'; -import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { DATABASE_SERVICE, DatabaseService, paginateCursor } from '@libs/database'; import { Inject } from '@nestjs/common'; import { and, desc, eq, isNull } from 'drizzle-orm'; -import { ITeamRepository } from '../../../domain/repository'; +import { TeamMembersQuery } from '../../../application/dtos'; +import { ITeamRepository, type RawMemberRow } from '../../../domain/repository'; import * as schema from '../models'; import type { NewTeam, NewTeamMember, Team, TeamMember } from '../../../domain/entities'; @@ -87,10 +88,18 @@ export class TeamRepository implements ITeamRepository { return member || null; }; - public findMembers = async (teamId: string) => - this.membersQuery + public findMembers = async (teamId: string, query: TeamMembersQuery) => { + const q = this.membersQuery .where(eq(schema.teamMembers.teamId, teamId)) - .orderBy(desc(schema.teamMembers.joinedAt)); + .orderBy(desc(schema.teamMembers.joinedAt)) + .$dynamic(); + + return paginateCursor(q, { + column: schema.teamMembers.teamId, + cursor: query.cursor, + limit: query.limit, + }); + }; public findByUser = async (userId: string) => { const filters = [ diff --git a/src/user/application/controllers/user/controller.ts b/src/user/application/controllers/user/controller.ts index a99f0616..b3aeb742 100644 --- a/src/user/application/controllers/user/controller.ts +++ b/src/user/application/controllers/user/controller.ts @@ -1,6 +1,6 @@ import { Body, Get, Patch, Query } from '@nestjs/common'; import { ApiBaseController, GetUserId } from '@shared/decorators'; -import { PaginationQuery } from '@shared/schemas'; +import { CursorQuery } from '@shared/schemas'; import { UpdateProfileDto } from '../../dtos'; import { UserFacade } from '../../user.facade'; @@ -25,7 +25,7 @@ export class UserController { @Get('activity') @GetMeActivitySwagger() - async getActivity(@Query() query: PaginationQuery, @GetUserId() id: string) { + async getActivity(@Query() query: CursorQuery, @GetUserId() id: string) { return this.facade.getActivity(id, query); } } diff --git a/src/user/application/dtos/user.dto.ts b/src/user/application/dtos/user.dto.ts index 5046d290..67a70dc0 100644 --- a/src/user/application/dtos/user.dto.ts +++ b/src/user/application/dtos/user.dto.ts @@ -1,4 +1,4 @@ -import { AvatarResponseSchema, createPaginationSchema } from '@shared/schemas'; +import { AvatarResponseSchema, createCursorResponseSchema } from '@shared/schemas'; import { createZodDto } from 'nestjs-zod'; import { z } from 'zod/v4'; @@ -185,8 +185,8 @@ const UserActivityItemSchema = z }) .describe('Элемент активности пользователя'); -export const UserActivityResponseSchema = createPaginationSchema(UserActivityItemSchema).describe( - 'Ответ со списком активности пользователя', -); +export const UserActivityResponseSchema = createCursorResponseSchema( + UserActivityItemSchema, +).describe('Ответ со списком активности пользователя'); export class UserActivityResponse extends createZodDto(UserActivityResponseSchema) {} diff --git a/src/user/application/use-cases/find-by-ids.query.ts b/src/user/application/use-cases/find-by-ids.query.ts index 5dc1dcf5..74cbf0c2 100644 --- a/src/user/application/use-cases/find-by-ids.query.ts +++ b/src/user/application/use-cases/find-by-ids.query.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; export class FindByIdsQuery { constructor(@Inject('IUserRepository') private readonly userRepo: IUserRepository) {} - async execute(ids: readonly string[]) { + async execute(ids: string[]) { return this.userRepo.findByIds(ids); } } diff --git a/src/user/application/use-cases/find-profile.query.ts b/src/user/application/use-cases/find-profile.query.ts index 49f33e7d..b25259ee 100644 --- a/src/user/application/use-cases/find-profile.query.ts +++ b/src/user/application/use-cases/find-profile.query.ts @@ -29,9 +29,8 @@ export class FindProfileQuery { const { notifications, preferences, security, user } = entity; const { id, email, avatarUrl, ...profile } = user; - const cdn = this.getCdnBaseUrl(); - const avatar = ImageHelper.buildResponsiveUrls(cdn, avatarUrl); + const avatar = ImageHelper.responsive(this.cfg, avatarUrl); return { id, @@ -49,12 +48,4 @@ export class FindProfileQuery { notifications, }; } - - private getCdnBaseUrl(): string { - const domain = this.cfg.get('DOMAIN'); - const bucket = this.cfg.get('S3_BUCKET_NAME'); - const endpoint = this.cfg.get('S3_ENDPOINT'); - - return domain ? `https://cdn.${domain}/${bucket}` : `${endpoint}/${bucket}`; - } } diff --git a/src/user/application/use-cases/get-activity.query.ts b/src/user/application/use-cases/get-activity.query.ts index 4439e685..ed615ce9 100644 --- a/src/user/application/use-cases/get-activity.query.ts +++ b/src/user/application/use-cases/get-activity.query.ts @@ -1,6 +1,6 @@ import { IUserRepository } from '@core/user/domain/repository'; import { Inject, Injectable } from '@nestjs/common'; -import { PaginationQuery } from '@shared/schemas'; +import { CursorQuery } from '@shared/schemas'; @Injectable() export class GetActivityQuery { @@ -9,30 +9,15 @@ export class GetActivityQuery { private readonly userRepo: IUserRepository, ) {} - async execute(id: string, query: PaginationQuery) { - const { limit, page } = query; - - const safeLimit = Math.min(limit, 50); - const offset = (page - 1) * safeLimit; - - const { items, total } = await this.userRepo.findActivityByUser(id, { - limit: safeLimit, - offset, + async execute(id: string, query: CursorQuery) { + const { items, meta } = await this.userRepo.findActivityByUser(id, { + limit: Math.min(query.limit, 50), + cursor: query.cursor, }); - const totalPages = Math.ceil(total / safeLimit); - return { - // TODO: реализовать полноценную пагинацию по общей схеме (hasNextPage/hasPrevPage) везде. items, - meta: { - total, - page, - limit: safeLimit, - totalPages, - hasPrevPage: page > 1, - hasNextPage: totalPages > 0 && page < totalPages, - }, + meta, }; } } diff --git a/src/user/application/user.facade.ts b/src/user/application/user.facade.ts index 28be54f1..223ce5ed 100644 --- a/src/user/application/user.facade.ts +++ b/src/user/application/user.facade.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { PaginationQuery } from '@shared/schemas'; +import { CursorQuery } from '@shared/schemas'; import { UpdateProfileDto, UpdateNotificationsDto } from './dtos'; import { @@ -22,7 +22,7 @@ export class UserFacade { return this.findProfileQuery.execute(userId); } - public async getActivity(userId: string, query: PaginationQuery) { + public async getActivity(userId: string, query: CursorQuery) { return this.getActivityQuery.execute(userId, query); } diff --git a/src/user/domain/entities/user.domain.ts b/src/user/domain/entities/user.domain.ts index 53918888..27ea5c21 100644 --- a/src/user/domain/entities/user.domain.ts +++ b/src/user/domain/entities/user.domain.ts @@ -23,18 +23,18 @@ export type UserActivity = InferSelectModel; export type NewUserActivity = InferInsertModel; export type UserProfile = { - readonly user: User; - readonly security: { - readonly lastPasswordChange: string | null; - readonly is2faEnabled: boolean; + user: User; + security: { + lastPasswordChange: string | null; + is2faEnabled: boolean; }; - readonly preferences: UserPreferences | null; - readonly notifications: NotificationSettings; + preferences: UserPreferences | null; + notifications: NotificationSettings; }; export type UserWithSecurity = { - readonly user: User; - readonly security: { - readonly passwordHash: string | null; + user: User; + security: { + passwordHash: string | null; }; }; diff --git a/src/user/domain/repository/user.repository.interface.ts b/src/user/domain/repository/user.repository.interface.ts index c27abc5e..683135e0 100644 --- a/src/user/domain/repository/user.repository.interface.ts +++ b/src/user/domain/repository/user.repository.interface.ts @@ -8,22 +8,18 @@ import type { UserProfile, UserWithSecurity, } from '../entities'; +import type { CursorResult } from '@libs/database'; +import type { CursorQuery } from '@shared/schemas'; -type DeepPartial = { readonly [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] }; +type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] }; export interface IUserRepository { create(data: NewUser): Promise; findById(id: string): Promise; - findByIds(ids: readonly string[]): Promise; + findByIds(ids: string[]): Promise; findByEmail(email: string): Promise; findProfile(id: string): Promise; - findActivityByUser( - userId: string, - options: { readonly limit: number; readonly offset: number }, - ): Promise<{ - readonly items: readonly UserActivity[]; - readonly total: number; - }>; + findActivityByUser(userId: string, options: CursorQuery): Promise>; updateAvatar(id: string, url: string): Promise; updateProfile( id: string, diff --git a/src/user/infrastructure/persistence/repositories/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts index 7362f534..ef9b1365 100644 --- a/src/user/infrastructure/persistence/repositories/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -1,11 +1,13 @@ import { IUserRepository } from '@core/user/domain/repository'; -import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; +import { DATABASE_SERVICE, DatabaseService, paginateCursor } from '@libs/database'; import { Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; -import { desc, eq, count, inArray } from 'drizzle-orm'; +import { CursorQuery } from '@shared/schemas'; +import { eq, inArray } from 'drizzle-orm'; import * as sc from '../models'; +import type { UserActivity } from '../../../domain/entities/user.domain'; import type { NewUser, NewUserActivity, @@ -181,23 +183,23 @@ export class UserRepository implements IUserRepository { } } - async updateNotifications(id: string, settings: UserNotifications['settings']) { + public updateNotifications = async (id: string, settings: UserNotifications['settings']) => { const result = await this.db .update(sc.userNotifications) .set({ settings }) .where(eq(sc.userNotifications.userId, id)); return (result?.count ?? 0) > 0; - } + }; - async updateAvatar(id: string, url: string) { + public updateAvatar = async (id: string, url: string) => { const result = await this.db .update(sc.users) .set({ avatarUrl: url, updatedAt: new Date().toISOString() }) .where(eq(sc.users.id, id)); return (result?.count ?? 0) > 0; - } + }; - async updatePasswordHash(id: string, hash: string) { + public updatePasswordHash = async (id: string, hash: string) => { const result = await this.db .insert(sc.userSecurity) .values({ userId: id, passwordHash: hash }) @@ -206,34 +208,27 @@ export class UserRepository implements IUserRepository { set: { passwordHash: hash, lastPasswordChange: new Date().toISOString() }, }); return (result?.count ?? 0) > 0; - } + }; - async logActivity(data: NewUserActivity) { + public logActivity = async (data: NewUserActivity) => { const result = await this.db.insert(sc.userActivity).values({ ...data, id: data.id ?? createId(), }); return (result?.count ?? 0) > 0; - } - - async findActivityByUser(userId: string, options: { limit: number; offset: number }) { - const [totalResult, items] = await Promise.all([ - this.db - .select({ value: count() }) - .from(sc.userActivity) - .where(eq(sc.userActivity.userId, userId)), - this.db - .select() - .from(sc.userActivity) - .where(eq(sc.userActivity.userId, userId)) - .limit(options.limit) - .offset(options.offset) - .orderBy(desc(sc.userActivity.createdAt)), - ]); + }; - return { - items, - total: Number(totalResult[0]?.value ?? 0), - }; - } + public findActivityByUser = async (userId: string, query: CursorQuery) => { + const q = this.db + .select() + .from(sc.userActivity) + .where(eq(sc.userActivity.userId, userId)) + .$dynamic(); + + return paginateCursor(q, { + column: sc.userActivity.id, + ...query, + sort: { column: sc.userActivity.id, order: 'desc' }, + }); + }; }