Compare commits

..

2 Commits

Author SHA1 Message Date
vchikalkin
c43a609b8d remove all official template trash 2024-12-11 15:53:38 +03:00
vchikalkin
9ab9880c0f add files from official template 2024-12-10 20:08:38 +03:00
334 changed files with 3031 additions and 26503 deletions

View File

@ -1,227 +0,0 @@
name: Build & Deploy Web, Bot & Cache Proxy
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
run: |
echo "BOT_TOKEN=fake" > .env
echo "LOGIN_GRAPHQL=fake" >> .env
echo "PASSWORD_GRAPHQL=fake" >> .env
echo "URL_GRAPHQL=http://localhost/graphql" >> .env
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 .
- 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 }}
- 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 .
- 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 }}
# -------------------------------------------------
deploy:
name: Deploy to VPS
needs: build-and-push
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.VPS_SSH_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -p ${{ secrets.VPS_PORT }} -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
- name: Ensure zapishis directory exists on VPS
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
run: |
# Включаем все секреты, КРОМЕ тегов
echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env
echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env
echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env
echo "URL_GRAPHQL=${{ secrets.URL_GRAPHQL }}" >> .env
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)
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'
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:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
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 compose down && \
docker compose up -d
"

1
.gitignore vendored
View File

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

34
.vscode/launch.json vendored
View File

@ -1,34 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"port": 9230,
"request": "attach",
"skipFiles": [
"<node_internals>/**"
],
"type": "node",
"sourceMapPathOverrides": {
"/turbopack/[project]/*": "${webRoot}/*"
}
},
{
"type": "chrome",
"request": "launch",
"name": "Next.js: debug client-side",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}",
"sourceMapPathOverrides": {
"/turbopack/[project]/*": "${webRoot}/*"
}
}
],
"compounds": [
{
"name": "Next.js: debug full stack",
"configurations": ["Next.js: debug client-side", "Next.js: debug server-side"],
"stopAll": true
}
]
}

View File

@ -1,6 +0,0 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.git

View File

@ -1,51 +0,0 @@
ARG NODE_VERSION=22
ARG PROJECT=bot
# 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 botuser
USER botuser
WORKDIR /app
COPY --from=builder --chown=nodejs:nodejs /app .
WORKDIR /app/apps/${PROJECT}
CMD ["node", "dist/index.cjs"]

View File

@ -1,14 +0,0 @@
import { typescript } from '@repo/eslint-config/typescript';
/** @type {import("eslint").Linter.Config} */
export default [
...typescript,
{
ignores: ['**/types/**', '*.config.*'],
rules: {
'@typescript-eslint/naming-convention': 'off',
'unicorn/prevent-abbreviations': 'off',
'canonical/id-match': 'off',
},
},
];

View File

@ -1 +0,0 @@
export { default } from '@repo/lint-staged-config/config';

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

@ -1,47 +0,0 @@
{
"name": "bot",
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"license": "MIT",
"scripts": {
"build": "tsup",
"dev": "dotenv -e ../../.env.local tsx watch src/index.ts",
"start": "node dist/index.cjs",
"lint": "eslint",
"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:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/lint-staged-config": "workspace:*",
"dotenv-cli": "catalog:",
"lint-staged": "catalog:",
"tsx": "^4.19.2"
}
}

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 +0,0 @@
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 +0,0 @@
import { createBot } from './bot';
import { env as environment } from './config/env';
import { logger } from './utils/logger';
import { getRedisInstance } from './utils/redis';
import { run } from '@grammyjs/runner';
const bot = createBot({
token: environment.BOT_TOKEN,
});
bot.catch((error) => {
logger.error('Grammy bot error:');
logger.error(`Message: ${error?.message}`);
logger.error(error.error);
});
const runner = run(bot);
const redis = getRedisInstance();
async function gracefulShutdown(signal: string) {
logger.info(`Received ${signal}, starting graceful shutdown...`);
try {
await runner.stop();
logger.info('Bot stopped');
redis.disconnect();
logger.info('Redis disconnected');
} catch (error) {
const err_ = error as Error;
logger.error('Error during graceful shutdown:' + err_.message || '');
}
}
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);
});
logger.info('Bot started');

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

@ -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

@ -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

