vchikalkin 7c1f79db2a 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.
2025-08-26 12:32:22 +03:00

307 lines
9.3 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 @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 { ServicesService } from './services';
import { type VariablesOf } from '@graphql-typed-document-node/core';
import { getMinutes, isBeforeNow } from '@repo/utils/datetime-format';
import dayjs from 'dayjs';
export const ERRORS = {
INACTIVE_MASTER: 'Пользователь не является активным или мастером',
INVALID_TIME: 'Некорректное время',
MISSING_DATETIME_END: 'Не указана дата окончания',
MISSING_DATETIME_START: 'Не указана дата начала',
MISSING_SERVICES_IDS: 'Не указаны услуги',
NO_PAST_SLOT: 'Нельзя создать слот в прошлом',
NOT_FOUND_MASTER: 'Мастер не найден',
NOT_FOUND_SERVICE: 'Сервис не найден',
NOT_FOUND_SLOT: 'Слот не найден',
OVERLAPPING_TIME: 'Время пересекается с другими слотами',
SLOT_HAS_ORDERS: 'Слот имеет активные заказы',
};
export class SlotsService extends BaseService {
async createSlot(variables: VariablesOf<typeof GQL.CreateSlotDocument>) {
await this.checkIsBanned();
await this.checkBeforeCreate(variables);
const { customer } = await this._getUser();
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.CreateSlotDocument,
variables: {
...variables,
input: {
...variables.input,
master: customer?.documentId,
},
},
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async deleteSlot(variables: VariablesOf<typeof GQL.DeleteSlotDocument>) {
await this.checkIsBanned();
await this.checkPermission(variables);
const { slot } = await this.getSlot({ documentId: variables.documentId });
if (slot?.orders?.length) {
throw new Error(ERRORS.SLOT_HAS_ORDERS);
}
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.DeleteSlotDocument,
variables,
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
async getAvailableTimeSlots(
variables: VariablesOf<typeof GQL.GetSlotsDocument>,
context: { services: string[] },
) {
await this.checkIsBanned();
if (!variables.filters?.datetime_start) throw new Error(ERRORS.MISSING_DATETIME_START);
if (!context?.services?.length) throw new Error(ERRORS.MISSING_SERVICES_IDS);
const servicesService = new ServicesService(this._user);
// Получаем все услуги по массиву id`
const services: Array<{ duration: string }> = [];
for (const serviceId of context.services) {
const { service } = await servicesService.getService({
documentId: serviceId,
});
if (!service) throw new Error(ERRORS.NOT_FOUND_SERVICE);
services.push(service);
}
// Суммируем длительности всех услуг
const totalServiceDuration = services.reduce(
(sum, service) => sum + getMinutes(service.duration),
0,
);
const { query } = await getClientWithToken();
const getSlotsResult = await query({
query: GQL.GetSlotsOrdersDocument,
variables: {
filters: {
...variables.filters,
state: { eq: GQL.Enum_Slot_State.Open },
},
},
});
if (getSlotsResult.error) throw new Error(getSlotsResult.error.message);
const slots = getSlotsResult.data.slots;
const times: Array<{ slotId: string; time: string }> = [];
const now = dayjs();
for (const slot of slots) {
if (!slot?.datetime_start || !slot?.datetime_end) continue;
let datetimeStart = dayjs(slot.datetime_start);
const datetimeEnd = dayjs(slot.datetime_end).subtract(totalServiceDuration, 'minutes');
while (datetimeStart.valueOf() <= datetimeEnd.valueOf()) {
const slotStartTime = datetimeStart;
const potentialDatetimeEnd = datetimeStart.add(totalServiceDuration, 'minutes');
const hasConflict = slot.orders.some(
(order) =>
order?.state &&
![GQL.Enum_Order_State.Cancelled].includes(order.state) &&
slotStartTime.isBefore(dayjs(order.datetime_end)) &&
potentialDatetimeEnd.isAfter(dayjs(order.datetime_start)),
);
if (!hasConflict && datetimeStart.isAfter(now, 'minute')) {
times.push({ slotId: slot.documentId, time: datetimeStart.toISOString() });
}
datetimeStart = datetimeStart.add(15, 'minutes');
}
}
return { slots, times };
}
async getSlot(variables: VariablesOf<typeof GQL.GetSlotDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetSlotDocument,
variables,
});
return result.data;
}
async getSlots(variables: VariablesOf<typeof GQL.GetSlotsDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetSlotsDocument,
variables,
});
return result.data;
}
async updateSlot(variables: VariablesOf<typeof GQL.UpdateSlotDocument>) {
await this.checkIsBanned();
await this.checkPermission(variables);
await this.checkBeforeUpdateDatetime(variables);
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
mutation: GQL.UpdateSlotDocument,
variables,
});
const error = mutationResult.errors?.at(0);
if (error) throw new Error(error.message);
return mutationResult.data;
}
private async checkBeforeCreate(variables: VariablesOf<typeof GQL.CreateSlotDocument>) {
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<typeof GQL.UpdateSlotDocument>) {
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<VariablesOf<typeof GQL.GetSlotDocument>, 'documentId'>,
) {
const { customer } = await this._getUser();
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(SHARED_ERRORS.NO_PERMISSION);
}
}