vchikalkin e6c823570c feat(localization): update Pro access terminology and enhance subscription messages
- Replaced instances of "подписка" with "доступ" to clarify Pro access terminology.
- Updated subscription-related messages for improved user understanding and consistency.
- Enhanced command list and bot responses to reflect changes in Pro access messaging.
2025-09-17 12:44:21 +03:00

453 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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