/* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable @typescript-eslint/naming-convention */ import { ERRORS as SHARED_ERRORS } from '../constants/errors'; import * as GQL from '../types'; import { BaseService } from './base'; import { CustomersService } from './customers'; import { ServicesService } from './services'; import { SlotsService } from './slots'; import { SubscriptionsService } from './subscriptions'; import { type VariablesOf } from '@graphql-typed-document-node/core'; 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_SELF_ORDER: 'Нельзя записать к самому себе', NOT_FOUND_CLIENT: 'Клиент не найден', NOT_FOUND_MASTER: 'Мастер не найден', NOT_FOUND_ORDER: 'Заказ не найден', NOT_FOUND_ORDER_SLOT: 'Слот заказа не найден', ORDER_LIMIT_EXCEEDED_CLIENT: 'Достигнут лимит заказов у этого мастера на месяц. Попробуйте записаться позже или к другому мастеру.', ORDER_LIMIT_EXCEEDED_MASTER: 'Достигнут лимит заказов на месяц. Оформите Pro доступ для продолжения работы.', OVERLAPPING_TIME: 'Время пересекается с другими заказами', SLOT_CLOSED: 'Слот закрыт', }; const DEFAULT_ORDERS_SORT = ['slot.datetime_start:desc', 'datetime_start:desc']; export class OrdersService extends BaseService { async createOrder(variables: VariablesOf) { await this.checkIsBanned(); 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.client) throw new Error(ERRORS.MISSING_CLIENT); // Получаем информацию о мастере слота для проверки лимита const slotService = new SlotsService(this._user); const { slot } = await slotService.getSlot({ documentId: variables.input.slot }); if (!slot?.master?.telegramId) { throw new Error(ERRORS.INVALID_MASTER); } // Проверка лимита заказов мастера слота const subscriptionsService = new SubscriptionsService(this._user); const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings(); const proEnabled = subscriptionSetting?.proEnabled; if (proEnabled) { const { remainingOrdersCount, subscription } = await subscriptionsService.getSubscription( slot.master, ); const isMasterCreating = slot.master.documentId === customer?.documentId; // Если у мастера слота нет активной подписки и не осталось доступных заказов if (!subscription?.active && remainingOrdersCount <= 0) { throw new Error( isMasterCreating ? ERRORS.ORDER_LIMIT_EXCEEDED_MASTER : ERRORS.ORDER_LIMIT_EXCEEDED_CLIENT, ); } } const servicesService = new ServicesService(this._user); // Получаем все услуги по их идентификаторам const services: Array<{ duration: string }> = []; for (const serviceId of variables.input.services) { if (!serviceId) throw new Error(ERRORS.MISSING_SERVICE_ID); const { service } = await servicesService.getService({ documentId: serviceId, }); if (!service?.duration) throw new Error(ERRORS.INVALID_SERVICE_DURATION); services.push(service); } // Суммируем длительности всех услуг const totalServiceDuration = services.reduce( (sum, service) => sum + getMinutes(service.duration), 0, ); const datetimeEnd = dayjs(variables.input.datetime_start) .add(totalServiceDuration, 'minute') .toISOString(); await this.checkBeforeCreate({ ...variables, input: { ...variables.input, datetime_end: datetimeEnd, }, }); const isSlotMaster = slot.master.documentId === customer.documentId; const { mutate } = await this.getGraphQLClient(); const mutationResult = await mutate({ mutation: GQL.CreateOrderDocument, variables: { ...variables, input: { ...variables.input, datetime_end: datetimeEnd, state: isSlotMaster ? 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) { await this.checkIsBanned(); const { query } = await this.getGraphQLClient(); const result = await query({ query: GQL.GetOrderDocument, variables, }); return result.data; } async getOrders(variables: VariablesOf) { await this.checkIsBanned(); const { query } = await this.getGraphQLClient(); const result = await query({ query: GQL.GetOrdersDocument, variables: { sort: DEFAULT_ORDERS_SORT, ...variables, }, }); return result.data; } async updateOrder(variables: VariablesOf) { await this.checkIsBanned(); await this.checkUpdatePermission(variables); await this.checkBeforeUpdate(variables); const { customer } = await this._getUser(); const { query } = await this.getGraphQLClient(); 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(SHARED_ERRORS.NO_PERMISSION); if (isOrderClient && Object.keys(variables.data).length > 1) throw new Error(SHARED_ERRORS.NO_PERMISSION); if ( isOrderClient && variables.data.state && variables.data.state !== GQL.Enum_Order_State.Cancelling ) { throw new Error(SHARED_ERRORS.NO_PERMISSION); } const { mutate } = await this.getGraphQLClient(); 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) { 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 (clientId === slot?.master?.documentId) throw new Error(ERRORS.NO_SELF_ORDER); // Проверка, что заказ укладывается в рамки слота 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); } const slotMasterId = slot.master?.documentId; if (!slotMasterId) { throw new Error(ERRORS.NOT_FOUND_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) { if (variables.data.client || variables.data.services?.length || variables.data.slot) { throw new Error(SHARED_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) { 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(SHARED_ERRORS.NO_PERMISSION); } if (isOrderClient && variables?.data && Object.keys(variables.data).length > 1) { throw new Error(SHARED_ERRORS.NO_PERMISSION); } if ( isOrderClient && variables?.data?.state && variables.data.state !== GQL.Enum_Order_State.Cancelling ) { throw new Error(SHARED_ERRORS.NO_PERMISSION); } } private async getLastOrderNumber(variables: VariablesOf) { 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; } }