From 429f5dcab2c870e0647315a48e13a3bca97e2e90 Mon Sep 17 00:00:00 2001 From: vchikalkin Date: Tue, 5 Aug 2025 14:57:13 +0300 Subject: [PATCH] OrdersService: add checkBeforeCreate --- apps/web/components/orders/order-buttons.tsx | 4 +- .../schedule/day-slots-list/index.tsx | 4 +- apps/web/components/schedule/slot-buttons.tsx | 4 +- .../api/contacts/use-customer-contacts.ts | 4 +- packages/graphql/api/customers.ts | 8 +- packages/graphql/api/orders.ts | 181 +++++++++++++++++- packages/graphql/api/slots.ts | 4 +- packages/utils/src/datetime-format.ts | 16 +- 8 files changed, 200 insertions(+), 25 deletions(-) diff --git a/apps/web/components/orders/order-buttons.tsx b/apps/web/components/orders/order-buttons.tsx index 8a019eb..9610aa9 100644 --- a/apps/web/components/orders/order-buttons.tsx +++ b/apps/web/components/orders/order-buttons.tsx @@ -7,7 +7,7 @@ import { useIsMaster } from '@/hooks/api/customers'; import { useOrderMutation, useOrderQuery } from '@/hooks/api/orders'; import { usePushWithData } from '@/hooks/url'; import { Enum_Order_State } from '@repo/graphql/types'; -import { isBeforeToday } from '@repo/utils/datetime-format'; +import { isBeforeNow } from '@repo/utils/datetime-format'; export function OrderButtons({ documentId }: Readonly) { const push = usePushWithData(); @@ -50,7 +50,7 @@ export function OrderButtons({ documentId }: Readonly) { push('/orders/add', order); } - const isOrderStale = order?.datetime_start && isBeforeToday(order?.datetime_start); + const isOrderStale = order?.datetime_start && isBeforeNow(order?.datetime_start); const canCancel = !isOrderStale && (isCreated || (isMaster && isCancelling) || isApproved); const canComplete = isMaster && isApproved; diff --git a/apps/web/components/schedule/day-slots-list/index.tsx b/apps/web/components/schedule/day-slots-list/index.tsx index e3df2eb..863ec1a 100644 --- a/apps/web/components/schedule/day-slots-list/index.tsx +++ b/apps/web/components/schedule/day-slots-list/index.tsx @@ -7,7 +7,7 @@ import { useCustomerQuery } from '@/hooks/api/customers'; import { useMasterSlotsQuery } from '@/hooks/api/slots'; import { useDateTimeStore } from '@/stores/datetime'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; -import { getDateUTCRange, isTodayOrAfter } from '@repo/utils/datetime-format'; +import { getDateUTCRange, isNowOrAfter } from '@repo/utils/datetime-format'; import { type PropsWithChildren } from 'react'; export function DaySlotsList() { @@ -26,7 +26,7 @@ export function DaySlotsList() { return ; } - const isSelectedDateTodayOrAfter = selectedDate && isTodayOrAfter(selectedDate); + const isSelectedDateTodayOrAfter = selectedDate && isNowOrAfter(selectedDate); if (!slots?.length) { return ( diff --git a/apps/web/components/schedule/slot-buttons.tsx b/apps/web/components/schedule/slot-buttons.tsx index 5a19a37..0c53881 100644 --- a/apps/web/components/schedule/slot-buttons.tsx +++ b/apps/web/components/schedule/slot-buttons.tsx @@ -5,7 +5,7 @@ import FloatingActionPanel from '../shared/action-panel'; import { type SlotComponentProps } from './types'; import { useSlotDelete, useSlotMutation, useSlotQuery } from '@/hooks/api/slots'; import { Enum_Slot_State } from '@repo/graphql/types'; -import { isBeforeToday } from '@repo/utils/datetime-format'; +import { isBeforeNow } from '@repo/utils/datetime-format'; import { useRouter } from 'next/navigation'; export function SlotButtons({ documentId }: Readonly) { @@ -42,7 +42,7 @@ export function SlotButtons({ documentId }: Readonly) { const hasOrders = Boolean(slot?.orders.length); - const isSlotStale = slot?.datetime_start && isBeforeToday(slot?.datetime_start); + const isSlotStale = slot?.datetime_start && isBeforeNow(slot?.datetime_start); const canDelete = !isSlotStale && !hasOrders; const canToggle = !isSlotStale; diff --git a/apps/web/hooks/api/contacts/use-customer-contacts.ts b/apps/web/hooks/api/contacts/use-customer-contacts.ts index 1202e9a..cd11a02 100644 --- a/apps/web/hooks/api/contacts/use-customer-contacts.ts +++ b/apps/web/hooks/api/contacts/use-customer-contacts.ts @@ -20,8 +20,8 @@ export function useCustomerContacts() { refetch: refetchMasters, } = useMastersQuery(); - const clients = clientsData?.customers?.at(0)?.clients || []; - const masters = mastersData?.customers?.at(0)?.masters || []; + const clients = clientsData?.clients || []; + const masters = mastersData?.masters || []; const isLoading = isLoadingClients || isLoadingMasters; diff --git a/packages/graphql/api/customers.ts b/packages/graphql/api/customers.ts index 91c45c7..6fc1dce 100644 --- a/packages/graphql/api/customers.ts +++ b/packages/graphql/api/customers.ts @@ -43,7 +43,9 @@ export class CustomersService extends BaseService { }, }); - return result.data; + const customer = result.data.customers.at(0); + + return customer; } async getCustomer(variables: VariablesOf) { @@ -69,7 +71,9 @@ export class CustomersService extends BaseService { }, }); - return result.data; + const customer = result.data.customers.at(0); + + return customer; } async updateCustomer( diff --git a/packages/graphql/api/orders.ts b/packages/graphql/api/orders.ts index 0ae89e8..687e451 100644 --- a/packages/graphql/api/orders.ts +++ b/packages/graphql/api/orders.ts @@ -1,25 +1,43 @@ +/* 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 } from '@repo/utils/datetime-format'; +import { getMinutes, isBeforeNow } from '@repo/utils/datetime-format'; import dayjs from 'dayjs'; const ERRORS = { - INVALID_SERVICE_DURATION: 'Invalid service duration', - MISSING_CLIENT: 'Missing client', - MISSING_ORDER: 'Order not found', - MISSING_SERVICE_ID: 'Missing service id', - MISSING_SERVICES: 'Missing services', - MISSING_SLOT: 'Missing slot id', - MISSING_START_TIME: 'Missing time start', - NO_PERMISSION: 'No permission', + INACTIVE_CLIENT: 'Клиент не активен', + INACTIVE_MASTER: 'Мастер не активен', + INVALID_MASTER: 'Некорректный мастер', + INVALID_SERVICE_DURATION: 'Неверная длительность услуги', + INVALID_TIME: 'Некорректное время', + MISSING_CLIENT: 'Не указан клиент', + MISSING_ORDER: 'Заказ не найден', + MISSING_SERVICE_ID: 'Не указан идентификатор услуги', + MISSING_SERVICES: 'Отсутствуют услуги', + MISSING_SLOT: 'Не указан слот', + MISSING_START_TIME: 'Не указано время начала', + MISSING_TIME: 'Не указано время', + NO_MASTER_SELF_BOOK: 'Нельзя записать к самому себе', + NO_PERMISSION: 'Нет доступа', + NOT_FOUND_CLIENT: 'Клиент не найден', + NOT_FOUND_MASTER: 'Мастер не найден', + ORDER_IN_PAST: 'Нельзя создать запись на время в прошлом', + ORDER_OUT_OF_SLOT: 'Время заказа выходит за пределы слота', + OVERLAPPING_TIME: 'Время пересекается с другими заказами', + SLOT_CLOSED: 'Слот закрыт', }; export class OrdersService extends BaseService { async createOrder(variables: VariablesOf) { + await this.checkBeforeCreate(variables); + const { customer } = await this._getUser(); // Проверки на существование обязательных полей для предотвращения ошибок типов @@ -86,6 +104,8 @@ export class OrdersService extends BaseService { } async updateOrder(variables: VariablesOf) { + await this.checkUpdatePermission(variables); + const { customer } = await this._getUser(); const { query } = await getClientWithToken(); @@ -127,4 +147,147 @@ export class OrdersService extends BaseService { 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 || !datetime_end) { + throw new Error(ERRORS.MISSING_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.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.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 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(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); + } + } } diff --git a/packages/graphql/api/slots.ts b/packages/graphql/api/slots.ts index ceb3b18..573d790 100644 --- a/packages/graphql/api/slots.ts +++ b/packages/graphql/api/slots.ts @@ -4,7 +4,7 @@ import * as GQL from '../types'; import { BaseService } from './base'; import { ServicesService } from './services'; import { type VariablesOf } from '@graphql-typed-document-node/core'; -import { getMinutes, isBeforeToday } from '@repo/utils/datetime-format'; +import { getMinutes, isBeforeNow } from '@repo/utils/datetime-format'; import dayjs from 'dayjs'; export const ERRORS = { @@ -190,7 +190,7 @@ export class SlotsService extends BaseService { } // Проверка, что слот не создаётся в прошлом - if (datetime_start && isBeforeToday(datetime_start)) { + if (datetime_start && isBeforeNow(datetime_start)) { throw new Error(ERRORS.NO_PAST_SLOT); } diff --git a/packages/utils/src/datetime-format.ts b/packages/utils/src/datetime-format.ts index 73cd711..0703a71 100644 --- a/packages/utils/src/datetime-format.ts +++ b/packages/utils/src/datetime-format.ts @@ -1,4 +1,5 @@ /* eslint-disable import/no-unassigned-import */ +import { type OpUnitType } from 'dayjs'; import dayjs, { type ConfigType } from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; import utc from 'dayjs/plugin/utc'; @@ -91,15 +92,22 @@ export function getTimeZoneLabel(tz: string = DEFAULT_TZ): string { return `GMT${offset}`; } -export function isBeforeToday(date: Date | string) { +/** + * This indicates whether the date is before the other supplied date-time. + * ``` + * // Default unit - 'day': + * isBeforeNow(date, 'day') + * ``` + */ +export function isBeforeNow(date: Date | string, unit: OpUnitType = 'day') { const now = dayjs().tz(DEFAULT_TZ); const inputDate = dayjs(date).tz(DEFAULT_TZ); - return inputDate.isBefore(now, 'day'); + return inputDate.isBefore(now, unit); } -export function isTodayOrAfter(date: Date | string) { - return !isBeforeToday(date); +export function isNowOrAfter(date: Date | string) { + return !isBeforeNow(date); } export function sumTime(datetime: DateTime, durationMinutes: number) {