Vlad Chikalkin 2836153887
Feature/caching (#117)
* Refactor GraphQL client usage in services to improve consistency

- Replaced direct calls to `getClientWithToken` with a new method `getGraphQLClient` across multiple services, ensuring a unified approach to obtaining the GraphQL client.
- Updated the `BaseService` to manage the GraphQL client instance, enhancing performance by reusing the client when available.

* Refactor UpdateProfile component to use useClientOnce for initial profile update

- Replaced useEffect with useClientOnce to handle the first login profile update more efficiently.
- Removed local state management for hasUpdated, simplifying the component logic.
- Updated localStorage handling to ensure the profile update occurs only on the first login.

* Refactor Apollo Client setup to improve modularity and maintainability

- Introduced a new `createLink` function to encapsulate the link creation logic for Apollo Client.
- Updated `createApolloClient` to utilize the new `createLink` function, enhancing code organization.
- Simplified the handling of authorization headers by moving it to the link configuration.

* Add cache-proxy application with initial setup and configuration

- Created a new cache-proxy application using NestJS, including essential files such as Dockerfile, .gitignore, and .eslintrc.js.
- Implemented core application structure with AppModule, ProxyModule, and ProxyController for handling GraphQL requests.
- Configured caching with Redis and established environment variable management using Zod for validation.
- Added utility functions for query handling and time management, enhancing the application's functionality.
- Included README.md for project documentation and setup instructions.

* Add cache-proxy service to Docker configurations and update deployment workflow</message>

<message>
- Introduced a new cache-proxy service in both `docker-compose.dev.yml` and `docker-compose.yml`, with dependencies on Redis and integration into the web and bot services.
- Updated GitHub Actions workflow to include build and push steps for the cache-proxy image, ensuring it is deployed alongside web and bot services.
- Modified environment variable management to accommodate the cache-proxy, enhancing the overall deployment process.
- Adjusted GraphQL cached URL to point to the cache-proxy service for improved request handling.

* Add health check endpoint and controller to cache-proxy service

- Implemented a new HealthController in the cache-proxy application to provide a health check endpoint at `/api/health`, returning a simple status response.
- Updated the AppModule to include the HealthController, ensuring it is registered within the application.
- Configured a health check in the Docker Compose file for the cache-proxy service, allowing for automated health monitoring.

* Update proxy controller and environment variable for cache-proxy service

- Changed the route prefix of the ProxyController from `/proxy` to `/api` to align with the new API structure.
- Updated the default value of the `URL_GRAPHQL_CACHED` environment variable to reflect the new route, ensuring proper integration with the GraphQL service.

* Update cache proxy configuration for query time-to-live settings

- Increased the time-to-live for `GetCustomer`, `GetOrder`, `GetService`, and `GetSlot` queries to 24 hours, enhancing cache efficiency and performance.
- Maintained the existing setting for `GetSubscriptions`, which remains at 12 hours.

* Enhance subscription management and configuration settings

- Added new query time-to-live settings for `GetSlotsOrders`, `GetSubscriptionPrices`, and `GetSubscriptions` to improve caching strategy.
- Implemented `hasTrialSubscription` method in `SubscriptionsService` to check for trial subscriptions based on user history, enhancing subscription management capabilities.
- Updated GraphQL operations to reflect the change from `getSubscriptionSettings` to `GetSubscriptionSettings`, ensuring consistency in naming conventions.

* fix build

* Refactor subscription settings naming

- Updated the naming of `getSubscriptionSettings` to `GetSubscriptionSettings` in the cache-proxy configuration for consistency with other settings.
2025-10-06 23:26:03 +03:00

452 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 { 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 this.getGraphQLClient();
const mutationResult = await mutate({
mutation: GQL.CreateOrderDocument,
variables: {
...variables,
input: {
...variables.input,
datetime_end: datetimeEnd,
state: isSlotMaster ? GQL.Enum_Order_State.Approved : GQL.Enum_Order_State.Created,
},
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async getOrder(variables: VariablesOf<typeof GQL.GetOrderDocument>) {
await this.checkIsBanned();
const { query } = await this.getGraphQLClient();
const result = await query({
query: GQL.GetOrderDocument,
variables,
});
return result.data;
}
async getOrders(variables: VariablesOf<typeof GQL.GetOrdersDocument>) {
await this.checkIsBanned();
const { query } = await this.getGraphQLClient();
const result = await query({
query: GQL.GetOrdersDocument,
variables: {
sort: DEFAULT_ORDERS_SORT,
...variables,
},
});
return result.data;
}
async updateOrder(variables: VariablesOf<typeof GQL.UpdateOrderDocument>) {
await this.checkIsBanned();
await this.checkUpdatePermission(variables);
await this.checkBeforeUpdate(variables);
const { customer } = await this._getUser();
const { query } = await this.getGraphQLClient();
const {
data: { order },
} = await query({
query: GQL.GetOrderDocument,
variables: { documentId: variables.documentId },
});
if (!order) throw new Error(ERRORS.MISSING_ORDER);
const isOrderClient = order.client?.documentId === customer.documentId;
const isOrderMaster = order.slot?.master?.documentId === customer.documentId;
if (!isOrderClient && !isOrderMaster) throw new Error(SHARED_ERRORS.NO_PERMISSION);
if (isOrderClient && Object.keys(variables.data).length > 1)
throw new Error(SHARED_ERRORS.NO_PERMISSION);
if (
isOrderClient &&
variables.data.state &&
variables.data.state !== GQL.Enum_Order_State.Cancelling
) {
throw new Error(SHARED_ERRORS.NO_PERMISSION);
}
const { mutate } = await this.getGraphQLClient();
const lastOrderNumber = await this.getLastOrderNumber(variables);
const mutationResult = await mutate({
mutation: GQL.UpdateOrderDocument,
variables: {
...variables,
data: {
...variables.data,
order_number: lastOrderNumber ? lastOrderNumber + 1 : undefined,
},
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
private async checkBeforeCreate(variables: VariablesOf<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;
}
}