@ -1,16 +0,0 @@
{
"extends": "@repo/typescript-config/base.json",
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"alwaysStrict": true,
"strict": true,
"moduleResolution": "Node",
"module": "CommonJS",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["."],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -1,16 +0,0 @@
import { defineConfig } from 'tsup';
export default defineConfig({
bundle: true,
clean: true,
entry: ['./src/index.ts'],
external: ['telegraf', 'zod'],
format: 'cjs',
loader: { '.json': 'copy' },
minify: false,
noExternal: ['@repo'],
outDir: './dist',
sourcemap: false,
splitting: false,
target: 'es2022',
});

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,7 +0,0 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

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

@ -1,56 +0,0 @@
ARG NODE_VERSION=22
ARG PROJECT=web
# 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 openssl && \
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 nextjs
USER nextjs
WORKDIR /app
COPY --from=builder /app/apps/${PROJECT}/next.config.js .
COPY --from=builder /app/apps/${PROJECT}/package.json .
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/.next/static ./apps/${PROJECT}/.next/static
COPY --from=builder --chown=nextjs:nodejs /app/apps/${PROJECT}/public ./apps/${PROJECT}/public
WORKDIR /app/apps/${PROJECT}
CMD node server.js

View File

@ -1,9 +0,0 @@
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 getCustomer = wrapClientAction(customers.getCustomer);
export const getCustomers = wrapClientAction(customers.getCustomers);
export const getInvitedBy = wrapClientAction(customers.getInvitedBy);
export const updateCustomer = wrapClientAction(customers.updateCustomer);

View File

@ -1,14 +0,0 @@
import { authOptions } from '@/config/auth';
import { type BaseService } from '@repo/graphql/api/base';
import { getServerSession } from 'next-auth';
export function useService<T extends typeof BaseService>(service: T) {
return async function () {
const session = await getServerSession(authOptions);
if (!session?.user?.telegramId) throw new Error('Unauthorized');
const customer = { telegramId: session.user.telegramId };
return new service(customer) as InstanceType<T>;
};
}

View File

@ -1,7 +0,0 @@
import * as orders from './server/orders';
import { wrapClientAction } from '@/utils/actions';
export const createOrder = wrapClientAction(orders.createOrder);
export const getOrder = wrapClientAction(orders.getOrder);
export const getOrders = wrapClientAction(orders.getOrders);
export const updateOrder = wrapClientAction(orders.updateOrder);

View File

@ -1,43 +0,0 @@
'use server';
import { useService } from '../lib/service';
import { wrapServerAction } from '@/utils/actions';
import { CustomersService } from '@repo/graphql/api/customers';
const getService = useService(CustomersService);
export async function addInvitedBy(...variables: Parameters<CustomersService['addInvitedBy']>) {
const service = await getService();
return wrapServerAction(() => service.addInvitedBy(...variables));
}
export async function getCustomer(...variables: Parameters<CustomersService['getCustomer']>) {
const service = await getService();
return wrapServerAction(() => service.getCustomer(...variables));
}
export async function getCustomers(...variables: Parameters<CustomersService['getCustomers']>) {
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));
}
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {
const service = await getService();
return wrapServerAction(() => service.updateCustomer(...variables));
}

View File

@ -1,31 +0,0 @@
'use server';
import { useService } from '../lib/service';
import { wrapServerAction } from '@/utils/actions';
import { OrdersService } from '@repo/graphql/api/orders';
const getServicesService = useService(OrdersService);
export async function createOrder(...variables: Parameters<OrdersService['createOrder']>) {
const service = await getServicesService();
return wrapServerAction(() => service.createOrder(...variables));
}
export async function getOrder(...variables: Parameters<OrdersService['getOrder']>) {
const service = await getServicesService();
return wrapServerAction(() => service.getOrder(...variables));
}
export async function getOrders(...variables: Parameters<OrdersService['getOrders']>) {
const service = await getServicesService();
return wrapServerAction(() => service.getOrders(...variables));
}
export async function updateOrder(...variables: Parameters<OrdersService['updateOrder']>) {
const service = await getServicesService();
return wrapServerAction(() => service.updateOrder(...variables));
}

View File

