Compare commits

..

4 Commits

231 changed files with 2960 additions and 16485 deletions

View File

@ -1,43 +1,19 @@
name: Build & Deploy Web, Bot & Cache Proxy
name: Build & Deploy Web & Bot
on:
push:
branches:
- main
workflow_dispatch:
jobs:
build-and-push:
name: Build and Push to Docker Hub
runs-on: ubuntu-latest
outputs:
web_tag: ${{ steps.vars.outputs.web_tag }}
bot_tag: ${{ steps.vars.outputs.bot_tag }}
cache_proxy_tag: ${{ steps.vars.outputs.cache_proxy_tag }}
# Добавляем output-ы для отслеживания, какие проекты были собраны
web_built: ${{ steps.filter.outputs.web }}
bot_built: ${{ steps.filter.outputs.bot }}
cache_proxy_built: ${{ steps.filter.outputs.cache_proxy }}
steps:
- name: Checkout code
uses: actions/checkout@v3
# --- НОВОЕ: Шаг 1: dorny/paths-filter для условной сборки ---
- name: Filter changed paths
uses: dorny/paths-filter@v2
id: filter
with:
filters: |
web:
- 'apps/web/**'
- 'packages/**'
bot:
- 'apps/bot/**'
- 'packages/**'
cache_proxy:
- 'apps/cache-proxy/**'
# -----------------------------------------------------------
- name: Create .env file for build
- name: Create fake .env file for build
run: |
echo "BOT_TOKEN=fake" > .env
echo "LOGIN_GRAPHQL=fake" >> .env
@ -46,54 +22,25 @@ jobs:
echo "EMAIL_GRAPHQL=fake@example.com" >> .env
echo "NEXTAUTH_SECRET=fakesecret" >> .env
echo "BOT_URL=http://localhost:3000" >> .env
echo "REDIS_PASSWORD=fake" >> .env
echo "BOT_PROVIDER_TOKEN=fake" >> .env
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
- name: Set image tags
id: vars
run: |
echo "web_tag=web-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "bot_tag=bot-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "cache_proxy_tag=cache-proxy-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
# --- ИЗМЕНЕНО: Условное выполнение Build/Push ---
- name: Build web image
if: steps.filter.outputs.web == 'true'
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }} -f ./apps/web/Dockerfile .
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:latest -f ./apps/web/Dockerfile .
- name: Push web image to Docker Hub
if: steps.filter.outputs.web == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:${{ steps.vars.outputs.web_tag }}
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-web:latest
- name: Build bot image
if: steps.filter.outputs.bot == 'true'
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }} -f ./apps/bot/Dockerfile .
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:latest -f ./apps/bot/Dockerfile .
- name: Push bot image to Docker Hub
if: steps.filter.outputs.bot == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:${{ steps.vars.outputs.bot_tag }}
- name: Build cache-proxy image
if: steps.filter.outputs.cache_proxy == 'true'
run: |
docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }} -f ./apps/cache-proxy/Dockerfile .
- name: Push cache-proxy image to Docker Hub
if: steps.filter.outputs.cache_proxy == 'true'
run: |
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-cache-proxy:${{ steps.vars.outputs.cache_proxy_tag }}
# -------------------------------------------------
docker push ${{ secrets.DOCKERHUB_USERNAME }}/zapishis-bot:latest
deploy:
name: Deploy to VPS
@ -115,10 +62,8 @@ jobs:
run: |
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "mkdir -p /home/${{ secrets.VPS_USER }}/zapishis"
# --- НОВОЕ: Шаг 2: Создание основного .env БЕЗ ТЕГОВ ---
- name: Create .env file for deploy
- name: Create real .env file for production
run: |
# Включаем все секреты, КРОМЕ тегов
echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env
echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env
echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env
@ -126,26 +71,8 @@ jobs:
echo "EMAIL_GRAPHQL=${{ secrets.EMAIL_GRAPHQL }}" >> .env
echo "NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}" >> .env
echo "BOT_URL=${{ secrets.BOT_URL }}" >> .env
echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> .env
echo "REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}" >> .env
echo "BOT_PROVIDER_TOKEN=${{ secrets.BOT_PROVIDER_TOKEN }}" >> .env
echo "SUPPORT_TELEGRAM_URL=${{ secrets.SUPPORT_TELEGRAM_URL }}" >> .env
echo "URL_OFFER=${{ secrets.URL_OFFER }}" >> .env
echo "URL_PRIVACY=${{ secrets.URL_PRIVACY }}" >> .env
echo "URL_FAQ=${{ secrets.URL_FAQ }}" >> .env
# --- НОВОЕ: Шаг 3: Создание файлов тегов (.project.env) ---
- name: Create Project Tag Env Files
run: |
# Создаем файлы, которые будут содержать только одну переменную с тегом
echo "WEB_IMAGE_TAG=${{ needs.build-and-push.outputs.web_tag }}" > .env.web
echo "BOT_IMAGE_TAG=${{ needs.build-and-push.outputs.bot_tag }}" > .env.bot
echo "CACHE_PROXY_IMAGE_TAG=${{ needs.build-and-push.outputs.cache_proxy_tag }}" > .env.cache-proxy
# --- Шаг 4: Копирование .env и УСЛОВНОЕ копирование тегов ---
# Копируем основной .env всегда
- name: Copy .env to VPS via SCP (Always)
- name: Copy .env to VPS via SCP
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
@ -155,42 +82,6 @@ jobs:
source: '.env'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.web ТОЛЬКО, если web был собран (обновляем тег на VPS)
- name: Copy .env.web to VPS
if: ${{ needs.build-and-push.outputs.web_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.web'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.bot ТОЛЬКО, если bot был собран
- name: Copy .env.bot to VPS
if: ${{ needs.build-and-push.outputs.bot_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.bot'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# Копируем .env.cache-proxy ТОЛЬКО, если cache-proxy был собран
- name: Copy .env.cache-proxy to VPS
if: ${{ needs.build-and-push.outputs.cache_proxy_built == 'true' }}
uses: appleboy/scp-action@master
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: '.env.cache-proxy'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
- name: Copy docker-compose.yml to VPS via SCP
uses: appleboy/scp-action@master
with:
@ -201,27 +92,12 @@ jobs:
source: 'docker-compose.yml'
target: '/home/${{ secrets.VPS_USER }}/zapishis/'
# --- ФИНАЛЬНЫЙ ДЕПЛОЙ ---
- name: Login and deploy on VPS
run: |
ssh -i ~/.ssh/id_rsa -p ${{ secrets.VPS_PORT }} -o StrictHostKeyChecking=no ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} "
cd /home/${{ secrets.VPS_USER }}/zapishis && \
# 1. Объединение ВСЕХ ENV-файлов в один основной .env
# Теги из .env.web/.env.bot переопределят любые старые/пустые значения,
# и .env станет полным и актуальным.
echo \"Merging environment files into .env...\" && \
cat .env .env.web .env.bot .env.cache-proxy > .temp_env && \
mv .temp_env .env && \
# 2. Логин
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
# 3. Pull ВСЕХ сервисов (Docker Compose автоматически использует обновленный .env)
echo \"Pulling all services...\" && \
docker compose pull
# 4. Перезапуск
docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} && \
docker compose pull && \
docker compose down && \
docker compose up -d
"

1
.gitignore vendored
View File

@ -36,4 +36,3 @@ yarn-error.log*
# Misc
.DS_Store
*.pem
*.cmd

View File

@ -11,7 +11,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apk add --no-cache libc6-compat && \
corepack enable && \
pnpm install turbo@2.3.2 dotenv-cli --global
pnpm install turbo dotenv-cli --global
FROM base AS pruner
ARG PROJECT

View File

@ -5,10 +5,5 @@ export default [
...typescript,
{
ignores: ['**/types/**', '*.config.*'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'unicorn/prevent-abbreviations': 'off',
'canonical/id-match': 'off',
},
},
];

View File

@ -1,148 +0,0 @@
# Общие
-support-contact = По всем вопросам и обратной связи: @v_dev_support
# Описание бота
short-description =
Запись к мастерам, тренерам и репетиторам на вашем смартфоне 📱📅
{ -support-contact }
description =
📲 Запишись.онлайн — это встроенное в Telegram приложение + бот для мастеров и тренеров в вашем смартфоне.
Возможности:
• 📅 Ведение графика и запись клиентов
• 👥 Клиентская база в одном месте
• 🔔 Уведомления о новых и предстоящих записях
• 🧑‍ Работа мастером или тренером прямо в Telegram
• 🚀 Создание записи на услугу в пару кликов
✨ Всё, что нужно — ваш смартфон.
{ -support-contact }
# Команды
start =
.description = Запуск бота
addcontact =
.description = Добавить контакт
sharebot =
.description = Поделиться ботом
subscribe =
.description = Приобрести Pro доступ
pro =
.description = Информация о вашем Pro доступе
help =
.description = Список команд и поддержка
commands-list =
📋 Доступные команды:
• /addcontact — добавить контакт
• /sharebot — поделиться ботом
• /subscribe — приобрести Pro доступ
• /pro — информация о вашем Pro доступе
• /help — список команд
Откройте приложение кнопкой "Открыть", чтобы отредактировать свой профиль или создать запись
support =
{ -support-contact }
documents =
.description = Документы
# Кнопки
btn-add-contact = 👤 Добавить контакт
btn-share-bot = 🤝 Поделиться ботом
btn-pro = 👑 Pro доступ
btn-subscribe = 👑 Приобрести Pro
btn-pro-info = Мой Pro доступ
btn-open-app = 📱 Открыть приложение
btn-faq = 📖 Инструкция
btn-documents = 📋 Документы
btn-back = ◀️ Назад
# Согласие
share-phone-agreement =
<i> Нажимая кнопку <b>«Отправить номер телефона»</b></i>,
<i>вы:
- соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
- подтверждаете согласие на обработку персональных данных согласно <a href='{ $privacyUrl }'>Политике конфиденциальности</a></i>
share-contact-agreement =
<i> Отправляя контакт, имя и номер телефона, вы подтверждаете, что имеете согласие этого человека на передачу его контактных данных и на их обработку в рамках нашего сервиса.
(Пункт 4.5 <a href='{ $privacyUrl }'>Политики конфиденциальности</a>)</i>
payment-agreement =
Совершая оплату, вы соглашаетесь с <a href='{ $offerUrl }'>Публичной офертой</a>
agreement-links =
<a href='{ $offerUrl }'>Публичная оферта</a>
<a href='{ $privacyUrl }'>Политика конфиденциальности</a>
# Приветственные сообщения
msg-welcome =
👋 Добро пожаловать!
Пожалуйста, поделитесь своим номером телефона для регистрации
msg-welcome-back = 👋 С возвращением, { $name }!
# Сообщения о телефоне
msg-need-phone = 📱 Чтобы добавить контакт, сначала поделитесь своим номером телефона.
msg-phone-saved =
✅ Спасибо! Мы сохранили ваш номер телефона
Теперь вы можете открыть приложение или воспользоваться командами бота
msg-already-registered =
✅ Вы уже зарегистрированы в системе
<i>Для смены номера телефона обратитесь в поддержку (Контакты в профиле бота)</i>
msg-invalid-phone = ❌ Некорректный номер телефона. Пример: +79999999999
# Сообщения о контактах
msg-send-client-contact = 👤 Отправьте контакт пользователя, которого вы хотите добавить.
msg-send-client-contact-or-phone = 👤 Отправьте контакт пользователя или введите его номер телефона в сообщении
msg-send-contact = Пожалуйста, отправьте контакт пользователя через кнопку Telegram
msg-send-client-name = ✍️ Введите имя пользователя одним сообщением
msg-send-client-surname = ✍️ Введите фамилию пользователя одним сообщением
msg-invalid-name = ❌ Некорректное имя. Попробуйте еще раз
msg-contact-added =
✅ Добавили { $fullname } в список ваших контактов
Пригласите пользователя в приложение, чтобы вы могли добавлять с ним записи
msg-contact-forward = <i>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</i>
# Сообщения для шаринга
msg-share-bot =
📅 Воспользуйтесь этим ботом для записи к вашему мастеру!
Нажмите кнопку ниже, чтобы начать
# Системные сообщения
msg-cancel = ❌ Операция отменена
msg-unhandled = ❓ Неизвестная команда. Попробуйте /start
msg-cancel-operation = <i>Для отмены операции используйте команду /cancel</i>
# Ошибки
err-generic = ⚠️ Что-то пошло не так. Попробуйте еще раз через несколько секунд
err-banned = 🚫 Ваш аккаунт заблокирован
err-with-details = ❌ Произошла ошибка
{ $error }
err-limit-exceeded = 🚫 Слишком много запросов! Подождите немного
err-missing-telegram-id = ❌ Telegram ID не найден
err-cannot-add-self = ❌ Нельзя добавить свой номер телефона как контакт
# Сообщения о доступе
msg-subscribe =
👑 Pro доступ
• Разблокирует неограниченное количество заказов
msg-subscribe-success = ✅ Платеж успешно обработан!
msg-subscribe-error = ❌ Произошла ошибка при обработке платежа
msg-subscription-inactive = 🔴 Pro доступ неактивен
msg-subscription-active = 🟢 Ваш Pro доступ активен
msg-subscription-active-until = 👑 Ваш Pro доступ активен до { $date }
msg-subscription-active-days = 👑 Осталось дней вашего Pro доступа: { $days }
msg-subscription-active-days-short = Осталось дней: { $days }
msg-subscription-expired =
Ваш Pro доступ истек.
Воспользуйтесь командой /subscribe, чтобы получить неограниченное количество заказов
msg-subscribe-disabled = 🟢 Pro доступ отключен для всех. Ограничения сняты! Наслаждайтесь полным доступом! 🎉
# Информация о лимитах
msg-remaining-orders-this-month = 🧾 Доступно заказов в этом месяце: { $count }

View File

@ -12,27 +12,10 @@
"lint-staged": "lint-staged"
},
"dependencies": {
"@grammyjs/auto-chat-action": "^0.1.1",
"@grammyjs/commands": "^1.2.0",
"@grammyjs/conversations": "^2.1.0",
"@grammyjs/hydrate": "^1.6.0",
"@grammyjs/i18n": "^1.1.2",
"@grammyjs/menu": "^1.3.1",
"@grammyjs/parse-mode": "^2.2.0",
"@grammyjs/ratelimiter": "^1.2.1",
"@grammyjs/runner": "^2.0.3",
"@grammyjs/storage-redis": "^2.5.1",
"@grammyjs/types": "^3.22.1",
"@repo/graphql": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "catalog:",
"dayjs": "catalog:",
"grammy": "^1.38.1",
"ioredis": "^5.7.0",
"libphonenumber-js": "^1.12.24",
"pino": "^9.9.0",
"pino-pretty": "^13.1.1",
"radashi": "catalog:",
"telegraf": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"zod": "catalog:"

View File

@ -1,14 +0,0 @@
import { type AutoChatActionFlavor } from '@grammyjs/auto-chat-action';
import { type CommandsFlavor } from '@grammyjs/commands';
import { type ConversationFlavor } from '@grammyjs/conversations';
import { type HydrateFlavor } from '@grammyjs/hydrate';
import { type I18nFlavor } from '@grammyjs/i18n';
import { type Context as DefaultContext, type SessionFlavor } from 'grammy';
export type Context = ConversationFlavor<
HydrateFlavor<
AutoChatActionFlavor & CommandsFlavor & DefaultContext & I18nFlavor & SessionFlavor<SessionData>
>
>;
export type SessionData = {};

View File

@ -1,156 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable id-length */
import { type Context } from '@/bot/context';
import { env } from '@/config/env';
import { KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { parseContact } from '@/utils/contact';
import { combine } from '@/utils/messages';
import { type Conversation } from '@grammyjs/conversations';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration';
import parsePhoneNumber from 'libphonenumber-js';
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
// Все пользователи могут добавлять контакты
const telegramId = ctx.from?.id;
if (!telegramId) {
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
}
const registrationService = new RegistrationService();
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
if (!customer) {
return ctx.reply(
await conversation.external(({ t }) =>
combine(
t('msg-need-phone'),
t('share-phone-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
),
{ ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' },
);
}
// Просим отправить контакт или номер телефона
await ctx.reply(
await conversation.external(({ t }) =>
combine(
t('msg-send-client-contact-or-phone'),
t('msg-cancel-operation'),
t('share-contact-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
),
{
parse_mode: 'HTML',
},
);
// Ждём первое сообщение: контакт или текст с номером
const firstCtx = await conversation.wait();
let name = '';
let surname = '';
let phone = '';
if (firstCtx.message?.contact) {
/**
* Отправлен контакт
*/
const { contact } = firstCtx.message;
const parsedContact = parseContact(contact);
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
name = parsedContact.name;
surname = parsedContact.surname;
if (!parsedPhone?.isValid() || !parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
phone = parsedPhone.number;
} else if (firstCtx.message?.text) {
/**
* Номер в тексте сообщения
*/
const parsedPhone = parsePhoneNumber(firstCtx.message.text, 'RU');
if (!parsedPhone?.isValid() || !parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-phone')));
}
// Нельзя добавлять свой собственный номер телефона
if (customer.phone && customer.phone === parsedPhone.number) {
return ctx.reply(await conversation.external(({ t }) => t('err-cannot-add-self')));
}
phone = parsedPhone.number;
// Просим ввести имя клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-name')));
const nameCtx = await conversation.wait();
const typedName = nameCtx.message?.text?.trim() || '';
if (!typedName) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-name')));
}
name = typedName;
// Просим ввести фамилию клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-surname')));
const surnameCtx = await conversation.wait();
const typedSurname = surnameCtx.message?.text?.trim() || '';
if (!typedSurname) {
return ctx.reply(await conversation.external(({ t }) => t('msg-invalid-surname')));
}
surname = typedSurname;
} else {
return ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact-or-phone')));
}
try {
// Проверяем, есть ли клиент с таким номером
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({
phone,
});
let documentId = existingCustomer?.documentId;
// Если клиента нет, создаём нового
if (!documentId) {
const createCustomerResult = await registrationService.createCustomer({
data: { name, phone, surname },
});
documentId = createCustomerResult?.createCustomer?.documentId;
if (!documentId) throw new Error('Клиент не создан');
}
// Добавляем текущего пользователя к приглашенному
const invitedBy = [customer.documentId];
const customerService = new CustomersService({ telegramId });
await customerService.addInvitedBy({ data: { invitedBy }, documentId });
// Отправляем подтверждения и инструкции
await ctx.reply(
await conversation.external(({ t }) =>
t('msg-contact-added', { fullname: [name, surname].filter(Boolean).join(' ') }),
),
);
await ctx.reply(await conversation.external(({ t }) => t('msg-contact-forward')), {
parse_mode: 'HTML',
});
await ctx.reply(await conversation.external(({ t }) => t('msg-share-bot')), KEYBOARD_SHARE_BOT);
} catch (error) {
await ctx.reply(
await conversation.external(({ t }) => t('err-with-details', { error: String(error) })),
);
}
return conversation.halt();
}

View File

@ -1,2 +0,0 @@
export * from './add-contact';
export * from './subscription';

View File

@ -1,193 +0,0 @@
/* eslint-disable id-length */
import { type Context } from '@/bot/context';
import { env } from '@/config/env';
import { formatMoney } from '@/utils/format';
import { combine } from '@/utils/messages';
import { type Conversation } from '@grammyjs/conversations';
import { fmt, i } from '@grammyjs/parse-mode';
import { CustomersService } from '@repo/graphql/api/customers';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
import * as GQL from '@repo/graphql/types';
import { InlineKeyboard } from 'grammy';
import { sift } from 'radashi';
export async function subscription(conversation: Conversation<Context, Context>, ctx: Context) {
const telegramId = ctx.from?.id;
if (!telegramId) {
return replyError(ctx, conversation);
}
const subscriptionsService = new SubscriptionsService({ telegramId });
const {
hasActiveSubscription,
remainingDays,
subscription: currentSubscription,
} = await subscriptionsService.getSubscription({
telegramId,
});
const { subscriptionPrices } = await subscriptionsService.getSubscriptionPrices({
filters: {
active: {
eq: true,
},
period: {
ne: GQL.Enum_Subscriptionprice_Period.Trial,
},
},
});
const prices = sift(subscriptionPrices);
// строим клавиатуру с указанием даты окончания после покупки
const keyboard = buildPricesKeyboard(
prices,
currentSubscription?.expiresAt,
hasActiveSubscription,
);
// сообщение с выбором плана
const messageWithPrices = await ctx.reply(
combine(
await conversation.external(({ t }) => {
let statusLine = t('msg-subscribe');
if (hasActiveSubscription && currentSubscription?.expiresAt) {
statusLine = t('msg-subscription-active-until', {
date: new Date(currentSubscription.expiresAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
});
} else if (remainingDays) {
statusLine = t('msg-subscription-active-days', { days: remainingDays });
}
return combine(statusLine, fmt`${i}${t('msg-cancel-operation')}${i}`.text);
}),
),
{ parse_mode: 'HTML', reply_markup: keyboard },
);
// ждём выбора
const selectPlanWaitCtx = await conversation.wait();
// удаляем сообщение с выбором
try {
await ctx.api.deleteMessage(telegramId, messageWithPrices.message_id);
} catch {
/* игнорируем, если не удалось удалить */
}
const selectedPeriod = selectPlanWaitCtx.callbackQuery?.data;
if (!selectedPeriod) return replyError(ctx, conversation);
const selectedPrice = prices.find((price) => price?.period === selectedPeriod);
if (!selectedPrice) return replyError(ctx, conversation);
// создаём invoice (с указанием даты, до которой будет доступ)
const baseDate = currentSubscription?.expiresAt
? new Date(Math.max(Date.now(), new Date(currentSubscription.expiresAt).getTime()))
: new Date();
const targetDate = addDays(baseDate, selectedPrice.days ?? 0);
const targetDateRu = targetDate.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
const agreementText = await conversation.external(({ t }) => {
return t('payment-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
});
});
await ctx.reply(agreementText, {
parse_mode: 'HTML',
});
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
return ctx.replyWithInvoice(
'Оплата Pro доступа',
combine(
`${selectedPrice.description || 'Pro доступ'} — до ${targetDateRu}`,
'(Автопродление отключено)',
),
JSON.stringify({ period: selectedPrice.period }),
'RUB',
[
{
amount: selectedPrice.amount * 100, // Telegram ждёт в копейках
label: `${selectedPrice.description || 'К оплате'} — до ${targetDateRu}`,
},
],
{
protect_content: true,
provider_data: JSON.stringify({
receipt: {
customer: {
phone: customer?.phone.replaceAll(/\D/gu, ''),
},
items: [
{
amount: {
currency: 'RUB',
value: selectedPrice.amount,
},
description: selectedPrice.description || 'Pro доступ',
payment_mode: 'full_payment',
payment_subject: 'payment',
quantity: 1,
vat_code: 1,
},
],
tax_system_code: 1,
},
}),
provider_token: env.BOT_PROVIDER_TOKEN,
start_parameter: 'get_access',
},
);
}
// --- helpers ---
function addDays(date: Date, days: number) {
const d = new Date(date);
d.setDate(d.getDate() + days);
return d;
}
function buildPricesKeyboard(
prices: GQL.SubscriptionPriceFieldsFragment[],
currentExpiresAt?: string,
hasActiveSubscription = false,
) {
const keyboard = new InlineKeyboard();
const baseTime = currentExpiresAt
? Math.max(Date.now(), new Date(currentExpiresAt).getTime())
: Date.now();
for (const price of prices) {
const targetDate = addDays(new Date(baseTime), price.days ?? 0);
const targetDateRu = targetDate.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
keyboard.row({
callback_data: price.period,
pay: true,
text: `${hasActiveSubscription ? 'Продлить' : 'Доступ'} до ${targetDateRu} (${formatMoney(price.amount)})`,
});
}
return keyboard;
}
async function replyError(ctx: Context, conversation: Conversation<Context, Context>) {
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
}

View File

@ -1,12 +0,0 @@
import { handleAddContact } from '../handlers/add-contact';
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('addcontact', logHandle('command-add-contact'), handleAddContact);
export { composer as addContact };

View File

@ -1,12 +0,0 @@
import { handleDocuments } from '../handlers/documents';
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('documents', logHandle('command-documents'), handleDocuments);
export { composer as documents };

View File

@ -1,14 +0,0 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { mainMenu } from '@/config/keyboards';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('help', logHandle('command-help'), async (ctx) => {
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
});
export { composer as help };

View File

@ -1,8 +0,0 @@
export * from './add-contact';
export * from './documents';
export * from './help';
export * from './pro';
export * from './registration';
export * from './share-bot';
export * from './subscription';
export * from './welcome';

View File

@ -1,11 +0,0 @@
import { handlePro } from '../handlers/pro';
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('pro', logHandle('command-pro'), handlePro);
export { composer as pro };

View File

@ -1,81 +0,0 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_REMOVE, mainMenu } from '@/config/keyboards';
import { parseContact } from '@/utils/contact';
import { RegistrationService } from '@repo/graphql/api/registration';
import { Composer } from 'grammy';
import parsePhoneNumber from 'libphonenumber-js';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
// Обработка получения контакта от пользователя (регистрация или обновление)
feature.on(':contact', logHandle('contact-registration'), async (ctx) => {
const telegramId = ctx.from.id;
const { contact } = ctx.message;
const { name, surname } = parseContact(contact);
// Проверяем, не зарегистрирован ли уже пользователь
const registrationService = new RegistrationService();
const { customer: existingCustomer } = await registrationService._NOCACHE_GetCustomer({
telegramId,
});
if (existingCustomer) {
return ctx.reply(ctx.t('msg-already-registered'), {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
});
}
// Проверка наличия номера телефона
if (!contact.phone_number) {
return ctx.reply(ctx.t('msg-invalid-phone'));
}
// Нормализация и валидация номера
const parsedPhone = parsePhoneNumber(contact.phone_number, 'RU');
if (!parsedPhone?.isValid() || !parsedPhone?.number) {
return ctx.reply(ctx.t('msg-invalid-phone'));
}
try {
const { customer } = await registrationService._NOCACHE_GetCustomer({
phone: parsedPhone.number,
});
if (customer && !customer.telegramId) {
// Пользователь добавлен ранее мастером — обновляем данные
await registrationService.updateCustomer({
data: { active: true, name, surname, telegramId },
documentId: customer.documentId,
});
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE });
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
}
// Новый пользователь — создаём и активируем
const response = await registrationService.createCustomer({
data: { name, phone: parsedPhone.number, surname, telegramId },
});
const documentId = response?.createCustomer?.documentId;
if (!documentId) return ctx.reply(ctx.t('err-generic'));
await registrationService.updateCustomer({
data: { active: true },
documentId,
});
await ctx.reply(ctx.t('msg-phone-saved'), { ...KEYBOARD_REMOVE });
return ctx.reply(ctx.t('support'), { reply_markup: mainMenu });
} catch (error) {
return ctx.reply(ctx.t('err-with-details', { error: String(error) }));
}
});
export { composer as registration };

View File

@ -1,12 +0,0 @@
import { handleShareBot } from '../handlers/share-bot';
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('sharebot', logHandle('command-share-bot'), handleShareBot);
export { composer as shareBot };

View File

@ -1,48 +0,0 @@
import { handleSubscribe } from '../handlers/subscription';
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { logger } from '@/utils/logger';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
// Telegram требует отвечать на pre_checkout_query
composer.on('pre_checkout_query', logHandle('pre-checkout-query'), async (ctx) => {
await ctx.answerPreCheckoutQuery(true);
});
const feature = composer.chatType('private');
// команда для входа в flow подписки
feature.command('subscribe', logHandle('command-subscribe'), handleSubscribe);
// успешная оплата
feature.on(':successful_payment', logHandle('successful-payment'), async (ctx) => {
const telegramId = ctx.from.id;
const subscriptionsService = new SubscriptionsService({ telegramId });
try {
const rawPayload = ctx.message?.successful_payment.invoice_payload;
if (!rawPayload) throw new Error('Missing invoice payload');
const payload = JSON.parse(rawPayload);
const provider_payment_charge_id = ctx.message?.successful_payment?.provider_payment_charge_id;
const { formattedDate } = await subscriptionsService.createOrUpdateSubscription(
payload,
provider_payment_charge_id,
);
await ctx.reply(ctx.t('msg-subscribe-success'));
await ctx.reply(ctx.t('msg-subscription-active-until', { date: formattedDate }));
} catch (error) {
await ctx.reply(ctx.t('msg-subscribe-error'));
logger.error(
'Failed to process subscription after successful payment\n' + (error as Error)?.message,
);
}
});
export { composer as subscription };

View File

@ -1,17 +0,0 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.on('message', logHandle('unhandled-message'), (ctx) => {
return ctx.reply(ctx.t('msg-unhandled'));
});
feature.on('callback_query', logHandle('unhandled-callback-query'), (ctx) => {
return ctx.answerCallbackQuery();
});
export { composer as unhandledFeature };

View File

@ -1,42 +0,0 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { env } from '@/config/env';
import { KEYBOARD_SHARE_PHONE, mainMenu } from '@/config/keyboards';
import { combine } from '@/utils/messages';
import { RegistrationService } from '@repo/graphql/api/registration';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('start', logHandle('command-start'), async (ctx) => {
const telegramId = ctx.from.id;
const registrationService = new RegistrationService();
const { customer } = await registrationService._NOCACHE_GetCustomer({ telegramId });
if (customer) {
// Пользователь уже зарегистрирован — приветствуем
return ctx.reply(ctx.t('msg-welcome-back', { name: customer.name }), {
reply_markup: mainMenu,
});
}
// Новый пользователь — просим поделиться номером
return ctx.reply(
combine(
ctx.t('msg-welcome'),
ctx.t('share-phone-agreement', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
),
{
...KEYBOARD_SHARE_PHONE,
parse_mode: 'HTML',
},
);
});
export { composer as welcome };

View File

@ -1,7 +0,0 @@
import { type Context } from '@/bot/context';
async function handler(ctx: Context) {
await ctx.conversation.enter('addContact');
}
export { handler as handleAddContact };

View File

@ -1,18 +0,0 @@
import { type Context } from '@/bot/context';
import { env } from '@/config/env';
import { KEYBOARD_REMOVE } from '@/config/keyboards';
async function handler(ctx: Context) {
await ctx.reply(
ctx.t('agreement-links', {
offerUrl: env.URL_OFFER,
privacyUrl: env.URL_PRIVACY,
}),
{
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
},
);
}
export { handler as handleDocuments };

View File

@ -1,19 +0,0 @@
import { type Context } from '../context';
import { getUpdateInfo } from '../helpers/logging';
import { KEYBOARD_REMOVE } from '@/config/keyboards';
import { logger } from '@/utils/logger';
import { ERRORS } from '@repo/graphql/constants/errors';
import { type ErrorHandler } from 'grammy';
export const errorHandler: ErrorHandler<Context> = async (error) => {
const { ctx } = error;
const text = error.message.includes(ERRORS.NO_PERMISSION) ? 'err-banned' : 'err-generic';
await ctx.reply(ctx.t(text), { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
logger.error({
err: error.error,
update: getUpdateInfo(ctx),
});
};

View File

@ -1,5 +0,0 @@
export * from './add-contact';
export * from './documents';
export * from './pro';
export * from './share-bot';
export * from './subscription';

View File

@ -1,41 +0,0 @@
import { type Context } from '@/bot/context';
import { combine } from '@/utils/messages';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
async function handler(ctx: Context) {
const telegramId = ctx?.from?.id;
if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id'));
const subscriptionsService = new SubscriptionsService({ telegramId });
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
const proEnabled = subscriptionSetting?.proEnabled;
if (!proEnabled) {
await ctx.reply(ctx.t('msg-subscribe-disabled'));
}
const { hasActiveSubscription, remainingDays, remainingOrdersCount } =
await subscriptionsService.getSubscription({ telegramId });
if (hasActiveSubscription && remainingDays > 0) {
await ctx.reply(
combine(
ctx.t('msg-subscription-active'),
ctx.t('msg-subscription-active-days-short', { days: remainingDays }),
remainingDays === 0 ? ctx.t('msg-subscription-expired') : '',
),
);
} else {
await ctx.reply(
combine(
ctx.t('msg-subscription-inactive'),
ctx.t('msg-remaining-orders-this-month', { count: remainingOrdersCount }),
remainingOrdersCount === 0 ? ctx.t('msg-subscription-expired') : '',
),
);
}
}
export { handler as handlePro };

View File

@ -1,9 +0,0 @@
import { type Context } from '@/bot/context';
import { KEYBOARD_SHARE_BOT } from '@/config/keyboards';
async function handler(ctx: Context) {
await ctx.reply(ctx.t('msg-contact-forward'), { parse_mode: 'HTML' });
await ctx.reply(ctx.t('msg-share-bot'), { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
}
export { handler as handleShareBot };

View File

@ -1,22 +0,0 @@
import { type Context } from '@/bot/context';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
async function handler(ctx: Context) {
const telegramId = ctx?.from?.id;
if (!telegramId) throw new Error(ctx.t('err-missing-telegram-id'));
const subscriptionsService = new SubscriptionsService({ telegramId });
const { subscriptionSetting } = await subscriptionsService.getSubscriptionSettings();
const proEnabled = subscriptionSetting?.proEnabled;
if (proEnabled) {
await ctx.conversation.enter('subscription');
} else {
await ctx.reply(ctx.t('msg-subscribe-disabled'));
}
}
export { handler as handleSubscribe };

View File

@ -1,22 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { type Context } from '../context';
import { logger } from '@/utils/logger';
import { type Update } from '@grammyjs/types';
import { type Middleware } from 'grammy';
export function getUpdateInfo(context: Context): Omit<Update, 'update_id'> {
const { update_id, ...update } = context.update;
return update;
}
export function logHandle(id: string): Middleware<Context> {
return (context, next) => {
logger.info({
msg: `Handle "${id}"`,
...(id.startsWith('unhandled') ? { update: getUpdateInfo(context) } : {}),
});
return next();
};
}

View File

@ -1,14 +0,0 @@
import { type Context } from './context';
import { I18n } from '@grammyjs/i18n';
import path from 'node:path';
export const i18n = new I18n<Context>({
defaultLocale: 'ru',
directory: path.resolve(process.cwd(), 'locales'),
fluentBundleOptions: {
useIsolating: false,
},
useSession: true,
});
export const isMultipleLocales = i18n.locales.length > 1;

View File

@ -1,71 +0,0 @@
import { type Context } from './context';
import * as conversations from './conversations';
import * as features from './features';
import { unhandledFeature } from './features/unhandled';
import { errorHandler } from './handlers/errors';
import { i18n } from './i18n';
import * as middlewares from './middlewares';
import { setCommands, setInfo } from './settings';
import { env } from '@/config/env';
import { mainMenu } from '@/config/keyboards';
import { getRedisInstance } from '@/utils/redis';
import { autoChatAction, chatAction } from '@grammyjs/auto-chat-action';
import { createConversation, conversations as grammyConversations } from '@grammyjs/conversations';
import { hydrate } from '@grammyjs/hydrate';
import { limit } from '@grammyjs/ratelimiter';
import { Bot } from 'grammy';
type Parameters_ = {
token: string;
};
const redis = getRedisInstance();
export function createBot({ token }: Parameters_) {
const bot = new Bot<Context>(token);
bot.use(i18n);
bot.use(
limit({
keyGenerator: (ctx) => ctx.from?.id.toString(),
limit: env.RATE_LIMIT,
onLimitExceeded: async (ctx) => {
await ctx.reply(ctx.t('err-limit-exceeded'));
},
storageClient: redis,
timeFrame: env.RATE_LIMIT_TIME,
}),
);
bot.use(autoChatAction(bot.api));
bot.use(chatAction('typing'));
bot.use(grammyConversations()).command('cancel', async (ctx) => {
await ctx.conversation.exitAll();
await ctx.reply(ctx.t('msg-cancel'));
});
for (const conversation of Object.values(conversations)) {
bot.use(createConversation(conversation));
}
bot.use(mainMenu);
setInfo(bot);
setCommands(bot);
const protectedBot = bot.errorBoundary(errorHandler);
protectedBot.use(middlewares.updateLogger());
protectedBot.use(hydrate());
for (const feature of Object.values(features)) {
protectedBot.use(feature);
}
protectedBot.use(unhandledFeature);
return bot;
}

View File

@ -1,2 +0,0 @@
export * from './session';
export * from './update-logger';

View File

@ -1,20 +0,0 @@
import { type Context } from '@/bot/context';
import { TTL_SESSION } from '@/config/redis';
import { getRedisInstance } from '@/utils/redis';
import { getSessionKey } from '@/utils/session';
import { RedisAdapter } from '@grammyjs/storage-redis';
import { session as createSession, type Middleware } from 'grammy';
const storage = new RedisAdapter({
autoParseDates: true,
instance: getRedisInstance(),
ttl: TTL_SESSION,
});
export function session(): Middleware<Context> {
return createSession({
getSessionKey,
initial: () => ({}),
storage,
});
}

View File

@ -1,35 +0,0 @@
import { type Context } from '@/bot/context';
import { getUpdateInfo } from '@/bot/helpers/logging';
import { logger } from '@/utils/logger';
import { type Middleware } from 'grammy';
import { performance } from 'node:perf_hooks';
export function updateLogger(): Middleware<Context> {
return async (ctx, next) => {
ctx.api.config.use((previous, method, payload, signal) => {
logger.debug({
method,
msg: 'Bot API call',
payload,
});
return previous(method, payload, signal);
});
logger.debug({
msg: 'Update received',
update: getUpdateInfo(ctx),
});
const startTime = performance.now();
try {
return next();
} finally {
const endTime = performance.now();
logger.debug({
elapsed: endTime - startTime,
msg: 'Update processed',
});
}
};
}

View File

@ -1,47 +0,0 @@
import { type Context } from '@/bot/context';
import { i18n } from '@/bot/i18n';
import { Command, CommandGroup } from '@grammyjs/commands';
import { type LanguageCode } from '@grammyjs/types';
import { type Api, type Bot, type RawApi } from 'grammy';
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
const commands = createCommands([
'start',
'addcontact',
'sharebot',
'help',
'subscribe',
'pro',
'documents',
]);
for (const command of commands) {
addLocalizations(command);
}
const commandsGroup = new CommandGroup().add(commands);
await commandsGroup.setCommands({ api });
}
function addLocalizations(command: Command) {
for (const locale of i18n.locales) {
command.localize(
locale as LanguageCode,
command.name,
i18n.t(locale, `${command.name}.description`),
);
}
return command;
}
function createCommand(name: string) {
return new Command(name, i18n.t('en', `${name}.description`)).addToScope({
type: 'all_private_chats',
});
}
function createCommands(names: string[]) {
return names.map((name) => createCommand(name));
}

View File

@ -1,2 +0,0 @@
export * from './commands';
export * from './info';

View File

@ -1,10 +0,0 @@
import { type Context } from '../context';
import { i18n } from '../i18n';
import { type Api, type Bot, type RawApi } from 'grammy';
export async function setInfo({ api }: Bot<Context, Api<RawApi>>) {
for (const locale of i18n.locales) {
await api.setMyDescription(i18n.t(locale, 'description'));
await api.setMyShortDescription(i18n.t(locale, 'short-description'));
}
}

View File

@ -1,26 +1,9 @@
/* eslint-disable unicorn/prevent-abbreviations */
import { z } from 'zod';
export const envSchema = z.object({
BOT_PROVIDER_TOKEN: z.string(),
BOT_TOKEN: z.string(),
BOT_URL: z.string(),
RATE_LIMIT: z
.string()
.transform((value) => Number.parseInt(value, 10))
.default('2'),
RATE_LIMIT_TIME: z
.string()
.transform((value) => Number.parseInt(value, 10))
.default('3000'),
REDIS_HOST: z.string().default('redis'),
REDIS_PASSWORD: z.string(),
REDIS_PORT: z
.string()
.transform((value) => Number.parseInt(value, 10))
.default('6379'),
URL_FAQ: z.string(),
URL_OFFER: z.string(),
URL_PRIVACY: z.string(),
});
export const env = envSchema.parse(process.env);

View File

@ -1,73 +0,0 @@
import { env } from './env';
import { type Context } from '@/bot/context';
import {
handleAddContact,
handleDocuments,
handlePro,
handleShareBot,
handleSubscribe,
} from '@/bot/handlers';
import { Menu } from '@grammyjs/menu';
import {
type InlineKeyboardMarkup,
type ReplyKeyboardMarkup,
type ReplyKeyboardRemove,
} from '@grammyjs/types';
export const KEYBOARD_SHARE_PHONE = {
reply_markup: {
keyboard: [
[
{
request_contact: true,
text: ' Отправить номер телефона',
},
],
],
one_time_keyboard: true,
} as ReplyKeyboardMarkup,
};
export const KEYBOARD_REMOVE = {
reply_markup: {
remove_keyboard: true,
} as ReplyKeyboardRemove,
};
export const KEYBOARD_SHARE_BOT = {
reply_markup: {
inline_keyboard: [
[
{
text: ' Воспользоваться ботом',
url: env.BOT_URL + '?start=new',
},
],
],
} as InlineKeyboardMarkup,
};
// Главное меню
export const mainMenu = new Menu<Context>('main-menu', { autoAnswer: true })
.text((ctx) => ctx.t('btn-add-contact'), handleAddContact)
.row()
.text((ctx) => ctx.t('btn-subscribe'), handleSubscribe)
.text((ctx) => ctx.t('btn-pro-info'), handlePro)
.row()
.text((ctx) => ctx.t('btn-share-bot'), handleShareBot)
.row()
.text((ctx) => ctx.t('btn-documents'), handleDocuments)
.row()
.url(
(ctx) => ctx.t('btn-faq'),
() => env.URL_FAQ,
)
.row()
.url(
(ctx) => ctx.t('btn-open-app'),
() => {
const botUrl = new URL(env.BOT_URL);
botUrl.searchParams.set('startapp', '');
return botUrl.toString();
},
);

View File

@ -1 +0,0 @@
export const TTL_SESSION = 5 * 60; // 5 minutes in seconds

View File

@ -1,46 +1,276 @@
import { createBot } from './bot';
/* eslint-disable canonical/id-match */
/* eslint-disable consistent-return */
import { env as environment } from './config/env';
import { logger } from './utils/logger';
import { getRedisInstance } from './utils/redis';
import { run } from '@grammyjs/runner';
import {
commandsList,
KEYBOARD_REMOVE,
KEYBOARD_SHARE_BOT,
KEYBOARD_SHARE_PHONE,
MESSAGE_CANCEL,
MESSAGE_INVALID_PHONE,
MESSAGE_NOT_MASTER,
MESSAGE_SHARE_BOT,
MSG_ALREADY_MASTER,
MSG_BECOME_MASTER,
MSG_CONTACT_ADDED,
MSG_CONTACT_FORWARD,
MSG_ERROR,
MSG_NEED_PHONE,
MSG_PHONE_SAVED,
MSG_SEND_CLIENT_CONTACT,
MSG_WELCOME,
MSG_WELCOME_BACK,
} from './message';
import { isCustomerMaster } from './utils/customer';
import { isValidPhoneNumber, normalizePhoneNumber } from './utils/phone';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { Scenes, session, Telegraf, type Context as TelegrafContext } from 'telegraf';
import { message } from 'telegraf/filters';
import {
type SceneContextScene,
type SceneSession,
type WizardContextWizard,
type WizardSessionData,
} from 'telegraf/typings/scenes';
const bot = createBot({
token: environment.BOT_TOKEN,
// Расширяем контекст бота для работы с сценами и сессиями
type BotContext = TelegrafContext & {
scene: SceneContextScene<BotContext, WizardSessionData>;
session: SceneSession<WizardSessionData>;
wizard: WizardContextWizard<BotContext>;
};
// Создаём экземпляр бота с токеном
const bot = new Telegraf<BotContext>(environment.BOT_TOKEN);
// Создаём менеджер сцен и подключаем сессию
const stage = new Scenes.Stage<BotContext>();
bot.use(session({ defaultSession: () => ({ __scenes: { cursor: 0, state: {} } }) }));
bot.use(stage.middleware());
// Сцена добавления контакта клиента мастером
const addContactScene = new Scenes.WizardScene<BotContext>(
'add-contact',
// Шаг 1: Просим отправить контакт клиента
async (context) => {
await context.reply(MSG_SEND_CLIENT_CONTACT, { parse_mode: 'HTML' });
return context.wizard.next();
},
// Шаг 2: Обрабатываем полученный контакт
async (context) => {
if (!context.from) {
await context.reply('Ошибка: не удалось определить пользователя');
return context.scene.leave();
}
// Команда отмены — выход из сцены
if (context.message && 'text' in context.message && context.message.text === '/cancel') {
await context.reply(MESSAGE_CANCEL + commandsList, { parse_mode: 'HTML' });
return context.scene.leave();
}
// Проверяем, что отправлен контакт (через кнопку Telegram)
if (!context.message || !('contact' in context.message)) {
await context.reply('Пожалуйста, отправьте контакт клиента через кнопку Telegram');
return; // остаёмся в сцене, ждем корректный контакт
}
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
// Проверяем, что текущий пользователь — мастер
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer || !isCustomerMaster(customer)) {
await context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
return context.scene.leave();
}
const { contact } = context.message;
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
const phone = normalizePhoneNumber(contact.phone_number);
// Проверяем валидность номера телефона
if (!isValidPhoneNumber(phone)) {
await context.reply(MESSAGE_INVALID_PHONE, { parse_mode: 'HTML' });
return; // остаёмся в сцене, ждем правильный номер
}
try {
// Проверяем, есть ли клиент с таким номером
const { customer: existingCustomer } = await customerService.getCustomer({ phone });
let documentId = existingCustomer?.documentId;
// Если клиента нет, создаём нового
if (!documentId) {
const registrationService = new RegistrationService();
const createCustomerResult = await registrationService.createCustomer({ name, phone });
documentId = createCustomerResult?.createCustomer?.documentId;
if (!documentId) throw new Error('Клиент не создан');
}
// Добавляем текущего мастера к клиенту
const masters = [customer.documentId];
await customerService.addMasters({ data: { masters }, documentId });
// Отправляем подтверждения и инструкции
await context.reply(MSG_CONTACT_ADDED(name), { parse_mode: 'HTML' });
await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' });
await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
} catch (error) {
await context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
} finally {
await context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
context.scene.leave();
}
},
);
// Регистрируем сцену
stage.register(addContactScene);
// Команда /start — приветствие и запрос номера, если пользователь новый
bot.start(async (context) => {
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (customer) {
// Пользователь уже зарегистрирован — приветствуем
return context.reply(MSG_WELCOME_BACK(customer.name) + commandsList, {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
});
}
// Новый пользователь — просим поделиться номером
return context.reply(MSG_WELCOME, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
});
bot.catch((error) => {
logger.error('Grammy bot error:');
logger.error(`Message: ${error?.message}`);
logger.error(error.error);
// Команда /help — список команд
bot.command('help', async (context) => {
return context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
});
const runner = run(bot);
const redis = getRedisInstance();
// Команда /addcontact — начать сцену добавления контакта
bot.command('addcontact', async (context) => {
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
async function gracefulShutdown(signal: string) {
logger.info(`Received ${signal}, starting graceful shutdown...`);
if (!customer) {
// Нет номера — просим поделиться
return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
}
if (!isCustomerMaster(customer)) {
// Нет прав мастера
return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
}
// Входим в сцену
return context.scene.enter('add-contact');
});
// Команда /becomemaster — запрос статуса мастера
bot.command('becomemaster', async (context) => {
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) {
return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
}
if (isCustomerMaster(customer)) {
return context.reply(MSG_ALREADY_MASTER, { parse_mode: 'HTML' });
}
// Обновляем роль клиента на мастер
const response = await customerService
.updateCustomer({
data: { role: Enum_Customer_Role.Master },
})
.catch((error) => {
context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
});
if (response) {
return context.reply(MSG_BECOME_MASTER, { parse_mode: 'HTML' });
}
});
// Команда /sharebot — прислать ссылку на бота
bot.command('sharebot', async (context) => {
await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' });
await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
});
// Обработка получения контакта от пользователя (регистрация или обновление)
bot.on(message('contact'), async (context) => {
const telegramId = context.from.id;
const { contact } = context.message;
const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim();
// Проверка наличия номера телефона
if (!contact.phone_number) {
return context.reply(MESSAGE_INVALID_PHONE, { parse_mode: 'HTML' });
}
// Нормализация и валидация номера
const phone = normalizePhoneNumber(contact.phone_number);
if (!isValidPhoneNumber(phone)) {
return context.reply(MESSAGE_INVALID_PHONE, { parse_mode: 'HTML' });
}
const registrationService = new RegistrationService();
try {
await runner.stop();
logger.info('Bot stopped');
const { customer } = await registrationService.getCustomer({ phone });
redis.disconnect();
logger.info('Redis disconnected');
if (customer && !customer.telegramId) {
// Пользователь добавлен ранее мастером — обновляем данные
await registrationService.updateCustomer({
data: { active: true, name, telegramId },
documentId: customer.documentId,
});
return context.reply(MSG_PHONE_SAVED + commandsList, {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
});
}
// Новый пользователь — создаём и активируем
const response = await registrationService.createCustomer({ name, phone, telegramId });
const documentId = response?.createCustomer?.documentId;
if (!documentId) {
throw new Error('Не удалось создать клиента: отсутствует documentId');
}
await registrationService.updateCustomer({
data: { active: true },
documentId,
});
return context.reply(MSG_PHONE_SAVED + commandsList, {
...KEYBOARD_REMOVE,
parse_mode: 'HTML',
});
} catch (error) {
const err_ = error as Error;
logger.error('Error during graceful shutdown:' + err_.message || '');
return context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
}
}
process.once('SIGINT', () => gracefulShutdown('SIGINT'));
process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled Rejection: ' + reason);
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception: ' + error);
});
// Запуск бота
bot.launch();
logger.info('Bot started');
// Корректное завершение работы
process.once('SIGINT', () => bot.stop('SIGINT'));
process.once('SIGTERM', () => bot.stop('SIGTERM'));

83
apps/bot/src/message.ts Normal file
View File

@ -0,0 +1,83 @@
import { env as environment } from './config/env';
import { type ReplyKeyboardRemove } from 'telegraf/types';
export const commandsList = `
\n<b>📋 Доступные команды:</b>
<b>/addcontact</b> добавить контакт клиента
<b>/becomemaster</b> стать мастером
<b>/sharebot</b> поделиться ботом
<b>/help</b> список команд
\n
Откройте приложение кнопкой <b>"Открыть"</b>, чтобы отредактировать свой профиль или создать запись
`;
export const KEYBOARD_SHARE_PHONE = {
reply_markup: {
keyboard: [
[
{
request_contact: true,
text: '📱 Отправить номер телефона',
},
],
],
one_time_keyboard: true,
},
};
export const KEYBOARD_REMOVE = {
reply_markup: {
remove_keyboard: true,
} as ReplyKeyboardRemove,
};
export const KEYBOARD_SHARE_BOT = {
reply_markup: {
inline_keyboard: [
[
{
text: '🤖Воспользоваться ботом',
url: environment.BOT_URL,
},
],
],
},
};
export const MESSAGE_NOT_MASTER =
'⛔️ <b>Только мастер может добавлять контакты</b>\nСтать мастером можно на странице профиля в приложении или с помощью команды <b>/becomemaster</b>';
export const MSG_WELCOME =
'👋 <b>Добро пожаловать!</b>\nПожалуйста, поделитесь своим номером телефона для регистрации';
export const MSG_WELCOME_BACK = (name: string) =>
`👋 <b>С возвращением, ${name}!</b>`;
export const MSG_NEED_PHONE =
'📱 <b>Чтобы добавить контакт, сначала поделитесь своим номером телефона</b>';
export const MSG_SEND_CLIENT_CONTACT =
'👤 <b>Отправьте контакт клиента, которого вы хотите добавить. \n<em>Для отмены операции используйте команду /cancel</em></b>';
export const MSG_ALREADY_MASTER = '🎉 <b>Вы уже являетесь мастером!</b>';
export const MSG_BECOME_MASTER = '🥳 <b>Поздравляем! Теперь вы мастер</b>';
export const MSG_ERROR = (error?: unknown) =>
`❌ <b>Произошла ошибка</b>\n${error ? String(error) : ''}`;
export const MSG_PHONE_SAVED =
'✅ <b>Спасибо! Мы сохранили ваш номер телефона</b>\nТеперь вы можете открыть приложение или воспользоваться командами бота';
export const MSG_CONTACT_ADDED = (name: string) =>
`✅ <b>Добавили <b>${name}</b> в список ваших клиентов</b>\n\ригласите клиента в приложение, чтобы вы могли добавлять с ним записи`;
export const MSG_CONTACT_FORWARD =
'<em>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</em>';
export const MESSAGE_SHARE_BOT =
'📅 <b>Воспользуйтесь этим ботом для записи к вашему мастеру!</b>\nНажмите кнопку ниже, чтобы начать';
export const MESSAGE_CANCEL = '<b>❌ Отменена операции</b>';
export const MESSAGE_INVALID_PHONE = '❌ <b>Некорректный номер телефона</b>';

View File

@ -1,9 +0,0 @@
import { type Contact } from '@grammyjs/types';
export function parseContact(contact: Contact) {
return {
name: contact?.first_name?.trim() || '',
phone: contact?.phone_number?.trim() || '',
surname: contact?.last_name?.trim() || '',
};
}

View File

@ -0,0 +1,5 @@
import * as GQL from '@repo/graphql/types';
export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) {
return customer?.role === GQL.Enum_Customer_Role.Master;
}

View File

@ -1,4 +0,0 @@
export const formatMoney = Intl.NumberFormat('ru-RU', {
currency: 'RUB',
style: 'currency',
}).format;

View File

@ -1,19 +0,0 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import pino from 'pino';
const logger = pino({
transport: {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
options: {
colorize: true,
translateTime: true,
},
target: 'pino-pretty',
},
});
logger.info = logger.info.bind(logger);
logger.debug = logger.debug.bind(logger);
logger.error = logger.error.bind(logger);
export { logger };

View File

@ -1,3 +0,0 @@
export function combine(...messages: Array<string | undefined>) {
return messages.filter(Boolean).join('\n\n');
}

View File

@ -0,0 +1,9 @@
export function isValidPhoneNumber(phone: string) {
return /^\+7\d{10}$/u.test(phone);
}
export function normalizePhoneNumber(phone: string): string {
const digitsOnly = phone.replaceAll(/\D/gu, '');
return `+${digitsOnly}`;
}

View File

@ -1,23 +0,0 @@
import { env } from '@/config/env';
import { logger } from '@/utils/logger';
import Redis from 'ioredis';
const instance: Redis = createRedisInstance();
export function getRedisInstance() {
if (!instance) return createRedisInstance();
return instance;
}
function createRedisInstance() {
const redis = new Redis({
host: env.REDIS_HOST,
password: env.REDIS_PASSWORD,
port: env.REDIS_PORT,
});
redis.on('error', logger.error);
return redis;
}

View File

@ -1,5 +0,0 @@
import { type Context } from '@/bot/context';
export function getSessionKey(ctx: Omit<Context, 'session'>) {
return ctx.chat?.id.toString();
}

View File

@ -1,5 +0,0 @@
import { TIKTOK_URL_REGEX } from '@/constants/regex';
export function validateTikTokUrl(url: string) {
return TIKTOK_URL_REGEX.test(url);
}

View File

@ -8,7 +8,7 @@
"moduleResolution": "Node",
"module": "CommonJS",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./*"]
}
},
"include": ["."],

View File

@ -7,7 +7,7 @@ export default defineConfig({
external: ['telegraf', 'zod'],
format: 'cjs',
loader: { '.json': 'copy' },
minify: false,
minify: true,
noExternal: ['@repo'],
outDir: './dist',
sourcemap: false,

View File

@ -1,7 +0,0 @@
.git
Dockerfile
.dockerignore
node_modules
*.log
dist
README.md

View File

@ -1,13 +0,0 @@
import { typescript } from '@repo/eslint-config/typescript';
/** @type {import("eslint").Linter.Config} */
export default [
...typescript,
{
ignores: ['**/types/**', '*.config.*', '*.config.js', '.eslintrc.js'],
rules: {
'import/no-duplicates': 'off',
'import/consistent-type-specifier-style': 'off',
},
},
];

View File

@ -1,56 +0,0 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View File

@ -1,51 +0,0 @@
ARG NODE_VERSION=22
ARG PROJECT=cache-proxy
# Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update
RUN apk add --no-cache libc6-compat
FROM alpine as base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apk add --no-cache libc6-compat && \
corepack enable && \
pnpm install turbo@2.3.2 dotenv-cli --global
FROM base AS pruner
ARG PROJECT
WORKDIR /app
COPY . .
RUN turbo prune --scope=${PROJECT} --docker
FROM base AS builder
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm install --prod --frozen-lockfile
COPY --from=pruner /app/out/full/ .
COPY turbo.json turbo.json
COPY .env .env
RUN dotenv -e .env turbo run build --filter=${PROJECT}...
RUN --mount=type=cache,id=pnpm,target=~/.pnpm-store pnpm prune --prod --no-optional
RUN rm -rf ./**/*/src
FROM alpine AS runner
ARG PROJECT
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
USER appuser
WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app .
WORKDIR /app/apps/${PROJECT}
CMD ["node", "dist/main.js"]

View File

@ -1,73 +0,0 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ pnpm install
```
## Running the app
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Test
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View File

@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"monorepo": true,
"compilerOptions": {
"deleteOutDir": true
}
}

View File

@ -1,75 +0,0 @@
{
"name": "cache-proxy",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "dotenv -e ../../.env.local nest start -- --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/cache-manager": "^2.2.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/platform-fastify": "^10.3.3",
"@types/node": "catalog:",
"fastify": "^4.26.1",
"dotenv-cli": "catalog:",
"cache-manager": "^5.4.0",
"cache-manager-ioredis": "^2.1.0",
"ioredis": "^5.3.2",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"source-map-support": "^0.5.21",
"tsconfig-paths": "^4.2.0",
"typescript": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@nestjs/testing": "^10.0.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/jest": "^29.5.2",
"@types/supertest": "^6.0.0",
"eslint": "catalog:",
"jest": "^29.5.0",
"prettier": "catalog:",
"supertest": "^6.3.3",
"ts-jest": "29.1.1",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -1,18 +0,0 @@
import { ProxyModule } from './proxy/proxy.module';
import { HealthController } from './health/health.controller';
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
ProxyModule,
],
controllers: [HealthController],
providers: [],
})
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class AppModule {}

View File

@ -1,3 +0,0 @@
import { seconds } from 'src/utils/time';
export const DEFAULT_CACHE_TTL = seconds().fromMinutes(5);

View File

@ -1,3 +0,0 @@
import envSchema from './schema/env';
export const env = envSchema.parse(process.env);

View File

@ -1,22 +0,0 @@
import { DEFAULT_CACHE_TTL } from '../constants';
import { z } from 'zod';
const envSchema = z.object({
CACHE_TTL: z
.string()
.transform((val) => Number.parseInt(val, 10))
.default(DEFAULT_CACHE_TTL.toString()),
PORT: z
.string()
.transform((val) => Number.parseInt(val, 10))
.default('5000'),
REDIS_HOST: z.string().default('redis'),
REDIS_PASSWORD: z.string(),
REDIS_PORT: z
.string()
.transform((value) => Number.parseInt(value, 10))
.default('6379'),
URL_GRAPHQL: z.string(),
});
export default envSchema;

View File

@ -1,11 +0,0 @@
import { Controller, Get } from '@nestjs/common';
@Controller('api')
export class HealthController {
@Get('health')
public health() {
return { status: 'ok' };
}
}

View File

@ -1,15 +0,0 @@
import { AppModule } from './app.module';
import { env } from './config/env';
import { NestFactory } from '@nestjs/core';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { FastifyAdapter } from '@nestjs/platform-fastify';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.listen(env.PORT, '0.0.0.0');
}
bootstrap();

View File

@ -1,16 +0,0 @@
import { seconds } from 'src/utils/time';
export const queryTTL: Record<string, number | false> = {
GetCustomer: seconds().fromHours(12),
GetCustomers: false,
GetInvited: false,
GetInvitedBy: false,
GetOrders: false,
GetServices: false,
GetSlots: false,
GetSlotsOrders: false,
GetSubscriptionHistory: false,
GetSubscriptions: false,
GetSubscriptionSettings: seconds().fromHours(12),
Login: false,
};

View File

@ -1,8 +0,0 @@
import { env } from 'src/config/env';
import { queryTTL } from './config';
export function getQueryTTL(operationName: string) {
if (operationName.includes('NOCACHE')) return false;
return queryTTL[operationName] ?? env.CACHE_TTL;
}

View File

@ -1,138 +0,0 @@
import type { GQLRequest, GQLResponse } from './types';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import {
All,
Controller,
Delete,
Get,
HttpException,
HttpStatus,
Inject,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Cache } from 'cache-manager';
import { FastifyReply, FastifyRequest } from 'fastify';
import { env } from 'src/config/env';
import { extractDocumentId, getQueryType } from 'src/utils/query';
import { getQueryTTL } from './lib/utils';
type RedisStore = Omit<Cache, 'set'> & {
set: (key: string, value: unknown, { ttl }: { ttl: number }) => Promise<void>;
};
@Controller('api')
export class ProxyController {
constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: RedisStore) {}
@All('/graphql')
public async graphql(@Req() req: FastifyRequest, @Res() reply: FastifyReply) {
const { operationName, query, variables } = req.body as GQLRequest;
const queryType = getQueryType(query);
const key = `${operationName} ${JSON.stringify(variables)}`;
if (queryType.action === 'query') {
const cached = await this.cacheManager.get(key);
if (cached) return reply.send(cached);
}
const response = await fetch(env.URL_GRAPHQL, {
body: JSON.stringify({ operationName, query, variables }),
headers: {
Authorization: req.headers.authorization,
'Content-Type': 'application/json',
Cookie: req.headers.cookie,
},
method: req.method,
});
const data = (await response.json()) as GQLResponse;
if (!response.ok || data?.error || data?.errors?.length)
throw new HttpException(
response.statusText,
response.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
if (queryType.action === 'mutation' && queryType.entity) {
const documentId = extractDocumentId(data);
const keys = await this.cacheManager.store.keys(`*${queryType.entity}*`);
for (const key of keys) {
if (key.includes(documentId)) {
await this.cacheManager.del(key);
// console.log(`🗑 Cache invalidated (by key): ${key}`);
continue;
}
const value = await this.cacheManager.get(key);
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
if (serialized?.includes(documentId)) {
await this.cacheManager.del(key);
// console.log(`🗑 Cache invalidated (by value): ${key}`);
}
}
}
const ttl = getQueryTTL(operationName);
if (queryType.action === 'query' && data && ttl !== false)
await this.cacheManager.set(key, data, { ttl });
return reply.send(data);
}
@Get('/get-queries')
public async getQueriesList(@Res() reply: FastifyReply) {
const keys: string[] = await this.cacheManager.store.keys('*');
const entries = await Promise.all(
keys.map(async (key) => {
try {
const value = await this.cacheManager.get(key);
return { key, value };
} catch (e) {
return { key, error: e.message };
}
}),
);
return reply.send(entries);
}
@Delete('/delete-query')
public async deleteQuery(@Query('queryKey') queryKey: string, @Res() reply: FastifyReply) {
try {
await this.cacheManager.del(queryKey);
return reply.send('ok');
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Delete('/reset')
public async reset(@Res() reply: FastifyReply) {
try {
await this.cacheManager.reset();
return reply.send('ok');
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Get('/get-query')
public async getQueryValue(@Query('queryKey') queryKey: string, @Res() reply: FastifyReply) {
try {
const value = await this.cacheManager.get(queryKey);
return reply.send(value);
} catch (error) {
throw new HttpException(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}

View File

@ -1,22 +0,0 @@
import { ProxyController } from './proxy.controller';
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-ioredis';
import type { RedisOptions } from 'ioredis';
import { env } from 'src/config/env';
@Module({
controllers: [ProxyController],
imports: [
CacheModule.register<RedisOptions>({
host: env.REDIS_HOST,
port: env.REDIS_PORT,
store: redisStore,
ttl: env.CACHE_TTL,
password: env.REDIS_PASSWORD,
db: 1,
}),
],
})
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class ProxyModule {}

View File

@ -1,16 +0,0 @@
export type GQLRequest = {
operationName: string;
query: string;
variables: string;
};
export type GQLResponse = {
data: unknown;
error?: unknown;
errors?: unknown[];
};
export type QueryItem = {
queries: string[];
ttl: number | false;
};

View File

@ -1,22 +0,0 @@
import { GQLResponse } from 'src/proxy/types';
export function getQueryType(query: string) {
const actionMatch = query.match(/\b(query|mutation)\b/u);
const action = actionMatch ? (actionMatch[1] as 'query' | 'mutation') : null;
const entityMatch = query.match(
/\b(mutation|query)\s+\w*([A-Z][A-Za-z0-9_]+)/u,
);
const entity = entityMatch ? entityMatch[2] : null;
return { action, entity };
}
export function extractDocumentId(data: GQLResponse) {
if (!data?.data) return null;
const firstKey = Object.keys(data.data)[0];
if (!firstKey) return null;
return data.data[firstKey]?.documentId || null;
}

View File

@ -1,13 +0,0 @@
export function seconds() {
return {
fromDays(days: number) {
return days * 24 * 60 * 60;
},
fromHours(hours: number) {
return hours * 60 * 60;
},
fromMinutes(minutes: number) {
return minutes * 60;
},
};
}

View File

@ -1,4 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@ -1,23 +0,0 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2022",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -1,240 +0,0 @@
# Система проверки бана пользователей
## Обзор
Реализована многоуровневая система проверки бана пользователей, которая предотвращает доступ заблокированных пользователей к функциональности приложения.
## 1. База данных (`bannedUntil` поле)
В Strapi добавлено поле `bannedUntil` типа `datetime` в модель `Customer`:
- `null` = пользователь не забанен
- `дата в будущем` = временный бан до указанной даты
- `дата в далеком будущем` = постоянный бан
## 2. Утилита проверки (`packages/utils/src/customer.ts`)
```typescript
export function isCustomerBanned(customer: { bannedUntil?: string | null }): boolean {
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
}
```
## 3. Next Auth проверка (`apps/web/config/auth.ts`)
В `authorize` callback добавлена проверка бана:
```typescript
async authorize(credentials) {
const { telegramId } = credentials ?? {};
if (!telegramId) { throw new Error('Invalid Telegram ID'); }
try {
const { query } = await getClientWithToken();
const result = await query({
query: GetCustomerDocument,
variables: { telegramId: Number(telegramId) },
});
const customer = result.data.customers.at(0);
if (!customer || isCustomerBanned(customer)) {
throw new Error('User is banned or not found');
}
return { id: telegramId };
} catch (error) {
throw new Error('Authentication failed');
}
}
```
## 4. Универсальная проверка в BaseService (`packages/graphql/api/base.ts`)
Добавлен метод `checkIsBanned()` в `BaseService`:
```typescript
/**
* Универсальная проверка статуса бана пользователя
* Должна вызываться в начале каждого метода сервиса
*/
protected async checkIsBanned() {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetCustomerDocument,
variables: this._user,
});
const customer = result.data.customers.at(0);
if (!customer) {
throw new Error(BASE_ERRORS.NOT_FOUND_CUSTOMER);
}
if (isCustomerBanned(customer)) {
throw new Error(ERRORS.NO_PERMISSION);
}
return { customer };
}
```
**Использование в сервисах:**
```typescript
async someMethod() {
await this.checkIsBanned(); // Проверка бана в начале метода
// ... остальная логика
}
```
**Обновленные сервисы:**
- ✅ `CustomersService` - все методы
- ✅ `ServicesService` - все методы
- ✅ `OrdersService` - все методы
- ✅ `SlotsService` - все методы
- ✅ `RegistrationService` - добавлена собственная проверка
**Преимущества:**
- Автоматическая проверка во всех сервисах, наследующих от BaseService
- Единая точка проверки бана
- Работает как в веб-приложении, так и в боте
- Простота добавления в новые методы
- Защита всех API методов от забаненных пользователей
## 5. Защита от изменения статуса бана (`packages/graphql/api/customers.ts` и `registration.ts`)
Пользователи не могут изменять поле `bannedUntil` самостоятельно:
```typescript
// В CustomersService
async updateCustomer(variables: Omit<VariablesOf<typeof GQL.UpdateCustomerDocument>, 'documentId'>) {
await this.checkBanStatus();
const { customer } = await this._getUser();
// Проверяем, что пользователь не пытается изменить поле bannedUntil
if (variables.data.bannedUntil !== undefined) {
throw new Error(ERRORS.NO_PERMISSION);
}
// ... остальная логика обновления
}
// В RegistrationService
async updateCustomer(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
// Проверяем бан для существующего пользователя
if (variables.documentId) {
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetCustomerDocument,
variables: { documentId: variables.documentId },
});
const customer = result.data.customers.at(0);
if (customer && isCustomerBanned(customer)) {
throw new Error(ERRORS.NO_PERMISSION);
}
}
if (variables.data.bannedUntil) {
throw new Error(ERRORS.NO_PERMISSION);
}
// ... остальная логика обновления
}
```
**Преимущества:**
- Пользователи не могут снять с себя бан
- Только администраторы могут изменять статус блокировки
- Дополнительный уровень безопасности
- Защита работает во всех сервисах, которые обновляют данные пользователей
- Единая ошибка `NO_PERMISSION` для всех случаев отсутствия доступа
## 6. Client-side Check (`components/auth/ban-check.tsx`)
React компонент для проверки бана на клиенте:
```typescript
export function BanCheck({ children }: Readonly<PropsWithChildren>) {
const { data: session } = useSession();
const router = useRouter();
const isBanned = useIsBanned();
useEffect(() => {
if (session?.user?.telegramId && isBanned) {
router.push('/banned');
}
}, [session?.user?.telegramId, isBanned, router]);
if (session?.user?.telegramId && isBanned) {
return null;
}
return <>{children}</>;
}
```
**Использование в layout:**
```typescript
export default function Layout({ children }: Readonly<PropsWithChildren>) {
return (
<Provider>
<BanCheck>
<UpdateProfile />
<main className="grow">{children}</main>
<BottomNav />
</BanCheck>
</Provider>
);
}
```
## 7. Hook для проверки бана (`hooks/api/customers.ts`)
```typescript
export const useIsBanned = () => {
const { data: { customer } = {} } = useCustomerQuery();
if (!customer) return false;
return isCustomerBanned(customer);
};
```
## 8. Страница для забаненных пользователей (`apps/web/app/(auth)/banned/page.tsx`)
Создана специальная страница с информацией о бане и возможностью выхода из аккаунта.
## 9. Централизованные ошибки (`packages/graphql/constants/errors.ts`)
```typescript
export const ERRORS = {
NO_PERMISSION: 'Нет доступа',
} as const;
```
## Архитектура системы
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Next Auth │ │ BaseService │ │ Client-side │
│ (авторизация) │ │ (API методы) │ │ (UI) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ isCustomerBanned│ │ checkBanStatus()│ │ BanCheck │
│ (утилита) │ │ (метод) │ │ (компонент) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌─────────────────────────┐
│ bannedUntil (DB) │
│ (Strapi/PostgreSQL) │
└─────────────────────────┘
```
## Преимущества системы
**Многоуровневая защита** - проверка на всех уровнях приложения
**Универсальность** - работает в веб-приложении и боте
**Простота использования** - один вызов `checkBanStatus()` в начале метода
**Безопасность** - пользователи не могут обойти бан
**UX** - понятные сообщения и страница для забаненных
**DRY принцип** - нет дублирования кода
**Легкость расширения** - просто добавить новые проверки

View File

@ -11,7 +11,7 @@ ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN apk add --no-cache libc6-compat openssl && \
corepack enable && \
pnpm install turbo@2.3.2 dotenv-cli --global
pnpm install turbo dotenv-cli --global
FROM base AS pruner
ARG PROJECT

View File

@ -1,9 +1,8 @@
import * as customers from './server/customers';
import { wrapClientAction } from '@/utils/actions';
export const addInvitedBy = wrapClientAction(customers.addInvitedBy);
export const getInvited = wrapClientAction(customers.getInvited);
export const addMasters = wrapClientAction(customers.addMasters);
export const getClients = wrapClientAction(customers.getClients);
export const getCustomer = wrapClientAction(customers.getCustomer);
export const getCustomers = wrapClientAction(customers.getCustomers);
export const getInvitedBy = wrapClientAction(customers.getInvitedBy);
export const getMasters = wrapClientAction(customers.getMasters);
export const updateCustomer = wrapClientAction(customers.updateCustomer);

View File

@ -6,10 +6,16 @@ import { CustomersService } from '@repo/graphql/api/customers';
const getService = useService(CustomersService);
export async function addInvitedBy(...variables: Parameters<CustomersService['addInvitedBy']>) {
export async function addMasters(...variables: Parameters<CustomersService['addMasters']>) {
const service = await getService();
return wrapServerAction(() => service.addInvitedBy(...variables));
return wrapServerAction(() => service.addMasters(...variables));
}
export async function getClients(...variables: Parameters<CustomersService['getClients']>) {
const service = await getService();
return wrapServerAction(() => service.getClients(...variables));
}
export async function getCustomer(...variables: Parameters<CustomersService['getCustomer']>) {
@ -18,22 +24,10 @@ export async function getCustomer(...variables: Parameters<CustomersService['get
return wrapServerAction(() => service.getCustomer(...variables));
}
export async function getCustomers(...variables: Parameters<CustomersService['getCustomers']>) {
export async function getMasters(...variables: Parameters<CustomersService['getMasters']>) {
const service = await getService();
return wrapServerAction(() => service.getCustomers(...variables));
}
export async function getInvited(...variables: Parameters<CustomersService['getInvited']>) {
const service = await getService();
return wrapServerAction(() => service.getInvited(...variables));
}
export async function getInvitedBy(...variables: Parameters<CustomersService['getInvitedBy']>) {
const service = await getService();
return wrapServerAction(() => service.getInvitedBy(...variables));
return wrapServerAction(() => service.getMasters(...variables));
}
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {

View File

@ -6,12 +6,6 @@ import { ServicesService } from '@repo/graphql/api/services';
const getServicesService = useService(ServicesService);
export async function createService(...variables: Parameters<ServicesService['createService']>) {
const service = await getServicesService();
return wrapServerAction(() => service.createService(...variables));
}
export async function getService(...variables: Parameters<ServicesService['getService']>) {
const service = await getServicesService();
@ -23,9 +17,3 @@ export async function getServices(...variables: Parameters<ServicesService['getS
return wrapServerAction(() => service.getServices(...variables));
}
export async function updateService(...variables: Parameters<ServicesService['updateService']>) {
const service = await getServicesService();
return wrapServerAction(() => service.updateService(...variables));
}

View File

@ -1,84 +0,0 @@
'use server';
import { useService } from '../lib/service';
import { wrapServerAction } from '@/utils/actions';
import { SubscriptionsService } from '@repo/graphql/api/subscriptions';
const getService = useService(SubscriptionsService);
export async function createSubscription(
...variables: Parameters<SubscriptionsService['createSubscription']>
) {
const service = await getService();
return wrapServerAction(() => service.createSubscription(...variables));
}
export async function createSubscriptionHistory(
...variables: Parameters<SubscriptionsService['createSubscriptionHistory']>
) {
const service = await getService();
return wrapServerAction(() => service.createSubscriptionHistory(...variables));
}
export async function createTrialSubscription() {
const service = await getService();
return wrapServerAction(() => service.createTrialSubscription());
}
export async function getSubscription(
...variables: Parameters<SubscriptionsService['getSubscription']>
) {
const service = await getService();
return wrapServerAction(() => service.getSubscription(...variables));
}
export async function getSubscriptionHistory(
...variables: Parameters<SubscriptionsService['getSubscriptionHistory']>
) {
const service = await getService();
return wrapServerAction(() => service.getSubscriptionHistory(...variables));
}
export async function getSubscriptionPrices(
...variables: Parameters<SubscriptionsService['getSubscriptionPrices']>
) {
const service = await getService();
return wrapServerAction(() => service.getSubscriptionPrices(...variables));
}
export async function getSubscriptions(
...variables: Parameters<SubscriptionsService['getSubscriptions']>
) {
const service = await getService();
return wrapServerAction(() => service.getSubscriptions(...variables));
}
export async function getSubscriptionSettings(
...variables: Parameters<SubscriptionsService['getSubscriptionSettings']>
) {
const service = await getService();
return wrapServerAction(() => service.getSubscriptionSettings(...variables));
}
export async function updateSubscription(
...variables: Parameters<SubscriptionsService['updateSubscription']>
) {
const service = await getService();
return wrapServerAction(() => service.updateSubscription(...variables));
}
export async function updateSubscriptionHistory(
...variables: Parameters<SubscriptionsService['updateSubscriptionHistory']>
) {
const service = await getService();
return wrapServerAction(() => service.updateSubscriptionHistory(...variables));
}

View File

@ -3,5 +3,3 @@ import { wrapClientAction } from '@/utils/actions';
export const getServices = wrapClientAction(services.getServices);
export const getService = wrapClientAction(services.getService);
export const createService = wrapClientAction(services.createService);
export const updateService = wrapClientAction(services.updateService);

View File

@ -1,13 +0,0 @@
import * as subscriptions from './server/subscriptions';
import { wrapClientAction } from '@/utils/actions';
export const getSubscription = wrapClientAction(subscriptions.getSubscription);
export const getSubscriptions = wrapClientAction(subscriptions.getSubscriptions);
export const getSubscriptionSettings = wrapClientAction(subscriptions.getSubscriptionSettings);
export const getSubscriptionPrices = wrapClientAction(subscriptions.getSubscriptionPrices);
export const getSubscriptionHistory = wrapClientAction(subscriptions.getSubscriptionHistory);
export const createSubscription = wrapClientAction(subscriptions.createSubscription);
export const updateSubscription = wrapClientAction(subscriptions.updateSubscription);
export const createSubscriptionHistory = wrapClientAction(subscriptions.createSubscriptionHistory);
export const updateSubscriptionHistory = wrapClientAction(subscriptions.updateSubscriptionHistory);
export const createTrialSubscription = wrapClientAction(subscriptions.createTrialSubscription);

View File

@ -2,15 +2,12 @@
import { authOptions } from '@/config/auth';
import { getServerSession } from 'next-auth/next';
import { redirect } from 'next/navigation';
export async function getSessionUser() {
const session = await getServerSession(authOptions);
const user = session?.user;
if (!user?.telegramId) {
return redirect('/');
}
if (!user?.telegramId) throw new Error('Missing session');
return user;
}

View File

@ -1,57 +0,0 @@
'use client';
import { Container } from '@/components/layout';
import { Button } from '@repo/ui/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@repo/ui/components/ui/card';
import { AlertTriangle, Ban } from 'lucide-react';
import { signOut } from 'next-auth/react';
const handleSignOut = () => {
signOut({ callbackUrl: '/' });
};
export default function BannedPage() {
return (
<Container>
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-destructive/10">
<Ban className="size-8 text-destructive" />
</div>
<CardTitle className="text-xl">Аккаунт заблокирован</CardTitle>
<CardDescription>
Ваш аккаунт был заблокирован администратором. Для получения дополнительной информации
обратитесь в поддержку.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg bg-muted p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 text-yellow-500" />
<div className="text-sm text-muted-foreground">
<p className="mb-1 font-medium text-foreground">Возможные причины блокировки:</p>
<ul className="list-inside list-disc space-y-1">
<li>Нарушение правил использования сервиса</li>
<li>Спам или нежелательная активность</li>
<li>Множественные жалобы от других пользователей</li>
<li>Технические проблемы с аккаунтом</li>
</ul>
</div>
</div>
</div>
<Button className="w-full" onClick={handleSignOut} variant="outline">
Выйти из аккаунта
</Button>
</CardContent>
</Card>
</div>
</Container>
);
}

View File

@ -22,18 +22,7 @@ export default function Auth() {
signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: user?.id?.toString(),
}).then((result) => {
if (
result?.error &&
(result?.error?.includes('CredentialsSignin') ||
result?.error?.includes('UNREGISTERED'))
) {
// Пользователь не зарегистрирован
redirect('/unregistered');
} else if (result?.ok) {
redirect('/profile');
}
telegramId: user?.id,
});
});
}

View File

@ -10,6 +10,4 @@ export default function Page() {
redirect(isTG ? '/telegram' : '/browser');
});
return 'Redirecting...';
}

View File

@ -1,6 +1,6 @@
import { TelegramProvider } from '@/providers/telegram';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
export default async function Layout({ children }: Readonly<PropsWithChildren>) {
return <TelegramProvider>{children}</TelegramProvider>;
}

View File

@ -1,79 +1,40 @@
/* eslint-disable promise/prefer-await-to-then */
'use client';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react';
import { signIn, type SignInResponse, useSession } from 'next-auth/react';
import { signIn, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react';
import { redirect } from 'next/navigation';
import { useEffect } from 'react';
export default function Auth() {
useTelegramTheme();
useAuth();
useTelegramAuth();
return <LoadingSpinner />;
return null;
}
/**
* Хук для авторизации пользователя через NextAuth
*/
function useTelegramAuth() {
function useAuth() {
const initDataUser = useSignal(initData.user);
const { data: session, status } = useSession();
const router = useRouter();
const handleSignInResult = useCallback(
(result: SignInResponse | undefined) => {
if (!result) return;
if (
result.error &&
(result.error.includes('CredentialsSignin') || result.error.includes('UNREGISTERED'))
) {
router.replace('/unregistered');
} else if (result.ok) {
router.replace('/profile');
}
},
[router],
);
const { status } = useSession();
useEffect(() => {
const telegramId = initDataUser?.id;
if (!telegramId) return;
if (!initDataUser?.id) return;
if (status === 'authenticated') {
// Если telegramId есть в сессии — редирект
if (session?.user?.telegramId) {
router.replace('/profile');
} else {
// Если telegramId отсутствует — пробуем заново signIn
void signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: telegramId.toString(),
}).then(handleSignInResult);
}
return;
redirect('/profile');
}
if (status === 'unauthenticated') {
void signIn('telegram', {
signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: telegramId.toString(),
}).then(handleSignInResult);
telegramId: initDataUser.id,
}).then(() => redirect('/profile'));
}
}, [initDataUser?.id, status, session?.user?.telegramId, router, handleSignInResult]);
}, [initDataUser?.id, status]);
}
/**
* Хук для установки темы из Telegram Mini App
*/
function useTelegramTheme() {
const isDark = isMiniAppDark();
const { setTheme } = useTheme();

View File

@ -1,54 +0,0 @@
import { UnregisteredClient } from './unregistered-client';
import { Container } from '@/components/layout';
import { env } from '@/config/env';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@repo/ui/components/ui/card';
import { Bot, MessageCircle } from 'lucide-react';
export default function UnregisteredPage() {
return (
<Container>
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/20">
<Bot className="size-8 text-blue-600 dark:text-blue-400" />
</div>
<CardTitle className="text-xl">Давайте познакомимся</CardTitle>
<CardDescription>
Для использования приложения необходимо поделиться своим номером телефона с ботом
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg bg-muted p-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<MessageCircle className="mt-0.5 size-5 text-blue-500" />
<div className="text-sm">
<p className="mb-1 font-medium text-foreground">Как поделиться:</p>
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
<li>Вернитесь к Telegram боту</li>
<li>
Отправьте команду{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">/start</code>
</li>
<li>Нажмите на появившуюся кнопку "Отправить номер телефона"</li>
<li>Закройте и откройте это приложение еще раз</li>
</ol>
</div>
</div>
</div>
</div>
<UnregisteredClient botUrl={env.BOT_URL} />
</CardContent>
</Card>
</div>
</Container>
);
}

View File

@ -1,37 +0,0 @@
'use client';
import { Button } from '@repo/ui/components/ui/button';
import { Bot, ExternalLink } from 'lucide-react';
import Link from 'next/link';
import { signOut } from 'next-auth/react';
type UnregisteredClientProps = {
readonly botUrl: string;
};
export function UnregisteredClient({ botUrl }: UnregisteredClientProps) {
const handleSignOut = () => {
signOut({ callbackUrl: '/' });
};
const handleRefresh = () => {
window.location.reload();
};
return (
<div className="flex flex-col gap-2">
<Button asChild className="w-full">
<Link href={botUrl} rel="noopener noreferrer" target="_blank">
<Bot className="mr-2 size-4" />
Перейти к боту
<ExternalLink className="ml-2 size-4" />
</Link>
</Button>
<Button className="w-full" onClick={handleRefresh} variant="outline">
Обновить страницу
</Button>
<Button className="w-full" onClick={handleSignOut} variant="outline">
Выйти из аккаунта
</Button>
</div>
);
}

View File

@ -1,6 +0,0 @@
import { DocumentsLayout } from '@/components/documents/layout';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
return <DocumentsLayout title="Публичная оферта">{children}</DocumentsLayout>;
}

View File

@ -1,99 +0,0 @@
import { env } from '@/config/env';
export const metadata = {
title: 'Публичная оферта',
description: 'Публичная оферта бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
};
### Договор-оферта на использование сервиса «Запишись.онлайн» (@zapishis_online_bot)
Настоящий документ является публичной офертой в соответствии с пунктом 2 статьи 437 Гражданского кодекса Российской Федерации и представляет собой предложение индивидуального предпринимателя (самозанятого) — далее именуемого «Администрация», заключить Договор на использование Сервиса (далее «Договор», «Оферта») с любым физическим лицом, принявшим условия настоящей Оферты (далее «Пользователь»).
#### 1. Термины и определения
1.1. Оферта — настоящий документ, постоянно размещенный в сети Интернет по адресу <a href={env.URL_OFFER}>{env.URL_OFFER}</a>.
1.2. Акцепт — полное и безоговорочное принятие условий Оферты Пользователем путем оплаты доступа через встроенный платежный бот ЮKassa в Telegram.
1.3. Сервис — Telegram-бот и мини-приложение, позволяющее пользователям создавать и принимать заказы, управлять расписанием и взаимодействовать друг с другом без необходимости регистрации.
1.4. Администрация — самозанятое лицо, являющееся разработчиком и правообладателем Сервиса.
1.5. Пользователь — любое физическое лицо, использующее Сервис в личных или профессиональных целях.
1.6. Доступ — право использования функционала Сервиса на определённый оплаченный период (например, неделя, месяц, год).
1.7. Оплата — денежные средства, перечисленные Пользователем через платёжный бот ЮKassa в Telegram.
#### 2. Акцепт оферты и заключение договора
2.1. Акцептом настоящей Оферты считается оплата Пользователем доступа к Сервису любым доступным способом.
2.2. С момента совершения оплаты Пользователь считается заключившим Договор с Администрацией на условиях, изложенных в настоящей Оферте.
2.3. Пользователь подтверждает, что ему понятны все условия настоящей Оферты и он принимает их без ограничений.
#### 3. Предмет договора
3.1. Администрация предоставляет Пользователю неисключительное право (доступ) на использование функционала Сервиса в пределах оплаченного периода времени.
3.2. Сервис предоставляется в онлайн-формате через Telegram-бота без установки дополнительного программного обеспечения.
3.3. Пользователь получает право использовать функционал Сервиса в личных целях, в том числе для организации и планирования заказов, встреч и тренировок.
#### 4. Порядок оплаты и использование
4.1. Оплата производится через встроенные инструменты Telegram-бота с использованием платёжной системы ЮKassa.
4.2. Комиссия платёжной системы включена в итоговую стоимость. Администрация не взимает дополнительных платежей.
4.3. Доступ активируется автоматически после успешного подтверждения оплаты.
4.4. Пользователь может продлить доступ путём повторной оплаты. Автоматическое продление не применяется.
4.5. Возврат денежных средств возможен только в случае технических ошибок, по письменному обращению на адрес поддержки.
#### 5. Права и обязанности сторон
5.1. Пользователь обязуется:
- не использовать Сервис в противоправных целях;
- не вмешиваться в работу Сервиса и не предпринимать действий, направленных на нарушение его функционирования;
- предоставлять достоверную информацию при оплате и использовании Сервиса;
- при добавлении контактов других лиц (например, клиентов, мастеров) гарантировать, что у него есть согласие этих лиц на передачу и обработку их персональных данных в рамках Сервиса;
5.2. Администрация обязуется:
- обеспечивать бесперебойную работу Сервиса, за исключением периодов технического обслуживания;
- обрабатывать персональные данные Пользователей в соответствии с Политикой конфиденциальности;
- принимать обращения и запросы Пользователей по вопросам работы Сервиса. 6. Ответственность сторон;
6.1. Сервис предоставляется «как есть». Администрация не несёт ответственности за временные сбои, потерю данных или недоступность Сервиса, возникшие по причинам, не зависящим от неё.
6.2. Пользователь несёт полную ответственность за корректность совершаемых платежей и действий, совершаемых через свой Telegram-аккаунт.
#### 7. Обработка персональных данных
7.1. Администрация обрабатывает персональные данные Пользователя в соответствии с Федеральным законом №152-ФЗ «О персональных данных» и Политикой конфиденциальности.
7.2. Использование Сервиса означает согласие Пользователя на обработку его персональных данных.
#### 8. Срок действия и расторжение договора
8.1. Договор вступает в силу с момента оплаты доступа и действует в течение оплаченного периода.
8.2. Пользователь может прекратить использование Сервиса в любое время без возврата оплаченных средств.
8.3. Администрация вправе приостановить доступ в случае нарушения Пользователем условий настоящей Оферты.
#### 9. Заключительные положения
9.1. Настоящий Договор регулируется законодательством Российской Федерации.
9.2. Все споры и разногласия решаются путём переговоров, а при недостижении соглашения — в судебном порядке по месту нахождения Администрации.
9.3. Администрация оставляет за собой право изменять условия Оферты с размещением новой редакции на сайте.
#### 10. Контакты
Если у Вас есть вопросы по настоящему договору публичной оферты персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

@ -1,6 +0,0 @@
import { DocumentsLayout } from '@/components/documents/layout';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
return <DocumentsLayout title="Политика конфиденциальности">{children}</DocumentsLayout>;
}

View File

@ -1,131 +0,0 @@
export const metadata = {
title: 'Политика конфиденциальности',
description:
'Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)',
};
### Политика конфиденциальности бота / мини-приложения «Запишись.онлайн» (@zapishis_online_bot)
#### 1. Термины и определения
- **Telegram** Telegram Messenger Inc. (платформа, на которой работает бот и мини-приложение).
- **Платформа** экосистема ботов и мини-приложений Telegram.
- **Разработчик** физическое лицо, самозанятый, владелец и оператор сервиса «Запишись.онлайн» (@zapishis_online_bot) - (далее — «Разработчик»).
- **Сторонний сервис** бот/мини-приложение Разработчика, предоставляемое в Платформе.
- **Пользователь** лицо, использующее Сторонний сервис через свою учетную запись Telegram (далее — «Вы»).
- **Политика** настоящий документ, регулирующий отношения между Разработчиком и Пользователем в части сбора и - обработки персональных данных.
#### 2. Общие положения
2.1. Настоящая Политика регулирует исключительно отношения между Разработчиком и Пользователем. Она не заменяет и не изменяет Политику конфиденциальности Telegram: [https://telegram.org/privacy](https://telegram.org/privacy).
2.2. Разработчик соблюдает применимые требования платформы Telegram к конфиденциальности и защите данных.
2.3. Использование Сервиса Пользователем и/или активация платного доступа означает согласие Пользователя с условиями настоящей Политики.
2.4. Если Вы не согласны с условиями Политики — прекратите использование Сервиса.
#### 3. Отказ от ответственности
3.1. Сторонний сервис является независимым приложением и не поддерживается, не одобряется и не аффилирован с Telegram (за исключением использования API и инфраструктуры Telegram).
3.2. Разработчик вправе изменять настоящую Политику — изменения вступают в силу с момента их публикации. Вы обязаны самостоятельно отслеживать обновления.
3.3. Используя Сервис, Вы подтверждаете, что ознакомлены и согласны с условиями использования Telegram для ботов и мини-приложений: [https://telegram.org/tos/bots](https://telegram.org/tos/bots), [https://telegram.org/tos/mini-apps](https://telegram.org/tos/mini-apps).
3.4. Вы гарантируете, что используете Сервис в соответствии с действующим законодательством и обладаете правом взаимодействовать с ним (например, достигли возраста, необходимого для использования услуг).
3.5. Вы обязуетесь предоставлять точную и актуальную информацию, если Сервис запрашивает её.
3.6. Любая информация, которую Вы делаете общедоступной самостоятельно (через профиль Telegram, публичные сообщения и т.п.), может стать доступна другим пользователям и не подпадает под защиту настоящей Политики в части конфиденциальности этой общедоступной информации.
#### 4. Сбор персональных данных
4.1. Telegram по умолчанию предоставляет сторонним сервисам ограниченный набор данных о Пользователе — подробнее: [https://telegram.org/privacy#6-bot-messages](https://telegram.org/privacy#6-bot-messages).
4.2. Сторонний сервис может дополнительно получать данные, которые Вы передаёте в чате бота или в мини-приложении (например, контакт, телефон), если Вы явно их отправляете.
4.3. В случае мини-приложения дополнительно могут передаваться данные в соответствии с правилами мини-приложений Telegram: [https://telegram.org/tos/mini-apps#4-privacy](https://telegram.org/tos/mini-apps#4-privacy).
4.4. Сторонний сервис может собирать также анонимную статистику использования (диагностика, события взаимодействия), не связываемую напрямую с персоной.
4.5. Пользователь может передавать данные третьих лиц (например, контактные данные клиентов или мастеров) для использования в Сервисе. При этом пользователь гарантирует, что эти лица дали согласие на обработку их персональных данных в рамках Сервиса.
#### 5. Какие данные мы собираем и как используем
5.1. Разработчик запрашивает, собирает и обрабатывает только те данные, которые необходимы для корректной работы функций Сервиса, в частности:
- Telegram ID и (опционально) отображаемое имя пользователя;
- телефон, только если Вы предоставили его добровольно (например, при регистрации);
- данные о заказах: дата/время, описание заказа, статус;
- информация о факте покупки Pro-доступа: период доступа, тип покупки (детали платёжной транзакции обрабатывает платёжный оператор — ЮKassa);
5.2. Цели обработки:
- предоставление и поддержка работы Сервиса (создание заказов, напоминания, управление доступом);
- подтверждение и учет оплат (взаимодействие с платёжным оператором для актуализации статуса доступа);
- реализация реферальной программы (хранение связей «кто пригласил/кого пригласили»);
- анализ использования и улучшение сервиса;
- выполнение юридических обязательств (хранение информации о транзакциях и др.);
> **Важно:** детальные платёжные данные (реквизиты карт и т.д.) не хранятся у Разработчика — их обрабатывает платёжный оператор (ЮKassa) и Telegram-платежный бот.
#### 6. Передача данных третьим лицам
6.1. Разработчик не передаёт персональные данные третьим лицам, за исключением следующих случаев:
- платёжному оператору (ЮKassa) и связанным службам для обработки платежей;
- Telegram как платформе для функционирования бота и мини-приложения;
- в случае необходимости — исполнителям, оказывающим техническую поддержку, при условии подписания ими обязательств о конфиденциальности;
- если передача требуется по закону (запросы уполномоченных органов и т.п.);
6.2. Разработчик не продаёт и не передаёт персональные данные для рекламных целей третьим лицам без Вашего отдельного согласия.
#### 7. Защита и хранение данных
7.1. Разработчик применяет разумные технические и организационные меры для защиты персональных данных (использование надежного VPS, ограничения доступа, резервное копирование и т.п.).
7.2. Доступ к персональным данным имеет только Разработчик (и/или доверенные исполнители технической поддержки при необходимости).
7.3. Данные хранятся на серверах, указанных Разработчиком. Если используются внешние сервисы/облачные хранилища — это будет указано в соответствующих местах Политики или сообщения при сборе данных.
#### 8. Права и обязанности сторон
8.1. Права Разработчика:
- вносить изменения в Политику с публикацией новой версии;
- ограничивать доступ к API/сервису при подозрении в злоупотреблениях;
- запросить подтверждение личности при необходимости обработки привилегированных запросов;
8.2. Обязанности Разработчика:
- обеспечивать доступность Политики и исполнять её условия;
- обрабатывать законные запросы пользователей о доступе, изменении или удалении данных в разумные сроки (не позднее 30 дней, если иное не установлено законом);
- соблюдать применимое законодательство о защите персональных данных;
8.3. Права Пользователя:
- запросить копию своих персональных данных, хранящихся у Разработчика;
- потребовать исправления неточных данных;
- потребовать удаления персональных данных в пределах, допустимых законом (с сохранением данных, необходимых для выполнения юридических обязательств, например, по учёту платежей);
- отозвать согласие на обработку персональных данных, если такое согласие предоставлялось добровольно;
- подать жалобу в уполномоченные органы по защите персональных данных, если считает, что его права нарушены;
8.4. Обязанности Пользователя:
- предоставлять точную и актуальную информацию;
- не использовать Сервис в нарушении законодательства и условий Telegram.
#### 9. Реклама и использование данных для аналитики
9.1. На текущем этапе Разработчик не использует персональные данные для демонстрации таргетированной рекламы третьих лиц без явного согласия Пользователя.
9.2. Разработчик может собирать агрегированную (анонимную) статистику использования Сервиса для улучшения функционала.
#### 10. Изменения Политики
10.1. Разработчик вправе вносить изменения в настоящую Политику. Все изменения публикуются на этой странице и вступают в силу с момента публикации.
#### 11. Контакты
Если у Вас есть вопросы по Политике конфиденциальности или запросы в отношении персональных данных, пожалуйста, свяжитесь с Разработчиком. Контакты указаны в описании бота.

View File

@ -1,4 +1,4 @@
import { ContactsList } from '@/components/contacts';
import { ContactsFilter, ContactsList } from '@/components/contacts';
import { ContactsContextProvider } from '@/context/contacts';
import { Card } from '@repo/ui/components/ui/card';
@ -8,7 +8,7 @@ export default function ContactsPage() {
<Card>
<div className="flex flex-row items-center justify-between space-x-4 p-4">
<h1 className="font-bold">Контакты</h1>
{/* <ContactsFilter /> */}
<ContactsFilter />
</div>
<div className="p-4 pt-0">
<ContactsList />

View File

@ -1,24 +1,13 @@
'use client';
import { CheckBanned, UpdateProfile } from '@/components/auth';
import { UpdateProfile } from '@/components/auth';
import { BottomNav } from '@/components/navigation';
import { EmptyProvider } from '@/providers/empty';
import { TelegramProvider } from '@/providers/telegram';
import { isTMA } from '@telegram-apps/sdk-react';
import { type PropsWithChildren } from 'react';
export default function Layout({ children }: Readonly<PropsWithChildren>) {
const isTG = isTMA('simple');
const Provider = isTG ? TelegramProvider : EmptyProvider;
export default async function Layout({ children }: Readonly<PropsWithChildren>) {
return (
<CheckBanned>
<Provider>
<UpdateProfile />
<main className="grow">{children}</main>
<BottomNav />
</Provider>
</CheckBanned>
<>
<UpdateProfile />
<main className="grow">{children}</main>
<BottomNav />
</>
);
}

View File

@ -1,3 +1,4 @@
import { getOrder } from '@/actions/api/orders';
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import {
@ -8,14 +9,23 @@ import {
OrderStatus,
} from '@/components/orders';
import { type OrderPageParameters } from '@/components/orders/types';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<OrderPageParameters> };
export default async function ProfilePage(props: Readonly<Props>) {
const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['order', documentId],
});
return (
<>
<HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Запись" />
<Container>
<OrderDateTime {...parameters} />
@ -25,6 +35,6 @@ export default async function ProfilePage(props: Readonly<Props>) {
<div className="pb-24" />
<OrderButtons {...parameters} />
</Container>
</>
</HydrationBoundary>
);
}

View File

@ -2,7 +2,7 @@ import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { OrderForm } from '@/components/orders';
export default function AddOrdersPage() {
export default async function AddOrdersPage() {
return (
<>
<PageHeader title="Новая запись" />

View File

@ -1,6 +1,6 @@
'use client';
import { Container } from '@/components/layout';
import { OrdersList } from '@/components/orders';
import { ClientsOrdersList, OrdersList } from '@/components/orders';
import { DateSelect } from '@/components/orders/orders-list/date-select';
import { DateTimeStoreProvider } from '@/stores/datetime';
@ -10,6 +10,7 @@ export default function ProfilePage() {
<DateTimeStoreProvider>
<div />
<DateSelect />
<ClientsOrdersList />
<OrdersList />
</DateTimeStoreProvider>
</Container>

View File

@ -1,156 +0,0 @@
import {
getSubscription,
getSubscriptionPrices,
getSubscriptions,
} from '@/actions/api/subscriptions';
import { getSessionUser } from '@/actions/session';
import { TryFreeButton } from '@/components/subscription';
import { env } from '@/config/env';
import { Enum_Subscriptionprice_Period as SubscriptionPricePeriod } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { formatMoney } from '@repo/utils/money';
import { ArrowRight, Crown, Infinity as InfinityIcon } from 'lucide-react';
import Link from 'next/link';
export default async function ProPage() {
const { telegramId } = await getSessionUser();
const { subscriptions } = await getSubscriptions({
filters: { customer: { telegramId: { eq: telegramId } } },
});
const hasActiveSubscription = subscriptions?.length
? ((await getSubscription({ telegramId }))?.hasActiveSubscription ?? false)
: false;
const canUseTrial = !subscriptions?.length;
const { subscriptionPrices = [] } = await getSubscriptionPrices({
filters: {
active: { eq: true },
period: { ne: SubscriptionPricePeriod.Trial },
},
});
const botUrl = new URL(env.BOT_URL);
botUrl.searchParams.set('start', 'pro');
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-100 dark:from-slate-900 dark:via-slate-800 dark:to-slate-900">
{/* Hero Section */}
<div className="px-4 py-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-4xl text-center">
<div className="mb-2 flex justify-center">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-purple-600 to-blue-600 opacity-30 blur-xl dark:from-purple-700 dark:to-blue-700" />
<div className="relative rounded-full bg-gradient-to-r from-purple-600 to-blue-600 p-4 dark:from-purple-700 dark:to-blue-700">
<Crown className="size-8 text-white" />
</div>
</div>
</div>
<h1 className="mb-4 text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-6xl">
<span className="bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent dark:from-purple-700 dark:to-blue-700">
Pro
</span>{' '}
Доступ
</h1>
<p className="mx-auto mb-6 max-w-2xl text-xl text-gray-600 dark:text-gray-300">
{hasActiveSubscription
? 'Ваш Pro доступ активен!'
: 'Разблокируйте больше возможностей'}
</p>
{!hasActiveSubscription && (
<div className="flex flex-col items-center justify-center gap-4 sm:flex-row">
{canUseTrial && <TryFreeButton />}
<Button
asChild
className={`w-full border-2 text-base font-semibold sm:w-auto ${
canUseTrial
? 'border-gray-300 text-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700'
: 'border-0 bg-gradient-to-r from-purple-600 to-blue-600 text-white hover:from-purple-700 hover:to-blue-700 dark:from-purple-700 dark:to-blue-700 dark:hover:from-purple-800 dark:hover:to-blue-800'
}`}
size="lg"
variant={canUseTrial ? 'outline' : 'default'}
>
<Link href={botUrl.toString()} rel="noopener noreferrer" target="_blank">
Приобрести Pro доступ через бота
<ArrowRight className="ml-2 size-5" />
</Link>
</Button>
</div>
)}
<div className="mx-auto mt-12 max-w-2xl">
<h2 className="mb-4 text-center text-2xl font-bold text-gray-900 dark:text-white">
Преимущества
</h2>
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
<div className="mt-1 shrink-0">
<InfinityIcon className="size-5 text-purple-600 dark:text-purple-400" />
</div>
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
Доступно неограниченное количество записей в месяц
</p>
</div>
{/* <div className="flex items-start gap-3 rounded-lg border border-gray-200 bg-white/50 p-4 dark:border-gray-700 dark:bg-slate-800/50">
<div className="mt-1 shrink-0">
<Star className="size-5 text-purple-600 dark:text-purple-400" />
</div>
<p className="text-left text-base leading-relaxed text-gray-700 dark:text-gray-300">
Профиль и аватар выделяются цветом
</p>
</div> */}
</div>
</div>
{subscriptionPrices?.length > 0 && (
<div className="mx-auto mt-12 max-w-2xl">
<h2 className="mb-4 text-center text-2xl font-bold text-gray-900 dark:text-white">
Цены
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{subscriptionPrices.map((price) => (
<div
className={`relative rounded-xl border bg-white/60 p-4 text-left dark:bg-slate-800/60 ${
price?.period === SubscriptionPricePeriod.Month
? 'border-2 border-purple-400'
: 'border-gray-200 dark:border-gray-700'
}`}
key={price?.documentId}
>
{price?.period === SubscriptionPricePeriod.Month && (
<div className="absolute -top-2 right-3 rounded-full bg-purple-600 px-2 py-0.5 text-xs font-semibold text-white dark:bg-purple-500">
Популярный
</div>
)}
<div className="flex items-baseline justify-between">
<div className="text-xl font-bold text-gray-900 dark:text-white">
{formatMoney(price?.amount ?? 0)}
</div>
{typeof price?.days === 'number' && (
<div className="text-sm text-gray-600 dark:text-gray-300">
{price.days} дн.
</div>
)}
</div>
{price?.description && (
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{price.description}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More