Vlad Chikalkin 7d94521c8b
Issues/76 (#77)
* fix(jwt): update import path for isTokenExpired function to remove file extension

* packages/graphql: add slot tests

* fix(slots): update error handling for customer and slot retrieval, enhance time validation in slot updates

* fix(slots): update error messages for missing datetime fields and improve validation logic in slot updates

* fix(slots): update error messages and validation logic for slot creation and updates, including handling of datetime fields and master status

* refactor(slots): rename checkUpdateIsTimeChanging to checkUpdateDatetime for clarity in slot update validation

* test(slots): add comprehensive tests for getAvailableTimeSlots method, including edge cases and error handling

* fix(api): standardize error messages for customer and slot retrieval, and improve validation logic in slots service

* refactor(slots): rename validation methods for clarity and consistency in slot service

* OrdersService: add checkBeforeCreate

* add orders.test.js

* test(orders): add validation test for missing datetime_end in order creation

* feat(orders): implement updateOrder functionality with comprehensive validation tests

- Added updateOrder method in OrdersService with checks for permissions, order state, and datetime validation.
- Implemented tests for various scenarios including successful updates, permission errors, and validation failures.
- Enhanced error handling for overlapping time and invalid state changes.
- Updated GraphQL operations to support sorting in GetOrders query.

* fix(orders): update datetime validation logic and test cases for order creation and completion

- Modified order creation tests to set datetime_start to one hour in the past for past orders.
- Updated the OrdersService to use isNowOrAfter for validating order completion against the start time.
- Enhanced datetime utility function to accept a unit parameter for more flexible comparisons.

* fix(calendar): initialize selected date in ScheduleCalendar component if not set

- Added useEffect to set the selected date to the current date if it is not already defined.
- Imported useEffect alongside useState for managing component lifecycle.

* fix(order-form): initialize selected date in DateSelect component if not set

- Added useEffect to set the selected date to the current date if it is not already defined.
- Renamed setDate to setSelectedDate for clarity in state management.

* refactor(orders): streamline order creation logic and enhance test setup

- Removed redundant variable assignments in the createOrder method for cleaner code.
- Updated test setup in orders.test.js to use global mocks for user and service retrieval, improving test clarity and maintainability.
- Added checks for required fields in order creation to ensure data integrity.
2025-08-11 16:25:14 +03:00

423 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable @typescript-eslint/naming-convention */
import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types';
import { BaseService } from './base';
import { CustomersService } from './customers';
import { ServicesService } from './services';
import { SlotsService } from './slots';
import { type VariablesOf } from '@graphql-typed-document-node/core';
import { isCustomerMaster } from '@repo/utils/customer';
import { getMinutes, isBeforeNow, isNowOrAfter } from '@repo/utils/datetime-format';
import dayjs from 'dayjs';
export const ERRORS = {
CANNOT_COMPLETE_BEFORE_START: 'Нельзя завершить запись до её наступления',
INACTIVE_CLIENT: 'Клиент не активен',
INACTIVE_MASTER: 'Мастер не активен',
INVALID_MASTER: 'Некорректный мастер',
INVALID_SERVICE_DURATION: 'Неверная длительность услуги',
INVALID_TIME: 'Некорректное время',
MISSING_CLIENT: 'Не указан клиент',
MISSING_END_TIME: 'Не указано время окончания',
MISSING_ORDER: 'Заказ не найден',
MISSING_SERVICE_ID: 'Не указан идентификатор услуги',
MISSING_SERVICES: 'Отсутствуют услуги',
MISSING_SLOT: 'Не указан слот',
MISSING_START_TIME: 'Не указано время начала',
MISSING_TIME: 'Не указано время',
NO_CHANGE_STATE_COMPLETED: 'Нельзя изменить статус завершенного заказа',
NO_MASTER_SELF_BOOK: 'Нельзя записать к самому себе',
NO_ORDER_IN_PAST: 'Нельзя создать запись на время в прошлом',
NO_ORDER_OUT_OF_SLOT: 'Время заказа выходит за пределы слота',
NO_PERMISSION: 'Нет доступа',
NOT_FOUND_CLIENT: 'Клиент не найден',
NOT_FOUND_MASTER: 'Мастер не найден',
NOT_FOUND_ORDER: 'Заказ не найден',
NOT_FOUND_ORDER_SLOT: 'Слот заказа не найден',
OVERLAPPING_TIME: 'Время пересекается с другими заказами',
SLOT_CLOSED: 'Слот закрыт',
};
const DEFAULT_ORDERS_SORT = ['slot.datetime_start:desc', 'datetime_start:asc'];
export class OrdersService extends BaseService {
async createOrder(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
const { customer } = await this._getUser();
// Проверки на существование обязательных полей для предотвращения ошибок типов
if (!variables.input.slot) throw new Error(ERRORS.MISSING_SLOT);
if (!variables.input.services?.length) throw new Error(ERRORS.MISSING_SERVICES);
if (!variables.input.services[0]) throw new Error(ERRORS.MISSING_SERVICE_ID);
if (!variables.input.client) throw new Error(ERRORS.MISSING_CLIENT);
const servicesService = new ServicesService(this._user);
const { service } = await servicesService.getService({
documentId: variables.input.services[0],
});
if (!service?.duration) throw new Error(ERRORS.INVALID_SERVICE_DURATION);
const datetimeEnd = dayjs(variables.input.datetime_start)
.add(getMinutes(service.duration), 'minute')
.toISOString();
await this.checkBeforeCreate({
...variables,
input: {
...variables.input,
datetime_end: datetimeEnd,
},
});
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateOrderDocument,
variables: {
...variables,
input: {
...variables.input,
datetime_end: datetimeEnd,
state: isCustomerMaster(customer)
? GQL.Enum_Order_State.Approved
: GQL.Enum_Order_State.Created,
},
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async getOrder(variables: VariablesOf<typeof GQL.GetOrderDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetOrderDocument,
variables,
});
return result.data;
}
async getOrders(variables: VariablesOf<typeof GQL.GetOrdersDocument>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetOrdersDocument,
variables: {
sort: DEFAULT_ORDERS_SORT,
...variables,
},
});
return result.data;
}
async updateOrder(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
await this.checkUpdatePermission(variables);
await this.checkBeforeUpdate(variables);
const { customer } = await this._getUser();
const { query } = await getClientWithToken();
const {
data: { order },
} = await query({
query: GQL.GetOrderDocument,
variables: { documentId: variables.documentId },
});
if (!order) throw new Error(ERRORS.MISSING_ORDER);
const isOrderClient = order.client?.documentId === customer.documentId;
const isOrderMaster = order.slot?.master?.documentId === customer.documentId;
if (!isOrderClient && !isOrderMaster) throw new Error(ERRORS.NO_PERMISSION);
if (isOrderClient && Object.keys(variables.data).length > 1)
throw new Error(ERRORS.NO_PERMISSION);
if (
isOrderClient &&
variables.data.state &&
variables.data.state !== GQL.Enum_Order_State.Cancelling
) {
throw new Error(ERRORS.NO_PERMISSION);
}
const { mutate } = await getClientWithToken();
const lastOrderNumber = await this.getLastOrderNumber(variables);
const mutationResult = await mutate({
mutation: GQL.UpdateOrderDocument,
variables: {
...variables,
data: {
...variables.data,
order_number: lastOrderNumber ? lastOrderNumber + 1 : undefined,
},
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
private async checkBeforeCreate(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
const {
client: clientId,
datetime_end,
datetime_start,
services,
slot: slotId,
} = variables.input;
// Проверка наличия обязательных полей
if (!slotId) throw new Error(ERRORS.MISSING_SLOT);
if (!clientId) throw new Error(ERRORS.MISSING_CLIENT);
if (!services?.length) throw new Error(ERRORS.MISSING_SERVICES);
// Проверка корректности времени заказа.
if (!datetime_start) {
throw new Error(ERRORS.MISSING_START_TIME);
}
if (!datetime_end) {
throw new Error(ERRORS.MISSING_END_TIME);
}
if (new Date(datetime_end) <= new Date(datetime_start)) {
throw new Error(ERRORS.INVALID_TIME);
}
// Проверка, что заказ не создается на время в прошлом
if (isBeforeNow(datetime_start, 'minute')) {
throw new Error(ERRORS.NO_ORDER_IN_PAST);
}
const slotService = new SlotsService(this._user);
// Получаем слот
const { slot } = await slotService.getSlot({ documentId: slotId });
if (!slot) throw new Error(ERRORS.MISSING_SLOT);
// Проверка, что заказ укладывается в рамки слота
if (
new Date(datetime_start) < new Date(slot.datetime_start) ||
new Date(datetime_end) > new Date(slot.datetime_end)
) {
throw new Error(ERRORS.NO_ORDER_OUT_OF_SLOT);
}
// 1. Слот не должен быть закрыт
if (slot.state === GQL.Enum_Slot_State.Closed) {
throw new Error(ERRORS.SLOT_CLOSED);
}
const customerService = new CustomersService(this._user);
// Получаем клиента
const { customer: clientEntity } = await customerService.getCustomer({ documentId: clientId });
if (!clientEntity) throw new Error(ERRORS.NOT_FOUND_CLIENT);
// Проверка активности клиента
if (!clientEntity?.active) {
throw new Error(ERRORS.INACTIVE_CLIENT);
}
// Получаем мастера слота
const slotMaster = slot.master;
if (!slotMaster) throw new Error(ERRORS.INVALID_MASTER);
if (!slotMaster.active || slotMaster.role !== GQL.Enum_Customer_Role.Master) {
throw new Error(ERRORS.INACTIVE_MASTER);
}
// 2. Проверка ролей и связей
const isClientMaster = clientEntity.role === GQL.Enum_Customer_Role.Master;
const slotMasterId = slot.master?.documentId;
if (!slotMasterId) {
throw new Error(ERRORS.NOT_FOUND_MASTER);
}
if (isClientMaster) {
// Мастер может записывать только себя
if (slotMasterId !== clientId) {
throw new Error(ERRORS.INVALID_MASTER);
}
} else {
// Клиент не должен быть мастером слота
if (slotMasterId === clientId) {
throw new Error(ERRORS.NO_MASTER_SELF_BOOK);
}
const clientMasters = await customerService.getMasters({ documentId: clientId });
const isLinkedToSlotMaster = clientMasters?.masters.some(
(master) => master?.documentId === slotMasterId,
);
// Клиент должен быть привязан к мастеру слота
if (!isLinkedToSlotMaster) {
throw new Error(ERRORS.INVALID_MASTER);
}
}
// Проверка пересечений заказов по времени.
const { orders: overlappingOrders } = await this.getOrders({
filters: {
datetime_end: { gt: datetime_start },
datetime_start: { lt: datetime_end },
slot: {
documentId: { eq: slotId },
},
state: {
notIn: [GQL.Enum_Order_State.Cancelled],
},
},
});
if (overlappingOrders?.length) {
throw new Error(ERRORS.OVERLAPPING_TIME);
}
}
private async checkBeforeUpdate(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
if (variables.data.client || variables.data.services?.length || variables.data.slot) {
throw new Error(ERRORS.NO_PERMISSION);
}
const { order: existingOrder } = await this.getOrder({ documentId: variables.documentId });
if (!existingOrder) {
throw new Error(ERRORS.NOT_FOUND_ORDER);
}
if (!existingOrder.slot) {
throw new Error(ERRORS.NOT_FOUND_ORDER_SLOT);
}
if (existingOrder.state === GQL.Enum_Order_State.Completed && variables.data.state) {
throw new Error(ERRORS.NO_CHANGE_STATE_COMPLETED);
}
const { datetime_end, datetime_start } = variables.data;
if (!datetime_start || !datetime_end) {
return;
}
if (new Date(datetime_end) <= new Date(datetime_start)) {
throw new Error(ERRORS.INVALID_TIME);
}
const slotId = existingOrder.slot.documentId;
const { orders: overlappingEntities } = await this.getOrders({
filters: {
datetime_end: { gt: datetime_start },
datetime_start: { lt: datetime_end },
documentId: { ne: variables.documentId },
slot: {
documentId: { eq: slotId },
},
state: {
not: {
eq: GQL.Enum_Order_State.Cancelled,
},
},
},
});
if (overlappingEntities?.length) {
throw new Error(ERRORS.OVERLAPPING_TIME);
}
}
private async checkUpdatePermission(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
const { customer } = await this._getUser();
const { order } = await this.getOrder({ documentId: variables.documentId });
if (!order) throw new Error(ERRORS.MISSING_ORDER);
const isOrderClient = order.client?.documentId === customer.documentId;
const isOrderMaster = order.slot?.master?.documentId === customer.documentId;
if (!isOrderClient && !isOrderMaster) {
throw new Error(ERRORS.NO_PERMISSION);
}
if (isOrderClient && variables?.data && Object.keys(variables.data).length > 1) {
throw new Error(ERRORS.NO_PERMISSION);
}
if (
isOrderClient &&
variables?.data?.state &&
variables.data.state !== GQL.Enum_Order_State.Cancelling
) {
throw new Error(ERRORS.NO_PERMISSION);
}
}
private async getLastOrderNumber(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
const { datetime_end, datetime_start, state } = variables.data;
const { order: existingOrder } = await this.getOrder({ documentId: variables.documentId });
if (!existingOrder) {
throw new Error(ERRORS.NOT_FOUND_ORDER);
}
if (state !== GQL.Enum_Order_State.Completed || datetime_start || datetime_end) {
return undefined;
}
if (
state === GQL.Enum_Order_State.Completed &&
existingOrder?.datetime_start &&
isNowOrAfter(existingOrder?.datetime_start, 'minute')
) {
throw new Error(ERRORS.CANNOT_COMPLETE_BEFORE_START);
}
let lastOrderNumber: number | undefined;
if (state === GQL.Enum_Order_State.Completed) {
const clientId = existingOrder?.client?.documentId;
const {
orders: [lastOrder],
} = await this.getOrders({
filters: {
client: {
documentId: { eq: clientId },
},
state: {
eq: GQL.Enum_Order_State.Completed,
},
},
pagination: {
limit: 1,
},
sort: ['order_number:desc'],
});
lastOrderNumber = lastOrder?.order_number || undefined;
}
return lastOrderNumber;
}
}