@ -1,31 +0,0 @@
'use server';
import { useService } from '../lib/service';
import { wrapServerAction } from '@/utils/actions';
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();
return wrapServerAction(() => service.getService(...variables));
}
export async function getServices(...variables: Parameters<ServicesService['getServices']>) {
const service = await getServicesService();
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,45 +0,0 @@
'use server';
import { useService } from '../lib/service';
import { wrapServerAction } from '@/utils/actions';
import { SlotsService } from '@repo/graphql/api/slots';
const getService = useService(SlotsService);
export async function createSlot(...variables: Parameters<SlotsService['createSlot']>) {
const service = await getService();
return wrapServerAction(() => service.createSlot(...variables));
}
export async function deleteSlot(...variables: Parameters<SlotsService['deleteSlot']>) {
const service = await getService();
return wrapServerAction(() => service.deleteSlot(...variables));
}
export async function getAvailableTimeSlots(
...variables: Parameters<SlotsService['getAvailableTimeSlots']>
) {
const service = await getService();
return wrapServerAction(() => service.getAvailableTimeSlots(...variables));
}
export async function getSlot(...variables: Parameters<SlotsService['getSlot']>) {
const service = await getService();
return wrapServerAction(() => service.getSlot(...variables));
}
export async function getSlots(...variables: Parameters<SlotsService['getSlots']>) {
const service = await getService();
return wrapServerAction(() => service.getSlots(...variables));
}
export async function updateSlot(...variables: Parameters<SlotsService['updateSlot']>) {
const service = await getService();
return wrapServerAction(() => service.updateSlot(...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

@ -1,7 +0,0 @@
import * as services from './server/services';
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,9 +0,0 @@
import * as slots from './server/slots';
import { wrapClientAction } from '@/utils/actions';
export const getSlot = wrapClientAction(slots.getSlot);
export const getSlots = wrapClientAction(slots.getSlots);
export const createSlot = wrapClientAction(slots.createSlot);
export const updateSlot = wrapClientAction(slots.updateSlot);
export const deleteSlot = wrapClientAction(slots.deleteSlot);
export const getAvailableTimeSlots = wrapClientAction(slots.getAvailableTimeSlots);

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

@ -1,16 +0,0 @@
'use server';
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('/');
}
return user;
}

11
apps/web/apollo.config.js Normal file
View File

@ -0,0 +1,11 @@
/* eslint-disable no-undef */
module.exports = {
client: {
excludes: ['**/graphql/**/schema.graphql'],
includes: ['**/app/**/*', '**/graphql/**/query.graphql'],
service: {
name: 'strapi',
url: 'http://localhost:1337/graphql',
},
},
};

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

@ -1,43 +0,0 @@
/* eslint-disable promise/prefer-await-to-then */
'use client';
import { getTelegramUser } from '@/mocks/get-telegram-user';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { signIn, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { redirect } from 'next/navigation';
import { useEffect } from 'react';
export default function Auth() {
const { status } = useSession();
useTheme();
useEffect(() => {
if (status === 'authenticated') {
redirect('/profile');
}
if (status === 'unauthenticated' && process.env.NODE_ENV === 'development') {
getTelegramUser().then((user) => {
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');
}
});
});
}
}, [status]);
return <LoadingSpinner />;
}

View File

@ -1,15 +0,0 @@
'use client';
import { useClientOnce } from '@/hooks/telegram';
import { isTMA } from '@telegram-apps/sdk-react';
import { redirect } from 'next/navigation';
export default function Page() {
useClientOnce(() => {
const isTG = isTMA('simple');
redirect(isTG ? '/telegram' : '/browser');
});
return 'Redirecting...';
}

View File

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

View File

@ -1,84 +0,0 @@
/* 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 { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react';
export default function Auth() {
useTelegramTheme();
useTelegramAuth();
return <LoadingSpinner />;
}
/**
* Хук для авторизации пользователя через NextAuth
*/
function useTelegramAuth() {
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],
);
useEffect(() => {
const telegramId = initDataUser?.id;
if (!telegramId) 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;
}
if (status === 'unauthenticated') {
void signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: telegramId.toString(),
}).then(handleSignInResult);
}
}, [initDataUser?.id, status, session?.user?.telegramId, router, handleSignInResult]);
}
/**
* Хук для установки темы из Telegram Mini App
*/
function useTelegramTheme() {
const isDark = isMiniAppDark();
const { setTheme } = useTheme();
useEffect(() => {
setTheme(isDark ? 'dark' : 'light');
}, [isDark, setTheme]);
}

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>;
}

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