Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/area/application/area.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common';

import {
CreateStateDto,
MoveStateDto,
UpdateStateDto,
ReordersStatesDto,
CreateAreaDto,
UpdateAreaDto,
QueryParamsDto,
Expand All @@ -20,7 +20,7 @@ import {
DeleteStateUseCase,
GetStateQuery,
GetStatesQuery,
ReorderStateUseCase,
MoveStateUseCase,
RestoreStateUseCase,
UpdateStateUseCase,
} from './use-cases/states';
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/area/application/controllers/area/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand Down
25 changes: 13 additions & 12 deletions src/area/application/controllers/state/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
}
31 changes: 18 additions & 13 deletions src/area/application/controllers/state/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@
import {
CreateStateDto,
UpdateStateDto,
ReordersStatesDto,
CreateStateResponse,
StateResponse,
StatesResponse,
MoveStateDto,
} from '../../dtos';

export const FindAllStatesSwagger = () =>
Expand All @@ -35,8 +35,8 @@
ApiParam({
name: 'slug',
type: 'string',
description: 'Slug проекта',

Check warning on line 38 in src/area/application/controllers/state/swagger.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Define a constant instead of duplicating this literal 7 times

Check warning on line 38 in src/area/application/controllers/state/swagger.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Define a constant instead of duplicating this literal 7 times
example: 'super-project',

Check warning on line 39 in src/area/application/controllers/state/swagger.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Define a constant instead of duplicating this literal 7 times

Check warning on line 39 in src/area/application/controllers/state/swagger.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

Define a constant instead of duplicating this literal 7 times
}),
ApiQuery({
name: 'hidden',
Expand Down Expand Up @@ -205,16 +205,14 @@
SetMetadata(ZOD_RESPONSE_TOKEN, ActionResponse),
);

export const ReorderStatesSwagger = () =>
export const MoveStateSwagger = () =>
applyDecorators(
ApiOperation({
deprecated: true,
summary: 'Изменить порядок колонок на доске',
summary: 'Переместить колонку на доске',
description: [
'Позволяет переставить колонки на канбан-доске так, как вам удобно.',
'Вы просто передаёте массив ID колонок в нужном порядке —',
'сервер сохранит эту последовательность.',
'Например, вы хотите, чтобы колонка «Готово» была не последней, а перед «Архивом».',
'Изменяет порядок колонки (статуса) на доске проекта.',
'Позволяет переставлять колонки местами, задавая новую позицию.',
'Кастомные колонки можно перемещать свободно, системные — нельзя.',
].join('\n'),
}),
ApiParam({
Expand All @@ -223,19 +221,26 @@
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),
);
Expand Down
45 changes: 29 additions & 16 deletions src/area/application/dtos/state.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('Скрытые записи'),
Expand All @@ -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) {}
7 changes: 5 additions & 2 deletions src/area/application/use-cases/states/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand All @@ -22,5 +24,6 @@ export const StatesUseCases = [
UpdateStateUseCase,
GetStateQuery,
GetStatesQuery,
ReorderStateUseCase,
ReorderStatesUseCase,
MoveStateUseCase,
];
113 changes: 113 additions & 0 deletions src/area/application/use-cases/states/move.use-case.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading