From 57a20b05223c7b4f3b6859db8b78bf40ff50b32b Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 29 Jun 2026 19:35:38 +0300 Subject: [PATCH 1/4] fix(issue): update move logic to handle position, area, and state adjustments --- src/issue/application/use-cases/base/move.use-case.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/issue/application/use-cases/base/move.use-case.ts b/src/issue/application/use-cases/base/move.use-case.ts index 2de6f05..f5ee040 100644 --- a/src/issue/application/use-cases/base/move.use-case.ts +++ b/src/issue/application/use-cases/base/move.use-case.ts @@ -32,9 +32,16 @@ export class MoveIssueUseCase { HttpStatus.NOT_FOUND, ); } + await this.validateContext(dto, key, userId); - const result = await this.issueRepo.update(id, dto, userId); + const data = { + position: dto.position, + areaId: dto.targetAreaId ?? issue.areaId, + stateId: dto.targetStateId ?? issue.stateId, + }; + + const result = await this.issueRepo.update(id, data, userId); return { success: result, From 334b3514106876bac22a863f7f3ed7669814ab49 Mon Sep 17 00:00:00 2001 From: Maxim Date: Mon, 29 Jun 2026 19:36:12 +0300 Subject: [PATCH 2/4] fix(project): make project description nullable in DTO --- src/project/application/dtos/project.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/project/application/dtos/project.dto.ts b/src/project/application/dtos/project.dto.ts index 4221878..014265a 100644 --- a/src/project/application/dtos/project.dto.ts +++ b/src/project/application/dtos/project.dto.ts @@ -161,7 +161,7 @@ export const ProjectListItemSchema = z.object({ id: z.string().describe('ID проекта'), slug: z.string().describe('Slug проекта (URL-идентификатор)'), name: z.string().describe('Название проекта'), - description: z.string().describe('Описание проекта'), + description: z.string().nullable().describe('Описание проекта'), status: ProjectStatusSchema.default('active').describe('Текущий статус проекта'), color: z.string().describe('Цвет проекта'), icon: z.string().nullish().describe('Иконка проекта'), From 118ca4858197906d8e0170b3814416fba3a94968 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 30 Jun 2026 02:45:51 +0300 Subject: [PATCH 3/4] feat(state): implement move logic and state reordering triggers --- src/area/application/area.facade.ts | 10 +- .../controllers/area/controller.ts | 2 +- .../controllers/state/controller.ts | 25 ++-- src/area/application/dtos/state.dto.ts | 45 ++++--- .../application/use-cases/states/index.ts | 7 +- .../use-cases/states/move.use-case.ts | 113 ++++++++++++++++++ .../use-cases/states/reorder.use-case.ts | 57 +-------- src/area/area.module.ts | 3 +- src/area/domain/enums/index.ts | 1 + src/area/domain/enums/state-jobs.enum.ts | 7 ++ src/area/domain/events/index.ts | 1 + .../domain/events/reorder-states.event.ts | 3 + .../repository/states.repository.interface.ts | 1 + src/area/infrastructure/constants/index.ts | 2 + .../repositories/state.repository.ts | 28 ++++- .../workers/reorder-state.processor.ts | 46 +++++++ 16 files changed, 260 insertions(+), 91 deletions(-) create mode 100644 src/area/application/use-cases/states/move.use-case.ts create mode 100644 src/area/domain/enums/state-jobs.enum.ts create mode 100644 src/area/domain/events/reorder-states.event.ts create mode 100644 src/area/infrastructure/workers/reorder-state.processor.ts diff --git a/src/area/application/area.facade.ts b/src/area/application/area.facade.ts index 2886ed6..d49950f 100644 --- a/src/area/application/area.facade.ts +++ b/src/area/application/area.facade.ts @@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common'; import { CreateStateDto, + MoveStateDto, UpdateStateDto, - ReordersStatesDto, CreateAreaDto, UpdateAreaDto, QueryParamsDto, @@ -20,7 +20,7 @@ import { DeleteStateUseCase, GetStateQuery, GetStatesQuery, - ReorderStateUseCase, + MoveStateUseCase, RestoreStateUseCase, UpdateStateUseCase, } from './use-cases/states'; @@ -40,7 +40,7 @@ export class AreaFacade { private readonly updateStateUC: UpdateStateUseCase, private readonly deleteStateUC: DeleteStateUseCase, private readonly restoreStateUC: RestoreStateUseCase, - private readonly reorderStateUC: ReorderStateUseCase, + private readonly moveStateUC: MoveStateUseCase, ) {} public async createArea(slug: string, dto: CreateAreaDto, userId: string) { @@ -87,7 +87,7 @@ export class AreaFacade { return this.restoreStateUC.execute(slug, stateId, userId); } - public async reoderStates(slug: string, dto: ReordersStatesDto, userId: string) { - return this.reorderStateUC.execute(slug, dto, userId); + public async moveState(slug: string, stateId: string, dto: MoveStateDto, userId: string) { + return this.moveStateUC.execute(slug, stateId, dto, userId); } } diff --git a/src/area/application/controllers/area/controller.ts b/src/area/application/controllers/area/controller.ts index 37a5a8e..2922f6b 100644 --- a/src/area/application/controllers/area/controller.ts +++ b/src/area/application/controllers/area/controller.ts @@ -12,7 +12,7 @@ import { UpdateAreaSwagger, } from './swagger'; -@ApiBaseController('projects/:slug/area', 'Project Areas', true) +@ApiBaseController('projects/:slug/areas', 'Project Areas', true) export class AreaController { constructor(private readonly facade: AreaFacade) {} diff --git a/src/area/application/controllers/state/controller.ts b/src/area/application/controllers/state/controller.ts index c0fa034..273e648 100644 --- a/src/area/application/controllers/state/controller.ts +++ b/src/area/application/controllers/state/controller.ts @@ -2,14 +2,14 @@ import { Body, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiBaseController, GetUserId, Public } from '@shared/decorators'; import { AreaFacade } from '../../area.facade'; -import { CreateStateDto, QueryParamsDto, ReordersStatesDto, UpdateStateDto } from '../../dtos'; +import { CreateStateDto, MoveStateDto, QueryParamsDto, UpdateStateDto } from '../../dtos'; import { CreateStateSwagger, FindAllStatesSwagger, FindOneStateSwagger, + MoveStateSwagger, RemoveStateSwagger, - ReorderStatesSwagger, RestoreStateSwagger, UpdateStateSwagger, } from './swagger'; @@ -59,16 +59,6 @@ export class StateController { return this.facade.deleteState(slug, stateId, userId); } - @Patch('reorder') - @ReorderStatesSwagger() - async reorder( - @Param('slug') slug: string, - @Body() dto: ReordersStatesDto, - @GetUserId() userId: string, - ) { - return this.facade.reoderStates(slug, dto, userId); - } - @Patch(':stateId') @UpdateStateSwagger() async update( @@ -89,4 +79,15 @@ export class StateController { ) { return this.facade.restoreState(slug, stateId, userId); } + + @Post(':stateId/move') + @MoveStateSwagger() + async move( + @Param('slug') slug: string, + @Param('stateId') stateId: string, + @Body() dto: MoveStateDto, + @GetUserId() userId: string, + ) { + return this.facade.moveState(slug, stateId, dto, userId); + } } diff --git a/src/area/application/dtos/state.dto.ts b/src/area/application/dtos/state.dto.ts index 2ca3aae..8bf728b 100644 --- a/src/area/application/dtos/state.dto.ts +++ b/src/area/application/dtos/state.dto.ts @@ -123,22 +123,6 @@ export const CreateStateSchema = StateSchema.omit({ }) .describe('Схема для создания нового состояния'); -export const ReorderStateItemSchema = z.object({ - id: z.string().describe('ID состояния'), - orderIndex: z.number().min(0).describe('Новый порядковый индекс'), -}); - -export const ReorderStatesSchema = z.object({ - items: z.array(ReorderStateItemSchema).min(1).describe('Массив состояний с новыми индексами'), -}); - -export class StateResponse extends createZodDto(StateSchema) {} -export class StatesResponse extends createZodDto(StatesSchema) {} -export class CreateStateDto extends createZodDto(CreateStateSchema) {} -export class UpdateStateDto extends createZodDto(CreateStateSchema.partial()) {} -export class CreateStateResponse extends createZodDto(CreateStateResponseSchema) {} -export class ReordersStatesDto extends createZodDto(ReorderStatesSchema) {} - export const QueryParamsSchema = z .object({ hidden: z.boolean().optional().default(false).describe('Скрытые записи'), @@ -151,3 +135,32 @@ export const QueryParamsSchema = z .extend(createSortingSchema(['order', 'title', 'tasksCount', 'createdAt']).shape); export class QueryParamsDto extends createZodDto(QueryParamsSchema) {} + +export const MoveStateSchema = z + .object({ + position: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .describe('Новая позиция состояния на доске'), + prevStatePosition: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .nullable() + .describe('Позиция предыдущего состояния'), + nextStatePosition: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .nullable() + .describe('Позиция следующего состояния'), + }) + .describe('Схема для перемещения состояния (колонки) на доске'); + +export class MoveStateDto extends createZodDto(MoveStateSchema) {} +export class StateResponse extends createZodDto(StateSchema) {} +export class StatesResponse extends createZodDto(StatesSchema) {} +export class CreateStateDto extends createZodDto(CreateStateSchema) {} +export class UpdateStateDto extends createZodDto(CreateStateSchema.partial()) {} +export class CreateStateResponse extends createZodDto(CreateStateResponseSchema) {} diff --git a/src/area/application/use-cases/states/index.ts b/src/area/application/use-cases/states/index.ts index 21b070a..844e553 100644 --- a/src/area/application/use-cases/states/index.ts +++ b/src/area/application/use-cases/states/index.ts @@ -2,7 +2,8 @@ import { CreateStateUseCase } from './create.use-case'; import { DeleteStateUseCase } from './delete.use-case'; import { GetStatesQuery } from './get-all.query'; import { GetStateQuery } from './get-one.query'; -import { ReorderStateUseCase } from './reorder.use-case'; +import { MoveStateUseCase } from './move.use-case'; +import { ReorderStatesUseCase } from './reorder.use-case'; import { RestoreStateUseCase } from './restore.use-state'; import { UpdateStateUseCase } from './update.use-case'; @@ -14,6 +15,7 @@ export * from './get-all.query'; export * from './restore.use-state'; export * from './update.use-case'; export * from './reorder.use-case'; +export * from './move.use-case'; export const StatesUseCases = [ CreateStateUseCase, @@ -22,5 +24,6 @@ export const StatesUseCases = [ UpdateStateUseCase, GetStateQuery, GetStatesQuery, - ReorderStateUseCase, + ReorderStatesUseCase, + MoveStateUseCase, ]; diff --git a/src/area/application/use-cases/states/move.use-case.ts b/src/area/application/use-cases/states/move.use-case.ts new file mode 100644 index 0000000..401fbc2 --- /dev/null +++ b/src/area/application/use-cases/states/move.use-case.ts @@ -0,0 +1,113 @@ +import { StateJobs, StateQueues } from '@core/area/domain/enums'; +import { + AreaErrorCodes, + AreaErrorMessages, + StateErrorCodes, + StateErrorMessages, +} from '@core/area/domain/errors'; +import { ReorderStatesEvent } from '@core/area/domain/events'; +import { IAreaRepository, IStateRepository } from '@core/area/domain/repository'; +import { REORDER_TRIGGER } from '@core/area/infrastructure/constants'; +import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { InjectQueue } from '@nestjs/bullmq'; +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; + +import { MoveStateDto } from '../../dtos'; + +@Injectable() +export class MoveStateUseCase { + constructor( + @Inject('IStateRepository') + private readonly stateRepo: IStateRepository, + @Inject('IAreaRepository') + private readonly areaRepo: IAreaRepository, + @InjectQueue(StateQueues.STATE) + private readonly stateQueue: Queue, + private readonly projectPolicy: ProjectAccessPolicy, + ) {} + + async execute(slug: string, stateId: string, dto: MoveStateDto, userId: string) { + try { + await this.projectPolicy.ensureProjectAccess(slug, userId, ['owner', 'admin']); + + const area = await this.areaRepo.findBySlug(slug); + + if (!area) { + throw new BaseException( + { + code: AreaErrorCodes.NOT_FOUND, + message: AreaErrorMessages[AreaErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + const state = await this.stateRepo.findOne(area.id, stateId); + + if (!state) { + throw new BaseException( + { + code: StateErrorCodes.NOT_FOUND, + message: StateErrorMessages[StateErrorCodes.NOT_FOUND], + }, + HttpStatus.NOT_FOUND, + ); + } + + if (state.isLocked) { + throw new BaseException( + { + code: StateErrorCodes.LOCKED, + message: StateErrorMessages[StateErrorCodes.LOCKED], + }, + HttpStatus.CONFLICT, + ); + } + + const { position } = dto; + + await this.stateRepo.update(area.id, stateId, { position }); + + await this.checkReorder(dto, area.id); + + return { + success: true, + message: 'Состояние успешно перемещено', + }; + } catch (err) { + if (err instanceof BaseException) { + throw err; + } + + throw new BaseException( + { + code: StateErrorCodes.REORDER_FAILED, + message: StateErrorMessages[StateErrorCodes.REORDER_FAILED], + }, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async checkReorder(dto: MoveStateDto, areaId: string) { + const prev = dto.prevStatePosition; + const next = dto.nextStatePosition; + + let distance = Infinity; + + if (prev !== null && next !== null) { + distance = Math.abs(next - prev); + } else if (prev !== null) { + distance = Math.abs(dto.position - prev); + } else if (next !== null) { + distance = Math.abs(next - dto.position); + } + + if (distance < REORDER_TRIGGER) { + const event = new ReorderStatesEvent(areaId); + await this.stateQueue.add(StateJobs.REORDER_STATES, event); + } + } +} diff --git a/src/area/application/use-cases/states/reorder.use-case.ts b/src/area/application/use-cases/states/reorder.use-case.ts index 451215c..0f8dc2e 100644 --- a/src/area/application/use-cases/states/reorder.use-case.ts +++ b/src/area/application/use-cases/states/reorder.use-case.ts @@ -1,63 +1,14 @@ -import { StateErrorCodes, StateErrorMessages } from '@core/area/domain/errors'; import { IStateRepository } from '@core/area/domain/repository'; -import { HttpStatus, Inject, Injectable } from '@nestjs/common'; -import { BaseException } from '@shared/error'; - -import { ReordersStatesDto } from '../../dtos'; -import { GetAreaQuery } from '../areas'; +import { Inject, Injectable } from '@nestjs/common'; @Injectable() -export class ReorderStateUseCase { +export class ReorderStatesUseCase { constructor( @Inject('IStateRepository') private readonly stateRepo: IStateRepository, - private readonly getAreaQ: GetAreaQuery, ) {} - async execute(slug: string, _dto: ReordersStatesDto, userId: string) { - throw new BaseException( - { - code: 'NOT_IMPLEMENTED', - message: 'Функция в разработке', - }, - HttpStatus.NOT_IMPLEMENTED, - ); - - try { - const area = await this.getAreaQ.execute({ key: slug }, userId); - - const state = await this.stateRepo.findOne(area.id, slug); - - if (!state) { - throw new BaseException( - { - code: StateErrorCodes.NOT_FOUND, - message: StateErrorMessages[StateErrorCodes.NOT_FOUND], - }, - HttpStatus.NOT_FOUND, - ); - } - - const result = true; - - return { - success: result, - message: result - ? 'Состояние успешно восстановлено' - : 'Не удалось восстановить состояние: запись не найдена или уже активна', - }; - } catch (err) { - if (err instanceof BaseException) { - throw err; - } - - throw new BaseException( - { - code: StateErrorCodes.REORDER_FAILED, - message: StateErrorMessages[StateErrorCodes.REORDER_FAILED], - }, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } + async execute(areaId: string) { + return this.stateRepo.reorder(areaId); } } diff --git a/src/area/area.module.ts b/src/area/area.module.ts index cf1a10a..4ee24f8 100644 --- a/src/area/area.module.ts +++ b/src/area/area.module.ts @@ -1,3 +1,4 @@ +import { StateQueues } from '@core/area/domain/enums'; import { ProjectModule } from '@core/project'; import { BullModule } from '@nestjs/bullmq'; import { forwardRef, Module } from '@nestjs/common'; @@ -11,7 +12,7 @@ import { AreaProcessor } from './infrastructure/workers/area.processor'; @Module({ imports: [ - BullModule.registerQueue({ name: AreaQueues.AREA_WORKSPACE }), + BullModule.registerQueue({ name: AreaQueues.AREA_WORKSPACE }, { name: StateQueues.STATE }), forwardRef(() => ProjectModule), ], controllers: [...CONTROLLERS], diff --git a/src/area/domain/enums/index.ts b/src/area/domain/enums/index.ts index e4c04b8..55758ba 100644 --- a/src/area/domain/enums/index.ts +++ b/src/area/domain/enums/index.ts @@ -1 +1,2 @@ export { AreaQueues, AreaWorkspaceJobs } from './area-jobs.enum'; +export { StateQueues, StateJobs } from './state-jobs.enum'; diff --git a/src/area/domain/enums/state-jobs.enum.ts b/src/area/domain/enums/state-jobs.enum.ts new file mode 100644 index 0000000..24928d8 --- /dev/null +++ b/src/area/domain/enums/state-jobs.enum.ts @@ -0,0 +1,7 @@ +export const enum StateQueues { + STATE = 'STATE_QUEUE', +} + +export const enum StateJobs { + REORDER_STATES = 'REORDER_STATES', +} diff --git a/src/area/domain/events/index.ts b/src/area/domain/events/index.ts index c52aeab..a1d4ecc 100644 --- a/src/area/domain/events/index.ts +++ b/src/area/domain/events/index.ts @@ -1 +1,2 @@ export * from './area-workspace.event'; +export * from './reorder-states.event'; diff --git a/src/area/domain/events/reorder-states.event.ts b/src/area/domain/events/reorder-states.event.ts new file mode 100644 index 0000000..9148344 --- /dev/null +++ b/src/area/domain/events/reorder-states.event.ts @@ -0,0 +1,3 @@ +export class ReorderStatesEvent { + constructor(public readonly areaId: string) {} +} diff --git a/src/area/domain/repository/states.repository.interface.ts b/src/area/domain/repository/states.repository.interface.ts index 93e35a1..1eec8a0 100644 --- a/src/area/domain/repository/states.repository.interface.ts +++ b/src/area/domain/repository/states.repository.interface.ts @@ -12,4 +12,5 @@ export interface IStateRepository { type: 'custom' | 'archived' | 'backlog' | 'todo' | 'in_progress' | 'review' | 'done', ): Promise; countByArea(areaId: string): Promise; + reorder(areaId: string): Promise; } diff --git a/src/area/infrastructure/constants/index.ts b/src/area/infrastructure/constants/index.ts index 204598d..feef819 100644 --- a/src/area/infrastructure/constants/index.ts +++ b/src/area/infrastructure/constants/index.ts @@ -9,3 +9,5 @@ export const DEFAULT_STATES = [ { title: 'Готово', type: 'done', category: 'completed', position: 4, color: '#10B981' }, { title: 'Архив', type: 'archived', category: 'archived', position: 5, color: '#6B7280' }, ] as const; + +export const REORDER_TRIGGER = 0.00001; diff --git a/src/area/infrastructure/persistence/repositories/state.repository.ts b/src/area/infrastructure/persistence/repositories/state.repository.ts index 261e67a..074a714 100644 --- a/src/area/infrastructure/persistence/repositories/state.repository.ts +++ b/src/area/infrastructure/persistence/repositories/state.repository.ts @@ -1,7 +1,7 @@ import { IStateRepository } from '@core/area/domain/repository'; import { DATABASE_SERVICE, DatabaseService } from '@libs/database'; import { Inject, Injectable } from '@nestjs/common'; -import { and, count, eq, isNotNull, isNull } from 'drizzle-orm'; +import { and, count, eq, isNotNull, isNull, sql } from 'drizzle-orm'; import * as schema from '../models'; @@ -113,6 +113,32 @@ export class StateRepository implements IStateRepository { return result ?? null; } + public async reorder(areaId: string) { + const currentStates = await this.db + .select({ id: schema.states.id }) + .from(schema.states) + .where(eq(schema.states.areaId, areaId)) + .orderBy(schema.states.position); + + if (currentStates.length === 0) { + return; + } + + const STEP = 100.0; + const sqlChunks: string[] = []; + + currentStates.forEach((state, index) => { + const newPos = (index + 1) * STEP; + sqlChunks.push(`WHEN id = '${state.id}' THEN ${newPos}::double precision`); + }); + + await this.db.execute(sql` + UPDATE ${schema.states} + SET position = CASE ${sql.raw(sqlChunks.join(' '))} END + WHERE area_id = ${areaId} + `); + } + public readonly countByArea = async (areaId: string) => { const [result] = await this.db .select({ count: count() }) diff --git a/src/area/infrastructure/workers/reorder-state.processor.ts b/src/area/infrastructure/workers/reorder-state.processor.ts new file mode 100644 index 0000000..89fb77e --- /dev/null +++ b/src/area/infrastructure/workers/reorder-state.processor.ts @@ -0,0 +1,46 @@ +import { StateJobs, StateQueues } from '@core/area/domain/enums'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import { Job } from 'bullmq'; + +import { ReorderStatesUseCase } from '../../application/use-cases'; +import { ReorderStatesEvent } from '../../domain/events'; + +@Injectable() +@Processor(StateQueues.STATE) +export class ReorderStateProcessor extends WorkerHost { + constructor(private readonly reorderState: ReorderStatesUseCase) { + super(); + } + + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + switch (job.name) { + case StateJobs.REORDER_STATES: + await this.handleReorderState(job); + break; + + default: + await job.log(`[WRN] No handler for job: ${job.name}`); + await job.updateProgress(100); + } + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + await job.log(String(error)); + throw error; + } + } + + private readonly handleReorderState = async (job: Job) => { + await job.log(`[INFO] Reordering states in area with ID: ${job.data.areaId}`); + await job.updateProgress(20); + + await this.reorderState.execute(job.data.areaId); + + await job.log(`[INFO] Finished reordering states in area with ID: ${job.data.areaId}`); + await job.updateProgress(100); + }; +} From de885b0e1cc833ac081909e481a3c2c1f7f05d73 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 30 Jun 2026 02:48:47 +0300 Subject: [PATCH 4/4] feat(issue): add issue reordering with worker processing and position updates --- .../application/controllers/state/swagger.ts | 31 +++++++------ src/issue/application/dtos/issue.dto.ts | 17 ++++++- .../use-cases/base/assign.use-case.ts | 2 +- .../use-cases/base/move.use-case.ts | 36 +++++++++++++-- .../use-cases/base/reorder.use-case.ts | 14 ++++++ .../use-cases/base/update.use-case.ts | 2 +- src/issue/application/use-cases/index.ts | 3 ++ src/issue/domain/enums/index.ts | 1 + src/issue/domain/enums/issue-jobs.enum.ts | 7 +++ src/issue/domain/events/index.ts | 1 + .../domain/events/issue-reorder.event.ts | 3 ++ .../issue.repository.interface.ts | 3 +- src/issue/infrastructure/constants/index.ts | 1 + .../repositories/issue.repository.ts | 32 ++++++++++--- .../workers/reorder-issues.processor.ts | 45 +++++++++++++++++++ src/issue/issue.module.ts | 4 +- .../use-cases/project/find-one.query.ts | 2 +- .../repositories/user.repository.ts | 6 ++- 18 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 src/issue/application/use-cases/base/reorder.use-case.ts create mode 100644 src/issue/domain/enums/index.ts create mode 100644 src/issue/domain/enums/issue-jobs.enum.ts create mode 100644 src/issue/domain/events/index.ts create mode 100644 src/issue/domain/events/issue-reorder.event.ts create mode 100644 src/issue/infrastructure/constants/index.ts create mode 100644 src/issue/infrastructure/workers/reorder-issues.processor.ts diff --git a/src/area/application/controllers/state/swagger.ts b/src/area/application/controllers/state/swagger.ts index c4ed279..8559a60 100644 --- a/src/area/application/controllers/state/swagger.ts +++ b/src/area/application/controllers/state/swagger.ts @@ -14,10 +14,10 @@ import { ActionResponse } from '@shared/schemas'; import { CreateStateDto, UpdateStateDto, - ReordersStatesDto, CreateStateResponse, StateResponse, StatesResponse, + MoveStateDto, } from '../../dtos'; export const FindAllStatesSwagger = () => @@ -205,16 +205,14 @@ export const UpdateStateSwagger = () => SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); -export const ReorderStatesSwagger = () => +export const MoveStateSwagger = () => applyDecorators( ApiOperation({ - deprecated: true, - summary: 'Изменить порядок колонок на доске', + summary: 'Переместить колонку на доске', description: [ - 'Позволяет переставить колонки на канбан-доске так, как вам удобно.', - 'Вы просто передаёте массив ID колонок в нужном порядке —', - 'сервер сохранит эту последовательность.', - 'Например, вы хотите, чтобы колонка «Готово» была не последней, а перед «Архивом».', + 'Изменяет порядок колонки (статуса) на доске проекта.', + 'Позволяет переставлять колонки местами, задавая новую позицию.', + 'Кастомные колонки можно перемещать свободно, системные — нельзя.', ].join('\n'), }), ApiParam({ @@ -223,19 +221,26 @@ export const ReorderStatesSwagger = () => description: 'Slug проекта', example: 'super-project', }), + ApiParam({ + name: 'stateId', + type: 'string', + description: 'State id состояния', + example: 'clv123456', + }), ApiBody({ - type: ReordersStatesDto.Output, - description: 'Массив ID состояний в правильном порядке', + type: MoveStateDto.Output, + description: 'Данные для перемещения состояния', }), ApiResponse({ status: 200, - description: 'Порядок обновлён', + description: 'Состояние перемещено', type: ActionResponse.Output, }), ApiValidationError(), - ApiNotFound('Одно или несколько состояний не найдены'), + ApiNotFound('Состояние не найдено'), ApiUnauthorized(), - ApiForbidden('Нет прав для изменения порядка'), + ApiForbidden('Нельзя переместить системный статус'), + ApiConflict('Состояние заблокировано'), SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse), ); diff --git a/src/issue/application/dtos/issue.dto.ts b/src/issue/application/dtos/issue.dto.ts index 968e20b..4792a14 100644 --- a/src/issue/application/dtos/issue.dto.ts +++ b/src/issue/application/dtos/issue.dto.ts @@ -154,7 +154,10 @@ export const CreateIssueResponseSchema = ActionResponseSchema.extend({ id: z.string().describe('Уникальный идентификатор созданной задачи'), }); -export const UpdateIssueSchema = CreateIssueSchema.partial() +export const UpdateIssueSchema = CreateIssueSchema.omit({ + position: true, +}) + .partial() .refine((data) => Object.keys(data).length > 0, { error: 'Необходимо передать хотя бы одно поле для обновления', abort: true, @@ -179,6 +182,18 @@ export const MoveIssueSchema = z .int('Позиция должна быть целым числом') .min(0, 'Позиция не может быть отрицательной') .describe('Новая позиция в колонке (0 — первая/верхняя)'), + prevIssuePosition: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .nullable() + .describe('Позиция предидущей задачи в колонке'), + nextIssuePosition: z + .number() + .int('Позиция должна быть целым числом') + .min(0, 'Позиция не может быть отрицательной') + .nullable() + .describe('Позиция следующей задачи в колонке'), }) .describe('Схема для перемещения задачи по доске или между областями'); diff --git a/src/issue/application/use-cases/base/assign.use-case.ts b/src/issue/application/use-cases/base/assign.use-case.ts index e06efd6..ab08264 100644 --- a/src/issue/application/use-cases/base/assign.use-case.ts +++ b/src/issue/application/use-cases/base/assign.use-case.ts @@ -50,7 +50,7 @@ export class AssignIssueUseCase { } } - const result = await this.issueRepo.update(id, { assigneeId: dto.assigneeId }, userId); + const result = await this.issueRepo.update(id, { assigneeId: dto.assigneeId }); return { success: result, diff --git a/src/issue/application/use-cases/base/move.use-case.ts b/src/issue/application/use-cases/base/move.use-case.ts index f5ee040..56e355f 100644 --- a/src/issue/application/use-cases/base/move.use-case.ts +++ b/src/issue/application/use-cases/base/move.use-case.ts @@ -1,9 +1,14 @@ import { GetAreaQuery, GetStateQuery } from '@core/area/application/use-cases'; +import { IssueJobs, IssueQueue } from '@core/issue/domain/enums'; import { IssueErrorCodes, IssueErrorMessages } from '@core/issue/domain/errors'; +import { ReorderIssuesEvent } from '@core/issue/domain/events'; import { IIssueRepository } from '@core/issue/domain/repositories'; +import { REORDER_TRIGGER } from '@core/issue/infrastructure/constants'; import { ProjectAccessPolicy } from '@core/project/domain/policy'; +import { InjectQueue } from '@nestjs/bullmq'; import { HttpStatus, Inject, Injectable } from '@nestjs/common'; import { BaseException } from '@shared/error'; +import { Queue } from 'bullmq'; import { MoveIssueDto } from '../../dtos'; @@ -12,6 +17,8 @@ export class MoveIssueUseCase { constructor( @Inject('IIssueRepository') private readonly issueRepo: IIssueRepository, + @InjectQueue(IssueQueue.ISSUE) + private readonly issueQueue: Queue, private readonly getArea: GetAreaQuery, private readonly getState: GetStateQuery, private readonly projectPolicy: ProjectAccessPolicy, @@ -41,11 +48,13 @@ export class MoveIssueUseCase { stateId: dto.targetStateId ?? issue.stateId, }; - const result = await this.issueRepo.update(id, data, userId); + await this.issueRepo.update(id, data); + + await this.checkReorder(dto, issue.stateId); return { - success: result, - message: result ? 'Задача успешно перемещена' : 'Не удалось переместить задачу', + success: true, + message: 'Задача успешно перемещена', }; } catch (e) { if (e instanceof BaseException) { @@ -70,4 +79,25 @@ export class MoveIssueUseCase { await this.getState.execute(key, dto.targetStateId, userId); } } + + private async checkReorder(dto: MoveIssueDto, currentStateId: string | null) { + const prev = dto.prevIssuePosition; + const next = dto.nextIssuePosition; + + let distance = Infinity; + + if (prev !== null && next !== null) { + distance = Math.abs(next - prev); + } else if (prev !== null) { + distance = Math.abs(dto.position - prev); + } else if (next !== null) { + distance = Math.abs(next - dto.position); + } + + if (distance < REORDER_TRIGGER) { + const stateId = dto.targetStateId ?? currentStateId; + const event = new ReorderIssuesEvent(stateId!); + await this.issueQueue.add(IssueJobs.REORDER_ISSUES, event); + } + } } diff --git a/src/issue/application/use-cases/base/reorder.use-case.ts b/src/issue/application/use-cases/base/reorder.use-case.ts new file mode 100644 index 0000000..6f3404c --- /dev/null +++ b/src/issue/application/use-cases/base/reorder.use-case.ts @@ -0,0 +1,14 @@ +import { IIssueRepository } from '@core/issue/domain/repositories'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class ReorderIssuesUseCase { + constructor( + @Inject('IIssueRepository') + private readonly issueRepo: IIssueRepository, + ) {} + + execute(stateId: string) { + return this.issueRepo.reorder(stateId); + } +} diff --git a/src/issue/application/use-cases/base/update.use-case.ts b/src/issue/application/use-cases/base/update.use-case.ts index 2625f59..b504a64 100644 --- a/src/issue/application/use-cases/base/update.use-case.ts +++ b/src/issue/application/use-cases/base/update.use-case.ts @@ -40,7 +40,7 @@ export class UpdateIssueUseCase { await this.validateContext(id, dto, project.id, key, userId); - const result = await this.issueRepo.update(id, dto, userId); + const result = await this.issueRepo.update(id, dto); return { success: result, diff --git a/src/issue/application/use-cases/index.ts b/src/issue/application/use-cases/index.ts index 2e82199..4489f23 100644 --- a/src/issue/application/use-cases/index.ts +++ b/src/issue/application/use-cases/index.ts @@ -4,6 +4,7 @@ import { DeleteIssueUseCase } from './base/delete.use-case'; import { FindAllIssueQuery } from './base/find-all.query'; import { FindOneIssueQuery } from './base/find-one.query'; import { MoveIssueUseCase } from './base/move.use-case'; +import { ReorderIssuesUseCase } from './base/reorder.use-case'; import { RestoreIssueUseCase } from './base/restore.use-case'; import { UpdateIssueUseCase } from './base/update.use-case'; @@ -15,6 +16,7 @@ export * from './base/find-one.query'; export * from './base/move.use-case'; export * from './base/restore.use-case'; export * from './base/update.use-case'; +export * from './base/reorder.use-case'; export const USE_CASES = [ CreateIssueUseCase, @@ -25,4 +27,5 @@ export const USE_CASES = [ RestoreIssueUseCase, FindOneIssueQuery, FindAllIssueQuery, + ReorderIssuesUseCase, ]; diff --git a/src/issue/domain/enums/index.ts b/src/issue/domain/enums/index.ts new file mode 100644 index 0000000..ec5025b --- /dev/null +++ b/src/issue/domain/enums/index.ts @@ -0,0 +1 @@ +export * from './issue-jobs.enum'; diff --git a/src/issue/domain/enums/issue-jobs.enum.ts b/src/issue/domain/enums/issue-jobs.enum.ts new file mode 100644 index 0000000..1a5f0e5 --- /dev/null +++ b/src/issue/domain/enums/issue-jobs.enum.ts @@ -0,0 +1,7 @@ +export const enum IssueQueue { + ISSUE = 'ISSUE_QUEUE', +} + +export const enum IssueJobs { + REORDER_ISSUES = 'REORDER_ISSUES', +} diff --git a/src/issue/domain/events/index.ts b/src/issue/domain/events/index.ts new file mode 100644 index 0000000..282508f --- /dev/null +++ b/src/issue/domain/events/index.ts @@ -0,0 +1 @@ +export * from './issue-reorder.event'; diff --git a/src/issue/domain/events/issue-reorder.event.ts b/src/issue/domain/events/issue-reorder.event.ts new file mode 100644 index 0000000..d3ca9df --- /dev/null +++ b/src/issue/domain/events/issue-reorder.event.ts @@ -0,0 +1,3 @@ +export class ReorderIssuesEvent { + constructor(public readonly stateId: string) {} +} diff --git a/src/issue/domain/repositories/issue.repository.interface.ts b/src/issue/domain/repositories/issue.repository.interface.ts index 1084c0f..5ecca9a 100644 --- a/src/issue/domain/repositories/issue.repository.interface.ts +++ b/src/issue/domain/repositories/issue.repository.interface.ts @@ -4,9 +4,10 @@ import type { Issue, NewIssue } from '../entities'; export interface IIssueRepository { create(data: NewIssue, userId: string): Promise<{ id: string }>; - update(id: string, data: Partial, userId: string): Promise; + update(id: string, data: Partial): Promise; delete(id: string, userId: string): Promise; findOne(id: string, userId: string): Promise; find(query: IssueQueryDto): Promise; restore(id: string, userId: string): Promise; + reorder(stateId: string): Promise; } diff --git a/src/issue/infrastructure/constants/index.ts b/src/issue/infrastructure/constants/index.ts new file mode 100644 index 0000000..129997f --- /dev/null +++ b/src/issue/infrastructure/constants/index.ts @@ -0,0 +1 @@ +export const REORDER_TRIGGER = 0.00001; diff --git a/src/issue/infrastructure/persistence/repositories/issue.repository.ts b/src/issue/infrastructure/persistence/repositories/issue.repository.ts index babd647..3eed157 100644 --- a/src/issue/infrastructure/persistence/repositories/issue.repository.ts +++ b/src/issue/infrastructure/persistence/repositories/issue.repository.ts @@ -69,11 +69,7 @@ export class IssueRepository implements IIssueRepository { return (result.count ?? 0) > 0; } - public async update( - id: string, - data: Partial, - _userId: string, - ) { + public async update(id: string, data: Partial) { const result = await this.db .update(schema.issues) .set(data) @@ -87,6 +83,32 @@ export class IssueRepository implements IIssueRepository { return result.length > 0; } + public async reorder(stateId: string) { + const currentIssues = await this.db + .select({ id: schema.issues.id }) + .from(schema.issues) + .where(eq(schema.issues.stateId, stateId)) + .orderBy(schema.issues.position); + + if (currentIssues.length === 0) { + return; + } + + const STEP = 100.0; + const sqlChunks: string[] = []; + + currentIssues.forEach((issue, index) => { + const newPos = (index + 1) * STEP; + sqlChunks.push(`WHEN id = '${issue.id}' THEN ${newPos}::double precision`); + }); + + await this.db.execute(sql` + UPDATE ${schema.issues} + SET position = CASE ${sql.raw(sqlChunks.join(' '))} END + WHERE state_id = ${stateId} + `); + } + private get baseIssueQuery() { return this.db .select(this.issueSelection) diff --git a/src/issue/infrastructure/workers/reorder-issues.processor.ts b/src/issue/infrastructure/workers/reorder-issues.processor.ts new file mode 100644 index 0000000..1cae378 --- /dev/null +++ b/src/issue/infrastructure/workers/reorder-issues.processor.ts @@ -0,0 +1,45 @@ +import { ReorderIssuesUseCase } from '@core/issue/application/use-cases'; +import { IssueJobs, IssueQueue } from '@core/issue/domain/enums'; +import { ReorderIssuesEvent } from '@core/issue/domain/events'; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { Injectable } from '@nestjs/common'; +import { Job } from 'bullmq'; + +@Injectable() +@Processor(IssueQueue.ISSUE) +export class ReorderStateProcessor extends WorkerHost { + constructor(private readonly reorderIssuesUC: ReorderIssuesUseCase) { + super(); + } + + async process(job: Job): Promise { + await job.log(`[START] Job ID: ${job.id} | Type: ${job.name}`); + + try { + switch (job.name) { + case IssueJobs.REORDER_ISSUES: + await this.handleReorderIssues(job); + break; + + default: + await job.log(`[WRN] No handler for job: ${job.name}`); + await job.updateProgress(100); + } + + await job.log(`[DONE] Job ${job.id} processed`); + } catch (error) { + await job.log(String(error)); + throw error; + } + } + + private readonly handleReorderIssues = async (job: Job) => { + await job.log(`[INFO] Reordering issues in states with ID: ${job.data.stateId}`); + await job.updateProgress(20); + + await this.reorderIssuesUC.execute(job.data.stateId); + + await job.log(`[INFO] Finished reordering issues in state with ID: ${job.data.stateId}`); + await job.updateProgress(100); + }; +} diff --git a/src/issue/issue.module.ts b/src/issue/issue.module.ts index b90c9e6..9a488a4 100644 --- a/src/issue/issue.module.ts +++ b/src/issue/issue.module.ts @@ -1,5 +1,7 @@ import { AreaModule } from '@core/area'; +import { IssueQueue } from '@core/issue/domain/enums'; import { ProjectModule } from '@core/project'; +import { BullModule } from '@nestjs/bullmq'; import { Module } from '@nestjs/common'; import { CONTROLLERS } from './application/controllers'; @@ -8,7 +10,7 @@ import { USE_CASES } from './application/use-cases'; import { REPOSITORIES } from './infrastructure/persistence/repositories'; @Module({ - imports: [AreaModule, ProjectModule], + imports: [AreaModule, ProjectModule, BullModule.registerQueue({ name: IssueQueue.ISSUE })], controllers: CONTROLLERS, providers: [...REPOSITORIES, ...USE_CASES, IssueFacade], }) diff --git a/src/project/application/use-cases/project/find-one.query.ts b/src/project/application/use-cases/project/find-one.query.ts index 25c9cc8..8385db6 100644 --- a/src/project/application/use-cases/project/find-one.query.ts +++ b/src/project/application/use-cases/project/find-one.query.ts @@ -55,7 +55,7 @@ export class FindProjectQuery { throw new BaseException( { code: 'AUTH_REQUIRED', - message: 'Требуется авторизация для доступа к приватному проекту', + message: 'Требуется авторизация для доступа к проекту', }, HttpStatus.UNAUTHORIZED, ); diff --git a/src/user/infrastructure/persistence/repositories/user.repository.ts b/src/user/infrastructure/persistence/repositories/user.repository.ts index ef9b136..f2e4f80 100644 --- a/src/user/infrastructure/persistence/repositories/user.repository.ts +++ b/src/user/infrastructure/persistence/repositories/user.repository.ts @@ -3,7 +3,7 @@ import { DATABASE_SERVICE, DatabaseService, paginateCursor } from '@libs/databas import { Inject, Injectable } from '@nestjs/common'; import { createId } from '@paralleldrive/cuid2'; import { CursorQuery } from '@shared/schemas'; -import { eq, inArray } from 'drizzle-orm'; +import { eq, inArray, sql } from 'drizzle-orm'; import * as sc from '../models'; @@ -186,7 +186,9 @@ export class UserRepository implements IUserRepository { public updateNotifications = async (id: string, settings: UserNotifications['settings']) => { const result = await this.db .update(sc.userNotifications) - .set({ settings }) + .set({ + settings: sql`${sc.userNotifications.settings} || ${JSON.stringify(settings)}::jsonb`, + }) .where(eq(sc.userNotifications.userId, id)); return (result?.count ?? 0) > 0; };