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/orders/order-form/datetime-select.tsx b/apps/web/components/orders/order-form/datetime-select.tsx index 0690e59..dba3763 100644 --- a/apps/web/components/orders/order-form/datetime-select.tsx +++ b/apps/web/components/orders/order-form/datetime-select.tsx @@ -6,16 +6,22 @@ import { Button } from '@repo/ui/components/ui/button'; import { Calendar } from '@repo/ui/components/ui/calendar'; import { formatTime } from '@repo/utils/datetime-format'; import dayjs from 'dayjs'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; export function DateSelect() { const selectedDate = useOrderStore((store) => store.date); - const setDate = useOrderStore((store) => store.setDate); + const setSelectedDate = useOrderStore((store) => store.setDate); const setTime = useOrderStore((store) => store.setTime); const setSlot = useOrderStore((store) => store.setSlotId); const masterId = useOrderStore((store) => store.masterId); const serviceId = useOrderStore((store) => store.serviceId); + useEffect(() => { + if (!selectedDate) { + setSelectedDate(new Date()); + } + }, [selectedDate, setSelectedDate]); + const [selectedMonthDate, setSelectedMonthDate] = useState(new Date()); const { data: { slots } = {} } = useAvailableTimeSlotsQuery( @@ -53,7 +59,7 @@ export function DateSelect() { mode="single" onMonthChange={(date) => setSelectedMonthDate(date)} onSelect={(date) => { - if (date) setDate(date); + if (date) setSelectedDate(date); setTime(null); setSlot(null); }} diff --git a/apps/web/components/schedule/calendar.tsx b/apps/web/components/schedule/calendar.tsx index c58b603..44cacf6 100644 --- a/apps/web/components/schedule/calendar.tsx +++ b/apps/web/components/schedule/calendar.tsx @@ -6,7 +6,7 @@ import { useDateTimeStore } from '@/stores/datetime'; import { Calendar } from '@repo/ui/components/ui/calendar'; import { getDateUTCRange } from '@repo/utils/datetime-format'; import dayjs from 'dayjs'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; export function ScheduleCalendar() { const { data: { customer } = {} } = useCustomerQuery(); @@ -14,6 +14,12 @@ export function ScheduleCalendar() { const selectedDate = useDateTimeStore((store) => store.date); const setSelectedDate = useDateTimeStore((store) => store.setDate); + useEffect(() => { + if (!selectedDate) { + setSelectedDate(new Date()); + } + }, [selectedDate, setSelectedDate]); + const [currentMonthDate, setCurrentMonthDate] = useState(new Date()); const { endOfMonth, startOfMonth } = getDateUTCRange(currentMonthDate).month(); 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/apps/web/vitest.config.mts b/apps/web/vitest.config.mts index 4915b13..cf8d8cb 100644 --- a/apps/web/vitest.config.mts +++ b/apps/web/vitest.config.mts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [tsconfigPaths(), react()], test: { environment: 'jsdom', - exclude: ['**/e2e/**', '**/*.spec.ts'], + exclude: ['**/e2e/**', '**/*.spec.ts', '**/node_modules/**'], include: ['**/*.test.{ts,tsx}'], }, }); diff --git a/packages/graphql/api/base.ts b/packages/graphql/api/base.ts index 1b7992c..e881673 100644 --- a/packages/graphql/api/base.ts +++ b/packages/graphql/api/base.ts @@ -2,9 +2,9 @@ import { getClientWithToken } from '../apollo/client'; import * as GQL from '../types'; -const ERRORS = { - MISSING_TELEGRAM_ID: 'Missing telegram id', - NOT_FOUND_CUSTOMER: 'Customer not found', +export const ERRORS = { + MISSING_TELEGRAM_ID: 'Не указан Telegram ID', + NOT_FOUND_CUSTOMER: 'Пользователь не найден', }; type UserProfile = { diff --git a/packages/graphql/api/customers.ts b/packages/graphql/api/customers.ts index 91c45c7..ab47e94 100644 --- a/packages/graphql/api/customers.ts +++ b/packages/graphql/api/customers.ts @@ -38,12 +38,12 @@ export class CustomersService extends BaseService { const result = await query({ query: GQL.GetClientsDocument, - variables: { - telegramId: variables?.telegramId || this._user.telegramId, - }, + variables, }); - return result.data; + const customer = result.data.customers.at(0); + + return customer; } async getCustomer(variables: VariablesOf) { @@ -64,12 +64,12 @@ export class CustomersService extends BaseService { const result = await query({ query: GQL.GetMastersDocument, - variables: { - telegramId: variables?.telegramId || this._user.telegramId, - }, + variables, }); - return result.data; + const customer = result.data.customers.at(0); + + return customer; } async updateCustomer( diff --git a/packages/graphql/api/orders.test.js b/packages/graphql/api/orders.test.js new file mode 100644 index 0000000..0487789 --- /dev/null +++ b/packages/graphql/api/orders.test.js @@ -0,0 +1,1407 @@ +import { getClientWithToken } from '../apollo/client'; +import * as GQL from '../types'; +import { CustomersService } from './customers'; +import { ERRORS, OrdersService } from './orders'; +import { ServicesService } from './services'; +import { SlotsService } from './slots'; +import dayjs from 'dayjs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../apollo/client'); +vi.mock('./customers'); +vi.mock('./services'); +vi.mock('./slots'); +vi.mock('../config/env', () => { + return { + env: { + BOT_TOKEN: 'test', + LOGIN_GRAPHQL: 'test', + PASSWORD_GRAPHQL: 'test', + URL_GRAPHQL: 'test', + }, + }; +}); + +const mockGetClientWithToken = vi.mocked(getClientWithToken); +const mockCustomersService = vi.mocked(CustomersService); +const mockServicesService = vi.mocked(ServicesService); +const mockSlotsService = vi.mocked(SlotsService); + +describe('OrdersService', () => { + /** + * @type {OrdersService} + */ + let ordersService; + const mockUser = { telegramId: 123_456_789 }; + + const mockCustomer = { + active: true, + documentId: 'customer-123', + firstName: 'John', + lastName: 'Doe', + role: GQL.Enum_Customer_Role.Customer, + telegramId: 123_456_789, + }; + + const mockMaster = { + active: true, + documentId: 'master-123', + firstName: 'Jane', + lastName: 'Master', + role: GQL.Enum_Customer_Role.Master, + telegramId: 987_654_321, + }; + + const now = dayjs().minute(0).second(0).millisecond(0); + vi.setSystemTime(now.toDate()); + + const mockSlot = { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'slot-123', + master: mockMaster, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockService = { + active: true, + documentId: 'service-123', + duration: '01:00:00', // 1 час + master: mockMaster, + name: 'Test Service', + }; + + const mockOrder = { + client: mockCustomer, + datetime_end: now.add(1, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + services: [mockService], + slot: mockSlot, + state: GQL.Enum_Order_State.Created, + }; + + const mockGetCustomerResult = { + data: { + customers: [mockCustomer], + }, + }; + + beforeEach(() => { + ordersService = new OrdersService(mockUser); + vi.clearAllMocks(); + + // Глобальный мок для _getUser + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockCustomer, + }); + + // Глобальные моки для сервисов + mockServicesService.mockImplementation(() => ({ + getService: vi.fn().mockResolvedValue({ + service: mockService, + }), + })); + + mockSlotsService.mockImplementation(() => ({ + getSlot: vi.fn().mockResolvedValue({ + slot: mockSlot, + }), + })); + + mockCustomersService.mockImplementation(() => ({ + getCustomer: vi.fn().mockResolvedValue({ + customer: mockCustomer, + }), + getMasters: vi.fn().mockResolvedValue({ + masters: [mockMaster], + }), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createOrder', () => { + const mockVariables = { + input: { + client: 'customer-123', + datetime_start: now.toISOString(), + services: ['service-123'], + slot: 'slot-123', + }, + }; + + const mockMutationResult = { + data: { + createOrder: mockOrder, + }, + errors: undefined, + }; + + it('should successfully create order for customer', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ data: { orders: [] } }); // нет пересекающихся заказов + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should successfully create approved order for master', async () => { + const masterCustomer = { + ...mockCustomer, + role: GQL.Enum_Customer_Role.Master, + }; + + // Переопределяем мок для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: masterCustomer, + }); + + const masterSlot = { + ...mockSlot, + master: masterCustomer, + }; + + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [masterCustomer] }, + }); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ data: { orders: [] } }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + // Переопределяем моки для мастера + mockSlotsService.mockImplementation(() => ({ + getSlot: vi.fn().mockResolvedValue({ + slot: masterSlot, + }), + })); + + mockCustomersService.mockImplementation(() => ({ + getCustomer: vi.fn().mockResolvedValue({ + customer: masterCustomer, + }), + getMasters: vi.fn().mockResolvedValue({ + masters: [masterCustomer], + }), + })); + + const result = ordersService.createOrder({ + ...mockVariables, + input: { + ...mockVariables.input, + client: masterCustomer.documentId, + }, + }); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should throw error when slot is missing', async () => { + const variablesWithoutSlot = { + input: { + client: 'customer-123', + datetime_start: now.toISOString(), + services: ['service-123'], + }, + }; + + const result = ordersService.createOrder(variablesWithoutSlot); + + await expect(result).rejects.toThrow(ERRORS.MISSING_SLOT); + }); + + it('should throw error when services are missing', async () => { + const variablesWithoutServices = { + input: { + client: 'customer-123', + datetime_start: now.toISOString(), + slot: 'slot-123', + }, + }; + + const result = ordersService.createOrder(variablesWithoutServices); + + await expect(result).rejects.toThrow(ERRORS.MISSING_SERVICES); + }); + + it('should throw error when datetime_start is missing', async () => { + const variablesWithoutStart = { + input: { + client: 'customer-123', + services: ['service-123'], + slot: 'slot-123', + }, + }; + + const result = ordersService.createOrder(variablesWithoutStart); + + await expect(result).rejects.toThrow(ERRORS.MISSING_START_TIME); + }); + + it('should throw error when client is missing', async () => { + const variablesWithoutClient = { + input: { + datetime_start: now.toISOString(), + services: ['service-123'], + slot: 'slot-123', + }, + }; + + const result = ordersService.createOrder(variablesWithoutClient); + + await expect(result).rejects.toThrow(ERRORS.MISSING_CLIENT); + }); + + it('should throw error when order time is in the past', async () => { + const pastTime = now.subtract(1, 'hour'); + const variablesWithPastTime = { + input: { + client: 'customer-123', + datetime_end: pastTime.add(1, 'hour').toISOString(), + datetime_start: pastTime.toISOString(), + services: ['service-123'], + slot: 'slot-123', + }, + }; + + const result = ordersService.createOrder(variablesWithPastTime); + + await expect(result).rejects.toThrow(ERRORS.NO_ORDER_IN_PAST); + }); + + it('should throw error when slot is not found', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: null, // слот не найден + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.MISSING_SLOT); + }); + + it('should throw error when order is out of slot time', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: mockSlot, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: mockCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [mockMaster], + }), + })); + + const variablesWithOutOfSlotTime = { + input: { + client: 'customer-123', + datetime_end: now.add(8, 'hour').toISOString(), + datetime_start: now.add(7, 'hour').toISOString(), // после окончания слота + services: ['service-123'], + slot: 'slot-123', + }, + }; + + const result = ordersService.createOrder(variablesWithOutOfSlotTime); + + await expect(result).rejects.toThrow(ERRORS.NO_ORDER_OUT_OF_SLOT); + }); + + it('should throw error when slot is closed', async () => { + const closedSlot = { + ...mockSlot, + state: GQL.Enum_Slot_State.Closed, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: closedSlot, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: mockCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [mockMaster], + }), + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.SLOT_CLOSED); + }); + + it('should throw error when client is not found', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: mockSlot, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: null, // клиент не найден + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [mockMaster], + }), + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.NOT_FOUND_CLIENT); + }); + + it('should throw error when client is inactive', async () => { + const inactiveCustomer = { + ...mockCustomer, + active: false, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: mockSlot, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: inactiveCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [mockMaster], + }), + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INACTIVE_CLIENT); + }); + + it('should throw error when master is inactive', async () => { + const inactiveMaster = { + ...mockMaster, + active: false, + }; + + const slotWithInactiveMaster = { + ...mockSlot, + master: inactiveMaster, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: slotWithInactiveMaster, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: mockCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [inactiveMaster], + }), + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INACTIVE_MASTER); + }); + + it('should throw error when customer tries to book themselves as master', async () => { + const activeCustomerAsMaster = { + ...mockCustomer, + active: true, + role: GQL.Enum_Customer_Role.Master, + }; + + const slotWithCustomerAsMaster = { + ...mockSlot, + master: activeCustomerAsMaster, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: slotWithCustomerAsMaster, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: mockCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [activeCustomerAsMaster], + }), + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.NO_MASTER_SELF_BOOK); + }); + + it('should throw error when customer is not linked to master', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: mockSlot, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: mockCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [], // клиент не связан с мастером + }), + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INVALID_MASTER); + }); + + it('should throw error when time overlaps with other orders', async () => { + const overlappingOrder = { + ...mockOrder, + documentId: 'order-456', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ + data: { orders: [overlappingOrder] }, + }); // есть пересекающиеся заказы + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: mockSlot, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: mockCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [mockMaster], + }), + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.OVERLAPPING_TIME); + }); + + it('should throw error when service duration is invalid', async () => { + const serviceWithoutDuration = { + ...mockService, + duration: null, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const mockGetSlot = vi.fn().mockResolvedValue({ + slot: mockSlot, + }); + mockSlotsService.mockImplementation(() => ({ + getSlot: mockGetSlot, + })); + + const mockGetCustomer = vi.fn().mockResolvedValue({ + customer: mockCustomer, + }); + mockCustomersService.mockImplementation(() => ({ + getCustomer: mockGetCustomer, + getMasters: vi.fn().mockResolvedValue({ + masters: [mockMaster], + }), + })); + + const mockGetService = vi.fn().mockResolvedValue({ + service: serviceWithoutDuration, + }); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = ordersService.createOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INVALID_SERVICE_DURATION); + }); + + it('should calculate datetime_end based on service duration', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ data: { orders: [] } }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + await ordersService.createOrder(mockVariables); + + expect(mockMutate).toHaveBeenCalledWith({ + mutation: GQL.CreateOrderDocument, + variables: { + ...mockVariables, + input: { + ...mockVariables.input, + datetime_end: now.add(1, 'hour').toISOString(), // 1 час от начала + state: GQL.Enum_Order_State.Created, + }, + }, + }); + }); + }); + + describe('updateOrder', () => { + const mockVariables = { + data: { + datetime_end: now.add(2, 'hour').toISOString(), + datetime_start: now.add(1, 'hour').toISOString(), + state: GQL.Enum_Order_State.Approved, + }, + documentId: 'order-123', + }; + + const mockMutationResult = { + data: { + updateOrder: { + ...mockOrder, + ...mockVariables.data, + }, + }, + errors: undefined, + }; + + it('should successfully update order by master', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetOrderDocument) { + return Promise.resolve({ + data: { + order: { + ...mockOrder, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ data: { orders: [] } }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const result = await ordersService.updateOrder(mockVariables); + + expect(result).toBe(mockMutationResult.data); + expect(mockMutate).toHaveBeenCalledWith({ + mutation: GQL.UpdateOrderDocument, + variables: { + ...mockVariables, + data: { + ...mockVariables.data, + order_number: undefined, + }, + }, + }); + }); + + it('should successfully update order by client (cancelling)', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetOrderDocument) { + return Promise.resolve({ + data: { + order: { + ...mockOrder, + client: mockCustomer, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + // Мокаем _getUser для клиента + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockCustomer, + }); + + const clientVariables = { + ...mockVariables, + data: { + state: GQL.Enum_Order_State.Cancelling, + }, + }; + + const result = await ordersService.updateOrder(clientVariables); + + expect(result).toBe(mockMutationResult.data); + }); + + it('should throw error when order is not found', async () => { + const mockQuery = vi.fn().mockResolvedValue({ + data: { order: null }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const result = ordersService.updateOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.MISSING_ORDER); + }); + + it('should throw error when user has no permission', async () => { + const unauthorizedUser = { + ...mockCustomer, + documentId: 'unauthorized-user', + }; + + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + client: mockCustomer, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для неавторизованного пользователя + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: unauthorizedUser, + }); + + const result = ordersService.updateOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + + it('should throw error when client tries to change more than one field', async () => { + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + client: mockCustomer, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для клиента + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockCustomer, + }); + + const clientVariables = { + ...mockVariables, + data: { + datetime_start: now.add(1, 'hour').toISOString(), + state: GQL.Enum_Order_State.Cancelling, + }, + }; + + const result = ordersService.updateOrder(clientVariables); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + + it('should throw error when client tries to change state to non-cancelling', async () => { + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + client: mockCustomer, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для клиента + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockCustomer, + }); + + const clientVariables = { + ...mockVariables, + data: { + state: GQL.Enum_Order_State.Approved, + }, + }; + + const result = ordersService.updateOrder(clientVariables); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + + it('should throw error when trying to change client', async () => { + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const variablesWithClient = { + ...mockVariables, + data: { + ...mockVariables.data, + client: 'new-client-id', + }, + }; + + const result = ordersService.updateOrder(variablesWithClient); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + + it('should throw error when trying to change services', async () => { + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const variablesWithServices = { + ...mockVariables, + data: { + ...mockVariables.data, + services: ['new-service-id'], + }, + }; + + const result = ordersService.updateOrder(variablesWithServices); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + + it('should throw error when trying to change slot', async () => { + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const variablesWithSlot = { + ...mockVariables, + data: { + ...mockVariables.data, + slot: 'new-slot-id', + }, + }; + + const result = ordersService.updateOrder(variablesWithSlot); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + + it('should throw error when order slot is not found', async () => { + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + client: mockCustomer, + slot: null, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для клиента заказа + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockCustomer, + }); + + const variablesWithSingleField = { + ...mockVariables, + data: { + state: GQL.Enum_Order_State.Cancelling, + }, + }; + + const result = ordersService.updateOrder(variablesWithSingleField); + + await expect(result).rejects.toThrow(ERRORS.NOT_FOUND_ORDER_SLOT); + }); + + it('should throw error when trying to change completed order state', async () => { + const completedOrder = { + ...mockOrder, + state: GQL.Enum_Order_State.Completed, + }; + + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: completedOrder, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const result = ordersService.updateOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.NO_CHANGE_STATE_COMPLETED); + }); + + it('should throw error when time is invalid', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetOrderDocument) { + return Promise.resolve({ + data: { + order: { + ...mockOrder, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const variablesWithInvalidTime = { + ...mockVariables, + data: { + datetime_end: now.add(1, 'hour').toISOString(), + datetime_start: now.add(2, 'hour').toISOString(), // конец раньше начала + }, + }; + + const result = ordersService.updateOrder(variablesWithInvalidTime); + + await expect(result).rejects.toThrow(ERRORS.INVALID_TIME); + }); + + it('should throw error when time overlaps with other orders', async () => { + const overlappingOrder = { + ...mockOrder, + documentId: 'order-456', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetOrderDocument) { + return Promise.resolve({ + data: { + order: { + ...mockOrder, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ + data: { orders: [overlappingOrder] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const result = ordersService.updateOrder(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.OVERLAPPING_TIME); + }); + + it('should successfully complete order and set order number', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetOrderDocument) { + return Promise.resolve({ + data: { + order: { + ...mockOrder, + client: mockCustomer, + datetime_start: now.subtract(1, 'hour').toISOString(), // заказ в прошлом + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ + data: { + orders: [ + { + ...mockOrder, + order_number: 5, + }, + ], + }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const completeVariables = { + ...mockVariables, + data: { + state: GQL.Enum_Order_State.Completed, + }, + }; + + const result = await ordersService.updateOrder(completeVariables); + + expect(result).toBe(mockMutationResult.data); + expect(mockMutate).toHaveBeenCalledWith({ + mutation: GQL.UpdateOrderDocument, + variables: { + ...completeVariables, + data: { + ...completeVariables.data, + order_number: 6, // 5 + 1 + }, + }, + }); + }); + + it('should successfully complete order and set order number to 1 when no previous completed orders', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetOrderDocument) { + return Promise.resolve({ + data: { + order: { + ...mockOrder, + client: mockCustomer, + datetime_start: now.subtract(1, 'hour').toISOString(), // заказ в прошлом + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + } + + if (query === GQL.GetOrdersDocument) { + return Promise.resolve({ + data: { + orders: [], // нет других завершенных заказов + }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const completeVariables = { + ...mockVariables, + data: { + state: GQL.Enum_Order_State.Completed, + }, + }; + + const result = await ordersService.updateOrder(completeVariables); + + expect(result).toBe(mockMutationResult.data); + expect(mockMutate).toHaveBeenCalledWith({ + mutation: GQL.UpdateOrderDocument, + variables: { + ...completeVariables, + data: { + ...completeVariables.data, + order_number: undefined, + }, + }, + }); + }); + + it('should throw error when trying to complete order before start time', async () => { + const futureOrder = { + ...mockOrder, + datetime_start: now.add(1, 'hour').toISOString(), + }; + + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: futureOrder, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const completeVariables = { + ...mockVariables, + data: { + state: GQL.Enum_Order_State.Completed, + }, + }; + + const result = ordersService.updateOrder(completeVariables); + + await expect(result).rejects.toThrow(ERRORS.CANNOT_COMPLETE_BEFORE_START); + }); + + it('should return early when no datetime changes are provided', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockResolvedValue({ + data: { + order: { + ...mockOrder, + slot: { ...mockSlot, master: mockMaster }, + }, + }, + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + // Мокаем _getUser для мастера + vi.spyOn(ordersService, '_getUser').mockResolvedValue({ + customer: mockMaster, + }); + + const variablesWithoutTime = { + ...mockVariables, + data: { + state: GQL.Enum_Order_State.Approved, + }, + }; + + const result = await ordersService.updateOrder(variablesWithoutTime); + + expect(result).toBe(mockMutationResult.data); + }); + }); +}); diff --git a/packages/graphql/api/orders.ts b/packages/graphql/api/orders.ts index 0ae89e8..626f4d2 100644 --- a/packages/graphql/api/orders.ts +++ b/packages/graphql/api/orders.ts @@ -1,23 +1,46 @@ +/* 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, isNowOrAfter } 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', +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) { const { customer } = await this._getUser(); @@ -26,7 +49,6 @@ export class OrdersService extends BaseService { 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.datetime_start) throw new Error(ERRORS.MISSING_START_TIME); if (!variables.input.client) throw new Error(ERRORS.MISSING_CLIENT); const servicesService = new ServicesService(this._user); @@ -41,6 +63,14 @@ export class OrdersService extends BaseService { .add(getMinutes(service.duration), 'minute') .toISOString(); + await this.checkBeforeCreate({ + ...variables, + input: { + ...variables.input, + datetime_end: datetimeEnd, + }, + }); + const { mutate } = await getClientWithToken(); const mutationResult = await mutate({ @@ -79,13 +109,19 @@ export class OrdersService extends BaseService { const result = await query({ query: GQL.GetOrdersDocument, - variables, + variables: { + sort: DEFAULT_ORDERS_SORT, + ...variables, + }, }); return result.data; } async updateOrder(variables: VariablesOf) { + await this.checkUpdatePermission(variables); + await this.checkBeforeUpdate(variables); + const { customer } = await this._getUser(); const { query } = await getClientWithToken(); @@ -117,9 +153,17 @@ export class OrdersService extends BaseService { const { mutate } = await getClientWithToken(); + const lastOrderNumber = await this.getLastOrderNumber(variables); + const mutationResult = await mutate({ mutation: GQL.UpdateOrderDocument, - variables, + variables: { + ...variables, + data: { + ...variables.data, + order_number: lastOrderNumber ? lastOrderNumber + 1 : undefined, + }, + }, }); const error = mutationResult.errors?.at(0); @@ -127,4 +171,252 @@ 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) { + 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) { + 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) { + 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) { + 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; + } } diff --git a/packages/graphql/api/slots.test.js b/packages/graphql/api/slots.test.js new file mode 100644 index 0000000..543f4c4 --- /dev/null +++ b/packages/graphql/api/slots.test.js @@ -0,0 +1,1505 @@ +import { getClientWithToken } from '../apollo/client'; +import * as GQL from '../types'; +import { ERRORS as BASE_ERRORS } from './base'; +import { ServicesService } from './services'; +import { ERRORS, SlotsService } from './slots'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +if (!dayjs.prototype.duration) { + dayjs.extend(duration); +} + +vi.mock('../apollo/client'); +vi.mock('./services'); +vi.mock('../config/env', () => { + return { + env: { + BOT_TOKEN: 'test', + LOGIN_GRAPHQL: 'test', + PASSWORD_GRAPHQL: 'test', + URL_GRAPHQL: 'test', + }, + }; +}); + +const mockGetClientWithToken = vi.mocked(getClientWithToken); +const mockServicesService = vi.mocked(ServicesService); + +describe('SlotsService', () => { + /** + * @type {SlotsService} + */ + let slotsService; + const mockUser = { telegramId: 123_456_789 }; + + const mockCustomer = { + documentId: 'customer-123', + firstName: 'John', + lastName: 'Doe', + telegramId: 123_456_789, + }; + + const now = dayjs().minute(0).second(0).millisecond(0); + vi.setSystemTime(now.toDate()); + + const mockSlot = { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'slot-123', + master: mockCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockGetCustomerResult = { + data: { + customers: [mockCustomer], + }, + }; + + const mockGetSlotResult = { + data: { + slot: mockSlot, + }, + }; + + beforeEach(() => { + slotsService = new SlotsService(mockUser); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getAvailableTimeSlots', () => { + const mockVariables = { + filters: { + datetime_start: now.toISOString(), + }, + }; + + const mockContext = { + service: { + documentId: { eq: 'service-123' }, + }, + }; + + const mockService = { + active: true, + documentId: 'service-123', + duration: '01:00:00', // 1 час + master: mockCustomer, + name: 'Test Service', + }; + + const mockGetServiceResult = { + service: mockService, + }; + + const mockSlotWithOrders = { + datetime_end: now.add(2, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'slot-123', + master: mockCustomer, + orders: [ + { + datetime_end: now.add(1, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Scheduled, + }, + ], + state: GQL.Enum_Slot_State.Open, + }; + + const mockSlotWithoutOrders = { + datetime_end: now.add(5, 'hour').toISOString(), + datetime_start: now.add(3, 'hour').toISOString(), + documentId: 'slot-124', + master: mockCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockGetSlotsOrdersResult = { + data: { + slots: [mockSlotWithOrders, mockSlotWithoutOrders], + }, + }; + + it('should successfully get available time slots', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve(mockGetSlotsOrdersResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + // Мокаем ServicesService.getService + const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + expect(result).resolves.toEqual({ + slots: [mockSlotWithOrders, mockSlotWithoutOrders], + times: [ + { + slotId: 'slot-123', + time: now.add(1, 'hour').toISOString(), // 11:00 + }, + { + slotId: 'slot-124', + time: now.add(3, 'hour').toISOString(), // 13:00 + }, + { + slotId: 'slot-124', + time: now.add(3, 'hour').add(15, 'minute').toISOString(), // 13:15 + }, + { + slotId: 'slot-124', + time: now.add(3, 'hour').add(30, 'minute').toISOString(), // 13:30 + }, + { + slotId: 'slot-124', + time: now.add(3, 'hour').add(45, 'minute').toISOString(), // 13:45 + }, + { + slotId: 'slot-124', + time: now.add(4, 'hour').toISOString(), // 14:00 + }, + ], + }); + }); + + it('should throw error when datetime_start is missing', async () => { + const variablesWithoutStart = { + filters: {}, + }; + + const result = slotsService.getAvailableTimeSlots(variablesWithoutStart, mockContext); + + await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_START); + }); + + it('should throw error when service documentId is missing', async () => { + const contextWithoutService = { + service: {}, + }; + + const result = slotsService.getAvailableTimeSlots(mockVariables, contextWithoutService); + + await expect(result).rejects.toThrow(ERRORS.MISSING_SERVICE_ID); + }); + + it('should throw error when service is not found', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve(mockGetSlotsOrdersResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + // Мокаем ServicesService.getService возвращающий null + const mockGetService = vi.fn().mockResolvedValue({ + data: { service: null }, + }); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + await expect(result).rejects.toThrow(ERRORS.SERVICE_NOT_FOUND); + }); + + it('should filter out times that conflict with orders', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve(mockGetSlotsOrdersResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + // Мокаем ServicesService.getService + const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + // Проверяем, что время конфликтующее с заказом не включено + const conflictingTime = now.toISOString(); + const hasConflictingTime = result.times.some((time) => time.time === conflictingTime); + expect(hasConflictingTime).toBe(false); + }); + + it('should include times from cancelled orders', async () => { + vi.setSystemTime(now.toDate()); // синхронизируем "текущее" время + + const slotWithCancelledOrder = { + ...mockSlotWithOrders, + orders: [ + { + datetime_end: now.add(1, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Cancelled, + }, + ], + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve({ + data: { slots: [slotWithCancelledOrder] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + // Слот: 10:00–12:00, сервис = 1 час, шаг = 15 мин + // Все возможные начала интервалов: от 10:00 до 11:00 включительно (всего 5) + const expectedTimes = [ + // now.toISOString(), // 10:00 + now.add(15, 'minute').toISOString(), // 10:15 + now.add(30, 'minute').toISOString(), // 10:30 + now.add(45, 'minute').toISOString(), // 10:45 + now.add(1, 'hour').toISOString(), // 11:00 + ]; + + const actualTimes = result.times + .filter((time) => time.slotId === slotWithCancelledOrder.documentId) + .map((time) => time.time); + + expect(actualTimes).toEqual(expectedTimes); + }); + + it('should filter out times in the past', async () => { + const pastSlot = { + datetime_end: now.subtract(1, 'hour').toISOString(), + datetime_start: now.subtract(2, 'hour').toISOString(), + documentId: 'slot-past', + master: mockCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve({ + data: { slots: [pastSlot] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + // Мокаем ServicesService.getService + const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + // Проверяем, что время в прошлом не включено + expect(result.times).toHaveLength(0); + }); + + it('should generate times with 15-minute intervals', async () => { + const longSlot = { + datetime_end: now.add(3, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'slot-long', + master: mockCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve({ + data: { slots: [longSlot] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + // Мокаем ServicesService.getService + const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + // Проверяем, что времена генерируются с интервалом 15 минут + expect(result.times.length).toBeGreaterThan(1); + + for (let index = 1; index < result.times.length; index++) { + const currentTime = dayjs(result.times[index].time); + const previousTime = dayjs(result.times[index - 1].time); + const diff = currentTime.diff(previousTime, 'minute'); + expect(diff).toBe(15); + } + }); + + it('should skip slots without datetime_start or datetime_end', async () => { + const incompleteSlot = { + datetime_end: now.add(1, 'hour').toISOString(), + datetime_start: null, // отсутствует datetime_start + documentId: 'slot-incomplete', + master: mockCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve({ + data: { slots: [incompleteSlot] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + // Мокаем ServicesService.getService + const mockGetService = vi.fn().mockResolvedValue(mockGetServiceResult); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + // Проверяем, что слот без datetime_start пропущен + expect(result.times).toHaveLength(0); + }); + + it('should handle GraphQL errors', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve({ + error: { message: 'GraphQL error' }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + const result = slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + await expect(result).rejects.toThrow('GraphQL error'); + }); + + it('should calculate service duration correctly', async () => { + const serviceWithDuration = { + ...mockService, + duration: '00:30:00', // 30 минут + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetSlotsOrdersDocument) { + return Promise.resolve({ + data: { slots: [mockSlotWithoutOrders] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + query: mockQuery, + }); + + // Мокаем ServicesService.getService с другой длительностью + const mockGetService = vi.fn().mockResolvedValue({ + service: serviceWithDuration, + }); + mockServicesService.mockImplementation(() => ({ + getService: mockGetService, + })); + + const result = await slotsService.getAvailableTimeSlots(mockVariables, mockContext); + + // Проверяем, что времена генерируются с учетом длительности услуги + expect(result.times.length).toBeGreaterThan(0); + + // Последнее время должно быть не позже чем datetime_end минус длительность услуги + const lastTime = dayjs(result.times[result.times.length - 1].time); + const slotEnd = dayjs(mockSlotWithoutOrders.datetime_end); + const serviceDuration = dayjs.duration('00:30:00'); + const maxTime = slotEnd.subtract(serviceDuration); + + expect(lastTime.valueOf()).toBeLessThanOrEqual(maxTime.valueOf()); + }); + }); + + describe('createSlot', () => { + const mockVariables = { + input: { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockMutationResult = { + data: { + createSlot: mockSlot, + }, + errors: undefined, + }; + + const mockMasterCustomer = { + ...mockCustomer, + active: true, + role: 'master', + }; + + const mockGetMasterCustomerResult = { + data: { + customers: [mockMasterCustomer], + }, + }; + + it('should successfully create slot when master is active', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ data: { slots: [] } }); // нет пересекающихся слотов + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.createSlot({ + input: { + ...mockVariables.input, + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + }, + }); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should throw error when master is not found', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [] }, // мастер не найден + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(BASE_ERRORS.CUSTOMER_NOT_FOUND); + }); + + it('should throw error when master is not active', async () => { + const inactiveMaster = { + ...mockMasterCustomer, + active: false, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [inactiveMaster] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INACTIVE_MASTER); + }); + + it('should throw error when master role is not master', async () => { + const nonMasterCustomer = { + ...mockMasterCustomer, + role: 'customer', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [nonMasterCustomer] }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.INACTIVE_MASTER); + }); + + it('should throw error when datetime_start is missing', async () => { + const variablesWithoutStart = { + input: { + datetime_end: now.add(6, 'hour').toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithoutStart); + + await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_START); + }); + + it('should throw error when datetime_end is missing', async () => { + const variablesWithoutEnd = { + input: { + datetime_start: now.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithoutEnd); + + await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_END); + }); + + it('should throw error when datetime_end is before datetime_start', async () => { + const variablesWithInvalidTime = { + input: { + datetime_end: now.toISOString(), + datetime_start: now.add(6, 'hour').toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithInvalidTime); + + await expect(result).rejects.toThrow(ERRORS.INVALID_TIME); + }); + + it('should throw error when datetime_end equals datetime_start', async () => { + const variablesWithEqualTime = { + input: { + datetime_end: now.toISOString(), + datetime_start: now.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithEqualTime); + + await expect(result).rejects.toThrow(ERRORS.INVALID_TIME); + }); + + it('should throw error when slot is created in the past', async () => { + const yesterday = dayjs().subtract(1, 'day'); + + const variablesWithPastTime = { + input: { + datetime_end: yesterday.add(1, 'hour').toISOString(), + datetime_start: yesterday.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(variablesWithPastTime); + + await expect(result).rejects.toThrow(ERRORS.PAST_SLOT); + }); + + it('should throw error when time overlaps with other slots', async () => { + const overlappingSlot = { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'slot-456', + master: mockMasterCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ + data: { slots: [overlappingSlot] }, + }); // есть пересекающиеся слоты + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.OVERLAPPING_TIME); + }); + + it('should allow creation when time does not overlap with other slots', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ + data: { slots: [] }, + }); // нет пересекающихся слотов + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.createSlot(mockVariables); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should include master documentId in mutation variables', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetMasterCustomerResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ data: { slots: [] } }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + await slotsService.createSlot(mockVariables); + + expect(mockMutate).toHaveBeenCalledWith({ + mutation: GQL.CreateSlotDocument, + variables: { + ...mockVariables, + input: { + ...mockVariables.input, + master: mockMasterCustomer.documentId, + }, + }, + }); + }); + }); + + describe('updateSlot', () => { + const mockVariables = { + data: { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + state: GQL.Enum_Slot_State.Open, + }, + documentId: 'slot-123', + }; + + const mockMutationResult = { + data: { + updateSlot: mockSlot, + }, + errors: undefined, + }; + + it('should successfully update slot when user has permission', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve(mockGetSlotResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ data: { slots: [] } }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should successfully update slot when time is not changing', async () => { + const variablesWithoutTime = { + data: { + state: GQL.Enum_Slot_State.Closed, + }, + documentId: 'slot-123', + }; + + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve(mockGetSlotResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.updateSlot(variablesWithoutTime); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should throw error when user does not have permission', async () => { + const unrelatedCustomer = { + ...mockCustomer, + documentId: 'different-customer-123', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [unrelatedCustomer] }, + }); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: mockSlot }, // slot принадлежит другому пользователю + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + + it('should throw error when slot does not exist', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: null }, // slot не найден + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.SLOT_NOT_FOUND); + }); + + it('should throw error when customer is not found', async () => { + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [] }, // пользователь не найден + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(BASE_ERRORS.CUSTOMER_NOT_FOUND); + }); + + it('should throw error when datetime_start is missing', async () => { + const variablesWithMissingStart = { + data: { + datetime_end: now.add(6, 'hour').toISOString(), + }, + documentId: 'slot-123', + }; + + const slotWithoutStart = { + ...mockSlot, + datetime_start: null, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithoutStart }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(variablesWithMissingStart); + + await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_START); + }); + + it('should throw error when datetime_end is missing', async () => { + const variablesWithMissingEnd = { + data: { + datetime_start: now.toISOString(), + }, + documentId: 'slot-123', + }; + + const slotWithoutEnd = { + ...mockSlot, + datetime_end: null, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithoutEnd }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(variablesWithMissingEnd); + + await expect(result).rejects.toThrow(ERRORS.MISSING_DATETIME_END); + }); + + it('should throw error when datetime_end is before datetime_start', async () => { + const variablesWithInvalidTime = { + data: { + datetime_end: now.toISOString(), + datetime_start: now.add(6, 'hour').toISOString(), + }, + documentId: 'slot-123', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve(mockGetSlotResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(variablesWithInvalidTime); + + await expect(result).rejects.toThrow(ERRORS.INVALID_TIME); + }); + + it('should throw error when datetime_end equals datetime_start', async () => { + const variablesWithEqualTime = { + data: { + datetime_end: now.toISOString(), + datetime_start: now.toISOString(), + }, + documentId: 'slot-123', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve(mockGetSlotResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(variablesWithEqualTime); + + await expect(result).rejects.toThrow(ERRORS.INVALID_TIME); + }); + + it('should throw error when slot has scheduled orders', async () => { + const slotWithScheduledOrders = { + ...mockSlot, + orders: [ + { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Scheduled, + }, + ], + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithScheduledOrders }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS); + }); + + it('should throw error when slot has approved orders', async () => { + const slotWithApprovedOrders = { + ...mockSlot, + orders: [ + { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Approved, + }, + ], + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithApprovedOrders }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS); + }); + + it('should throw error when slot has completed orders', async () => { + const slotWithCompletedOrders = { + ...mockSlot, + orders: [ + { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Completed, + }, + ], + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithCompletedOrders }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS); + }); + + it('should throw error when slot has cancelled orders', async () => { + const slotWithCancelledOrders = { + ...mockSlot, + orders: [ + { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Cancelled, + }, + ], + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithCancelledOrders }, + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS); + }); + + it('should allow update when slot has non-forbidden order states', async () => { + const slotWithNonForbiddenOrders = { + ...mockSlot, + orders: [ + { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Draft, // не запрещенное состояние + }, + ], + }; + + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithNonForbiddenOrders }, + }); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ data: { slots: [] } }); // нет пересекающихся слотов + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should throw error when time overlaps with other slots', async () => { + const overlappingSlot = { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'slot-456', + master: mockCustomer, + orders: [], + state: GQL.Enum_Slot_State.Open, + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve(mockGetSlotResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ + data: { slots: [overlappingSlot] }, + }); // есть пересекающиеся слоты + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.OVERLAPPING_TIME); + }); + + it('should allow update when time does not overlap with other slots', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve(mockGetSlotResult); + } + + if (query === GQL.GetSlotsDocument) { + return Promise.resolve({ + data: { slots: [] }, + }); // нет пересекающихся слотов + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.updateSlot(mockVariables); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + }); + + describe('deleteSlot', () => { + const mockVariables = { + documentId: 'slot-123', + }; + + const mockMutationResult = { + data: { + deleteSlot: { + documentId: 'slot-123', + }, + }, + errors: undefined, + }; + + it('should successfully delete slot when no orders', async () => { + const mockMutate = vi.fn().mockResolvedValue(mockMutationResult); + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve(mockGetSlotResult); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: mockMutate, + query: mockQuery, + }); + + const result = slotsService.deleteSlot(mockVariables); + + await expect(result).resolves.toBe(mockMutationResult.data); + }); + + it('should throw error when slot has orders', async () => { + const slotWithOrders = { + ...mockSlot, + orders: [ + { + datetime_end: now.add(6, 'hour').toISOString(), + datetime_start: now.toISOString(), + documentId: 'order-123', + state: GQL.Enum_Order_State.Scheduled, + }, + ], + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve(mockGetCustomerResult); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: slotWithOrders }, // slot с заказами + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.deleteSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.HAS_ORDERS); + }); + + it('should throw error when user does not have permission', async () => { + const unrelatedCustomer = { + ...mockCustomer, + documentId: 'different-customer-123', + }; + + const mockQuery = vi.fn().mockImplementation(({ query }) => { + if (query === GQL.GetCustomerDocument) { + return Promise.resolve({ + data: { customers: [unrelatedCustomer] }, + }); + } + + if (query === GQL.GetSlotDocument) { + return Promise.resolve({ + data: { slot: mockSlot }, // slot принадлежит другому пользователю + }); + } + + return Promise.resolve({ data: {} }); + }); + + mockGetClientWithToken.mockResolvedValue({ + mutate: vi.fn(), + query: mockQuery, + }); + + const result = slotsService.deleteSlot(mockVariables); + + await expect(result).rejects.toThrow(ERRORS.NO_PERMISSION); + }); + }); +}); diff --git a/packages/graphql/api/slots.ts b/packages/graphql/api/slots.ts index 405b4c0..573d790 100644 --- a/packages/graphql/api/slots.ts +++ b/packages/graphql/api/slots.ts @@ -1,21 +1,31 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import { getClientWithToken } from '../apollo/client'; import * as GQL from '../types'; import { BaseService } from './base'; import { ServicesService } from './services'; import { type VariablesOf } from '@graphql-typed-document-node/core'; -import { getMinutes } from '@repo/utils/datetime-format'; +import { getMinutes, isBeforeNow } from '@repo/utils/datetime-format'; import dayjs from 'dayjs'; -const ERRORS = { - HAS_ORDERS: 'Slot has orders', - MISSING_DATE: 'Missing date', - MISSING_SERVICE: 'Missing service', - MISSING_SERVICE_ID: 'Missing service id', - NO_PERMISSION: 'No permission', +export const ERRORS = { + INACTIVE_MASTER: 'Пользователь не является активным или мастером', + INVALID_TIME: 'Некорректное время', + MISSING_DATETIME_END: 'Не указана дата окончания', + MISSING_DATETIME_START: 'Не указана дата начала', + MISSING_SERVICE_ID: 'Не указана услуга', + NO_PAST_SLOT: 'Нельзя создать слот в прошлом', + NO_PERMISSION: 'Нет доступа', + 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.checkBeforeCreate(variables); + const { customer } = await this._getUser(); const { mutate } = await getClientWithToken(); @@ -43,7 +53,7 @@ export class SlotsService extends BaseService { const { slot } = await this.getSlot({ documentId: variables.documentId }); if (slot?.orders?.length) { - throw new Error(ERRORS.HAS_ORDERS); + throw new Error(ERRORS.SLOT_HAS_ORDERS); } const { mutate } = await getClientWithToken(); @@ -63,7 +73,7 @@ export class SlotsService extends BaseService { variables: VariablesOf, context: { service: GQL.ServiceFiltersInput }, ) { - if (!variables.filters?.datetime_start) throw new Error(ERRORS.MISSING_DATE); + if (!variables.filters?.datetime_start) throw new Error(ERRORS.MISSING_DATETIME_START); if (!context?.service?.documentId?.eq) throw new Error(ERRORS.MISSING_SERVICE_ID); const { query } = await getClientWithToken(); @@ -86,7 +96,7 @@ export class SlotsService extends BaseService { documentId: context.service.documentId.eq, }); - if (!service) throw new Error(ERRORS.MISSING_SERVICE); + if (!service) throw new Error(ERRORS.NOT_FOUND_SERVICE); const serviceDuration = getMinutes(service.duration); @@ -149,6 +159,7 @@ export class SlotsService extends BaseService { async updateSlot(variables: VariablesOf) { await this.checkPermission(variables); + await this.checkBeforeUpdateDatetime(variables); const { mutate } = await getClientWithToken(); @@ -163,6 +174,101 @@ export class SlotsService extends BaseService { 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 || masterEntity.role !== 'master') { + 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'>, ) { @@ -170,6 +276,8 @@ export class SlotsService extends BaseService { 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(ERRORS.NO_PERMISSION); } } diff --git a/packages/graphql/operations/orders.graphql b/packages/graphql/operations/orders.graphql index 0267d1c..db46252 100644 --- a/packages/graphql/operations/orders.graphql +++ b/packages/graphql/operations/orders.graphql @@ -15,10 +15,10 @@ fragment OrderFields on Order { } } -query GetOrders($filters: OrderFiltersInput, $pagination: PaginationArg) { +query GetOrders($filters: OrderFiltersInput, $pagination: PaginationArg, $sort: [String!]) { orders( filters: $filters - sort: ["slot.datetime_start:desc", "datetime_start:asc"] + sort: $sort pagination: $pagination ) { ...OrderFields diff --git a/packages/graphql/types/operations.generated.ts b/packages/graphql/types/operations.generated.ts index bf3542e..9f55727 100644 --- a/packages/graphql/types/operations.generated.ts +++ b/packages/graphql/types/operations.generated.ts @@ -665,29 +665,30 @@ export type UpdateCustomerMutationVariables = Exact<{ export type UpdateCustomerMutation = { __typename?: 'Mutation', updateCustomer?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined }; -export type OrderFieldsFragment = { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; +export type OrderFieldsFragment = { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; export type GetOrdersQueryVariables = Exact<{ filters?: InputMaybe; pagination?: InputMaybe; + sort?: InputMaybe | Scalars['String']['input']>; }>; -export type GetOrdersQuery = { __typename?: 'Query', orders: Array<{ __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined> }; +export type GetOrdersQuery = { __typename?: 'Query', orders: Array<{ __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined> }; export type GetOrderQueryVariables = Exact<{ documentId: Scalars['ID']['input']; }>; -export type GetOrderQuery = { __typename?: 'Query', order?: { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined }; +export type GetOrderQuery = { __typename?: 'Query', order?: { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined }; export type CreateOrderMutationVariables = Exact<{ input: OrderInput; }>; -export type CreateOrderMutation = { __typename?: 'Mutation', createOrder?: { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined }; +export type CreateOrderMutation = { __typename?: 'Mutation', createOrder?: { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined }; export type UpdateOrderMutationVariables = Exact<{ documentId: Scalars['ID']['input']; @@ -695,30 +696,30 @@ export type UpdateOrderMutationVariables = Exact<{ }>; -export type UpdateOrderMutation = { __typename?: 'Mutation', updateOrder?: { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined }; +export type UpdateOrderMutation = { __typename?: 'Mutation', updateOrder?: { __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined }; -export type ServiceFieldsFragment = { __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined }; +export type ServiceFieldsFragment = { __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined }; export type GetServicesQueryVariables = Exact<{ filters?: InputMaybe; }>; -export type GetServicesQuery = { __typename?: 'Query', services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined> }; +export type GetServicesQuery = { __typename?: 'Query', services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined> }; export type GetServiceQueryVariables = Exact<{ documentId: Scalars['ID']['input']; }>; -export type GetServiceQuery = { __typename?: 'Query', service?: { __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; +export type GetServiceQuery = { __typename?: 'Query', service?: { __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; export type CreateServiceMutationVariables = Exact<{ data: ServiceInput; }>; -export type CreateServiceMutation = { __typename?: 'Mutation', createService?: { __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; +export type CreateServiceMutation = { __typename?: 'Mutation', createService?: { __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; export type UpdateServiceMutationVariables = Exact<{ documentId: Scalars['ID']['input']; @@ -726,7 +727,7 @@ export type UpdateServiceMutationVariables = Exact<{ }>; -export type UpdateServiceMutation = { __typename?: 'Mutation', updateService?: { __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; +export type UpdateServiceMutation = { __typename?: 'Mutation', updateService?: { __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; export type SlotFieldsFragment = { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined }; @@ -749,14 +750,14 @@ export type GetSlotsOrdersQueryVariables = Exact<{ }>; -export type GetSlotsOrdersQuery = { __typename?: 'Query', slots: Array<{ __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, orders: Array<{ __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined>, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined> }; +export type GetSlotsOrdersQuery = { __typename?: 'Query', slots: Array<{ __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, orders: Array<{ __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined>, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined> }; export type GetSlotQueryVariables = Exact<{ documentId: Scalars['ID']['input']; }>; -export type GetSlotQuery = { __typename?: 'Query', slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, orders: Array<{ __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name?: string | null | undefined, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined>, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; +export type GetSlotQuery = { __typename?: 'Query', slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, orders: Array<{ __typename?: 'Order', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Order_State | null | undefined, order_number?: number | null | undefined, services: Array<{ __typename?: 'Service', documentId: string, name: string, active?: boolean | null | undefined, duration: string, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined>, client?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined, slot?: { __typename?: 'Slot', documentId: string, datetime_start: string, datetime_end: string, state?: Enum_Slot_State | null | undefined, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined } | null | undefined>, master?: { __typename?: 'Customer', active?: boolean | null | undefined, documentId: string, name: string, phone: string, photoUrl?: string | null | undefined, role: Enum_Customer_Role, telegramId?: number | null | undefined } | null | undefined } | null | undefined }; export type UpdateSlotMutationVariables = Exact<{ documentId: Scalars['ID']['input']; @@ -784,7 +785,7 @@ export const GetCustomerDocument = {"kind":"Document","definitions":[{"kind":"Op export const GetMastersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetMasters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"phone"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Long"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"phone"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"phone"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"telegramId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"documentId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"masters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}}]} as unknown as DocumentNode; export const GetClientsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetClients"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"phone"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Long"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"or"},"value":{"kind":"ListValue","values":[{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"phone"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"phone"}}}]}}]},{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"telegramId"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"telegramId"}}}]}}]}]}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"clients"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}}]} as unknown as DocumentNode; export const UpdateCustomerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateCustomer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CustomerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCustomer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"documentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}}]} as unknown as DocumentNode; -export const GetOrdersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderFiltersInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationArg"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ListValue","values":[{"kind":"StringValue","value":"slot.datetime_start:desc","block":false},{"kind":"StringValue","value":"datetime_start:asc","block":false}]}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OrderFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServiceFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Service"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SlotFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Slot"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OrderFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Order"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"order_number"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServiceFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"slot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SlotFields"}}]}}]}}]} as unknown as DocumentNode; +export const GetOrdersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filters"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderFiltersInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"PaginationArg"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"orders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filters"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filters"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}},{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OrderFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServiceFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Service"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SlotFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Slot"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OrderFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Order"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"order_number"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServiceFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"slot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SlotFields"}}]}}]}}]} as unknown as DocumentNode; export const GetOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"documentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OrderFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServiceFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Service"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SlotFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Slot"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OrderFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Order"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"order_number"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServiceFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"slot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SlotFields"}}]}}]}}]} as unknown as DocumentNode; export const CreateOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOrder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OrderFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServiceFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Service"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SlotFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Slot"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OrderFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Order"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"order_number"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServiceFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"slot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SlotFields"}}]}}]}}]} as unknown as DocumentNode; export const UpdateOrderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOrder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"data"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"OrderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOrder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"documentId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"documentId"}}},{"kind":"Argument","name":{"kind":"Name","value":"data"},"value":{"kind":"Variable","name":{"kind":"Name","value":"data"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"OrderFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"CustomerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Customer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"phone"}},{"kind":"Field","name":{"kind":"Name","value":"photoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"telegramId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServiceFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Service"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"active"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"SlotFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Slot"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"master"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"OrderFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Order"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"documentId"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_start"}},{"kind":"Field","name":{"kind":"Name","value":"datetime_end"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"order_number"}},{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServiceFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"client"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"CustomerFields"}}]}},{"kind":"Field","name":{"kind":"Name","value":"slot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"SlotFields"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/graphql/utils/jwt.test.ts b/packages/graphql/utils/jwt.test.ts index 34408ef..77fbbe4 100644 --- a/packages/graphql/utils/jwt.test.ts +++ b/packages/graphql/utils/jwt.test.ts @@ -1,5 +1,5 @@ /* eslint-disable unicorn/consistent-function-scoping */ -import { isTokenExpired } from './jwt.js'; +import { isTokenExpired } from './jwt'; import * as jwt from 'jsonwebtoken'; import { afterEach, describe, expect, it, vi } from 'vitest'; diff --git a/packages/graphql/vitest.config.mts b/packages/graphql/vitest.config.mts new file mode 100644 index 0000000..3f22771 --- /dev/null +++ b/packages/graphql/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + exclude: ['**/e2e/**', '**/*.spec.ts', '**/node_modules/**'], + include: ['**/*.test.{js,ts}'], + }, +}); diff --git a/packages/utils/src/datetime-format.ts b/packages/utils/src/datetime-format.ts index 93c75af..7be0d27 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) { - const nowUtc = dayjs().tz(DEFAULT_TZ); - const inputDateUtc = dayjs(date).tz(DEFAULT_TZ); +/** + * 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 inputDateUtc.isBefore(nowUtc, 'day'); + return inputDate.isBefore(now, unit); } -export function isTodayOrAfter(date: Date | string) { - return !isBeforeToday(date); +export function isNowOrAfter(date: Date | string, unit: OpUnitType = 'day') { + return !isBeforeNow(date, unit); } export function sumTime(datetime: DateTime, durationMinutes: number) {