Vlad Chikalkin 81fa32c3d2
Release/rc 1 (#87)
* feat(profile): implement local hooks for profile and service data editing

- Added `useProfileEdit` and `useServiceEdit` hooks to manage pending changes and save functionality for profile and service data cards.
- Updated `ProfileDataCard` and `ServiceDataCard` components to utilize these hooks, enhancing user experience with save and cancel options.
- Introduced buttons for saving and canceling changes, improving the overall interactivity of the forms.
- Refactored input handling to use `updateField` for better state management.

* feat(bot): integrate Redis and update bot configuration

- Added Redis service to both docker-compose files for local development and production environments.
- Updated bot configuration to utilize the Grammy framework, replacing Telegraf.
- Implemented graceful shutdown for the bot, ensuring proper resource management.
- Refactored bot commands and removed deprecated message handling logic.
- Enhanced environment variable management for Redis connection settings.
- Updated dependencies in package.json to include new Grammy-related packages.

* fix(registration): improve error handling for customer creation

- Updated error handling in the registration feature to return a generic error message when documentId is not present, enhancing user experience by providing clearer feedback.

* feat(bot): add unhandled command message and integrate unhandled feature

- Introduced a new message for unhandled commands in Russian localization to improve user feedback.
- Integrated the unhandled feature into the bot's middleware for better command handling.

* feat(locales): update Russian localization with additional contact information

- Enhanced the short description in the Russian localization file to include a contact note for user inquiries, improving user support accessibility.

* feat(help): enhance help command with support information

- Updated the help command to include a support message in the Russian localization, providing users with a contact point for inquiries.
- Improved the command response by combining the list of available commands with the new support information, enhancing user experience.

* fix(orders): update default sorting order for orders

- Changed the default sorting order for orders from 'datetime_start:asc' to 'datetime_start:desc' to ensure the most recent orders are displayed first, improving the user experience in order management.

* refactor(orders): remove ClientsOrdersList and streamline OrdersList component

- Eliminated the ClientsOrdersList component to simplify the orders page structure.
- Updated OrdersList to handle both client and master views, enhancing code reusability.
- Improved order fetching logic and UI rendering for better performance and user experience.

* fix(order-form): hide next button on success & error pages

* refactor(bot): streamline bot middleware and improve key generator function

- Removed unused session middleware and sequentialize function from the bot's error boundary.
- Simplified the key generator function for rate limiting by condensing its implementation.
- Enhanced overall code clarity and maintainability in the bot's configuration.

* feat(customer): implement banned customer check and enhance customer data handling

- Added `isCustomerBanned` function to determine if a customer is banned based on the `bannedUntil` field.
- Updated the `BaseService` to throw an error if a banned customer attempts to access certain functionalities.
- Enhanced the GraphQL operations to include the `bannedUntil` field in customer queries and mutations, improving data integrity and user experience.
- Integrated the `CheckBanned` component in the layout to manage banned customer states effectively.

* feat(ban-system): implement multi-level user ban checks across services

- Added a comprehensive ban checking system to prevent access for banned users at multiple levels, including database, API, and client-side.
- Introduced `bannedUntil` field in the customer model to manage temporary and permanent bans effectively.
- Enhanced `BaseService` and various service classes to include ban checks, ensuring that banned users cannot perform actions or access data.
- Updated error handling to provide consistent feedback for banned users across the application.
- Improved user experience with a dedicated ban check component and a user-friendly ban notification page.

* packages(apps/web): upgrade next@15.5.0
2025-08-26 13:23:52 +03:00

436 lines
14 KiB
TypeScript
Raw Permalink 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 { type VariablesOf } from '@graphql-typed-document-node/core';
import { isCustomerMaster } from '@repo/utils/customer';
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: 'Время заказа выходит за пределы слота',
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:desc'];
export class OrdersService extends BaseService {
async createOrder(variables: VariablesOf<typeof GQL.CreateOrderDocument>) {
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 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 { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateOrderDocument,
variables: {
...variables,
input: {
...variables.input,
datetime_end: datetimeEnd,
state: isCustomerMaster(customer)
? 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>) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetOrderDocument,
variables,
});
return result.data;
}
async getOrders(variables: VariablesOf<typeof GQL.GetOrdersDocument>) {
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.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 (
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<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;
}
}