/* eslint-disable @typescript-eslint/naming-convention */ import { getClientWithToken } from '../apollo/client'; import { ERRORS as SHARED_ERRORS } from '../constants/errors'; import * as GQL from '../types'; import { BaseService } from './base'; import { ServicesService } from './services'; import { type VariablesOf } from '@graphql-typed-document-node/core'; import { getMinutes, isBeforeNow } from '@repo/utils/datetime-format'; import dayjs from 'dayjs'; export const ERRORS = { INACTIVE_MASTER: 'Пользователь не является активным или мастером', INVALID_TIME: 'Некорректное время', MISSING_DATETIME_END: 'Не указана дата окончания', MISSING_DATETIME_START: 'Не указана дата начала', MISSING_SERVICES_IDS: 'Не указаны услуги', NO_PAST_SLOT: 'Нельзя создать слот в прошлом', NOT_FOUND_MASTER: 'Мастер не найден', NOT_FOUND_SERVICE: 'Сервис не найден', NOT_FOUND_SLOT: 'Слот не найден', OVERLAPPING_TIME: 'Время пересекается с другими слотами', SLOT_HAS_ORDERS: 'Слот имеет активные заказы', }; export class SlotsService extends BaseService { async createSlot(variables: VariablesOf) { await this.checkIsBanned(); await this.checkBeforeCreate(variables); const { customer } = await this._getUser(); const { mutate } = await getClientWithToken(); const mutationResult = await mutate({ mutation: GQL.CreateSlotDocument, variables: { ...variables, input: { ...variables.input, master: customer?.documentId, }, }, }); const error = mutationResult.errors?.at(0); if (error) throw new Error(error.message); return mutationResult.data; } async deleteSlot(variables: VariablesOf) { await this.checkIsBanned(); await this.checkPermission(variables); const { slot } = await this.getSlot({ documentId: variables.documentId }); if (slot?.orders?.length) { throw new Error(ERRORS.SLOT_HAS_ORDERS); } const { mutate } = await getClientWithToken(); const mutationResult = await mutate({ mutation: GQL.DeleteSlotDocument, variables, }); const error = mutationResult.errors?.at(0); if (error) throw new Error(error.message); return mutationResult.data; } async getAvailableTimeSlots( variables: VariablesOf, context: { services: string[] }, ) { await this.checkIsBanned(); if (!variables.filters?.datetime_start) throw new Error(ERRORS.MISSING_DATETIME_START); if (!context?.services?.length) throw new Error(ERRORS.MISSING_SERVICES_IDS); const servicesService = new ServicesService(this._user); // Получаем все услуги по массиву id` const services: Array<{ duration: string }> = []; for (const serviceId of context.services) { const { service } = await servicesService.getService({ documentId: serviceId, }); if (!service) throw new Error(ERRORS.NOT_FOUND_SERVICE); services.push(service); } // Суммируем длительности всех услуг const totalServiceDuration = services.reduce( (sum, service) => sum + getMinutes(service.duration), 0, ); const { query } = await getClientWithToken(); const getSlotsResult = await query({ query: GQL.GetSlotsOrdersDocument, variables: { filters: { ...variables.filters, state: { eq: GQL.Enum_Slot_State.Open }, }, }, }); if (getSlotsResult.error) throw new Error(getSlotsResult.error.message); const slots = getSlotsResult.data.slots; const times: Array<{ slotId: string; time: string }> = []; const now = dayjs(); for (const slot of slots) { if (!slot?.datetime_start || !slot?.datetime_end) continue; let datetimeStart = dayjs(slot.datetime_start); const datetimeEnd = dayjs(slot.datetime_end).subtract(totalServiceDuration, 'minutes'); while (datetimeStart.valueOf() <= datetimeEnd.valueOf()) { const slotStartTime = datetimeStart; const potentialDatetimeEnd = datetimeStart.add(totalServiceDuration, 'minutes'); const hasConflict = slot.orders.some( (order) => order?.state && ![GQL.Enum_Order_State.Cancelled].includes(order.state) && slotStartTime.isBefore(dayjs(order.datetime_end)) && potentialDatetimeEnd.isAfter(dayjs(order.datetime_start)), ); if (!hasConflict && datetimeStart.isAfter(now, 'minute')) { times.push({ slotId: slot.documentId, time: datetimeStart.toISOString() }); } datetimeStart = datetimeStart.add(15, 'minutes'); } } return { slots, times }; } async getSlot(variables: VariablesOf) { await this.checkIsBanned(); const { query } = await getClientWithToken(); const result = await query({ query: GQL.GetSlotDocument, variables, }); return result.data; } async getSlots(variables: VariablesOf) { await this.checkIsBanned(); const { query } = await getClientWithToken(); const result = await query({ query: GQL.GetSlotsDocument, variables, }); return result.data; } async updateSlot(variables: VariablesOf) { await this.checkIsBanned(); await this.checkPermission(variables); await this.checkBeforeUpdateDatetime(variables); const { mutate } = await getClientWithToken(); const mutationResult = await mutate({ mutation: GQL.UpdateSlotDocument, variables, }); const error = mutationResult.errors?.at(0); if (error) throw new Error(error.message); return mutationResult.data; } private async checkBeforeCreate(variables: VariablesOf) { const { datetime_end, datetime_start } = variables.input; if (!datetime_start) throw new Error(ERRORS.MISSING_DATETIME_START); if (!datetime_end) throw new Error(ERRORS.MISSING_DATETIME_END); // Проверка, что мастер существует и активен const { customer: masterEntity } = await this._getUser(); if (!masterEntity) throw new Error(ERRORS.NOT_FOUND_MASTER); if (!masterEntity?.active) { throw new Error(ERRORS.INACTIVE_MASTER); } // Проверка, что слот не создаётся в прошлом if (datetime_start && isBeforeNow(datetime_start)) { throw new Error(ERRORS.NO_PAST_SLOT); } // Проверка валидности времени if (!datetime_start || !datetime_end) { throw new Error(ERRORS.INVALID_TIME); } if (new Date(datetime_end) <= new Date(datetime_start)) { throw new Error(ERRORS.INVALID_TIME); } const overlappingEntities = await this.getSlots({ filters: { datetime_end: { gt: datetime_start }, datetime_start: { lt: datetime_end }, master: { telegramId: { eq: this._user.telegramId } }, }, }); if (overlappingEntities?.slots?.length) { throw new Error(ERRORS.OVERLAPPING_TIME); } } private async checkBeforeUpdateDatetime(variables: VariablesOf) { const { slot } = await this.getSlot({ documentId: variables.documentId }); if (!slot) throw new Error(ERRORS.NOT_FOUND_SLOT); const { datetime_end, datetime_start } = variables.data; const isTimeChanging = datetime_start || datetime_end; if (!isTimeChanging) return; if (!datetime_start) throw new Error(ERRORS.MISSING_DATETIME_START); if (!datetime_end) throw new Error(ERRORS.MISSING_DATETIME_END); // Проверка валидности времени if (new Date(datetime_end) <= new Date(datetime_start)) { throw new Error(ERRORS.INVALID_TIME); } const orders = slot?.orders; if ( orders?.length && orders?.some( (order) => order?.state && [ GQL.Enum_Order_State.Approved, GQL.Enum_Order_State.Cancelled, GQL.Enum_Order_State.Completed, GQL.Enum_Order_State.Scheduled, ].includes(order.state), ) ) { throw new Error(ERRORS.SLOT_HAS_ORDERS); } const { documentId } = slot; const overlappingEntities = await this.getSlots({ filters: { datetime_end: { gt: datetime_start }, datetime_start: { lt: datetime_end }, documentId: { not: { eq: documentId } }, master: { telegramId: { eq: this._user.telegramId } }, }, }); if (overlappingEntities?.slots?.length) { throw new Error(ERRORS.OVERLAPPING_TIME); } } private async checkPermission( variables: Pick, 'documentId'>, ) { const { customer } = await this._getUser(); const { slot } = await this.getSlot({ documentId: variables.documentId }); if (!slot) throw new Error(ERRORS.NOT_FOUND_SLOT); if (slot?.master?.documentId !== customer?.documentId) throw new Error(SHARED_ERRORS.NO_PERMISSION); } }