Compare commits

..

36 Commits

Author SHA1 Message Date
vchikalkin
3fb25cd667 update name on telegram first login 2025-01-20 18:06:43 +03:00
vchikalkin
c2ca2f9df5 telegram login: customer.active=true 2025-01-20 18:03:13 +03:00
vchikalkin
d15f42de1a contacts: loading spinner 2025-01-20 17:29:05 +03:00
vchikalkin
21b2e983eb do not open keyboard on page load 2025-01-15 18:38:24 +03:00
vchikalkin
30b0d4d394 app/bot: normalize phone before register 2025-01-15 18:31:28 +03:00
vchikalkin
d987c98b30 profile: add call feature 2025-01-15 18:20:56 +03:00
vchikalkin
53c74d565f profile: add button "message to telegram" 2025-01-15 18:15:04 +03:00
vchikalkin
2b1a499167 add page 'profile/[telegramId]' 2025-01-15 18:03:31 +03:00
vchikalkin
d00f8475f2 beautify actions 2025-01-15 17:23:17 +03:00
vchikalkin
7652ca9bb4 move updateRole closer to profile-card 2025-01-15 17:13:22 +03:00
vchikalkin
0a49f8dc7d refactor updateRole function 2025-01-15 17:00:50 +03:00
vchikalkin
248fdd0e41 profile: use react-query 2025-01-15 15:19:58 +03:00
vchikalkin
4af2ae4d4e replace zustand with @tanstack/react-query 2025-01-15 14:37:58 +03:00
vchikalkin
59e9d61e37 rename filteredContacts -> contacts 2025-01-14 19:32:50 +03:00
vchikalkin
f6c52c11c3 add loading spinner 2025-01-14 19:29:54 +03:00
vchikalkin
000cb77acd use zustand for contacts 2025-01-14 19:10:05 +03:00
vchikalkin
550c5474a3 get customer contacts 2025-01-14 15:40:31 +03:00
vchikalkin
0a7d9c75c0 add action getProfile 2025-01-13 20:03:01 +03:00
vchikalkin
af128f42c5 refactor customer queries 2025-01-13 19:50:25 +03:00
vchikalkin
ab6211825e fix app layout 2025-01-13 17:31:29 +03:00
vchikalkin
e48f0eb951 feat(apps/web): add basic /contacts page 2025-01-13 16:58:47 +03:00
vchikalkin
38bb5193f9 rename page 'masters' -> 'contacts' 2025-01-12 18:22:57 +03:00
vchikalkin
56a299c656 feat(apps/web): update user photo on app launch 2025-01-12 18:19:29 +03:00
vchikalkin
f1f1ac3183 fix(apps/web): actions getCustomer 2025-01-12 16:04:10 +03:00
vchikalkin
e31953ba57 fix(apps/web): user is undefined 2025-01-12 15:52:10 +03:00
vchikalkin
a8f5f47293 tests for createOrUpdateClient 2025-01-12 15:49:03 +03:00
vchikalkin
8d9ed72b91 packages/graphql: add api/customer tests 2025-01-12 15:34:37 +03:00
vchikalkin
cd027f29e6 apps/bot: remove api.ts -> move getCustomer to packages/graphql/api 2025-01-12 12:40:17 +03:00
vchikalkin
f1e12a48aa apps/bot: create or update functions 2025-01-10 19:39:47 +03:00
vchikalkin
0d3b513b69 Чтобы добавить контакт, сначала поделитесь своим номером телефона. 2025-01-10 19:10:29 +03:00
vchikalkin
f1c98fd271 apps/bot: check user already exists w/o telegramId (invited) 2025-01-10 19:06:30 +03:00
vchikalkin
db1dcbf581 app/bot: add contact define name & phone 2025-01-10 18:51:55 +03:00
vchikalkin
87ec038739 remove ';' 2025-01-10 18:45:15 +03:00
vchikalkin
23f5838c82 apps/bot: rename createCustomer -> createUser 2025-01-10 18:44:14 +03:00
vchikalkin
b1321e751d apps/bot: check role 'master' before add contact 2025-01-10 18:40:58 +03:00
vchikalkin
d723758704 apps/bot: add feature add contact 2025-01-10 18:35:06 +03:00
326 changed files with 4217 additions and 23548 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 +1,7 @@
import { typescript } from '@repo/eslint-config/typescript';
import { reactConfig } from '@repo/eslint-config/react-internal';
/** @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',
},
},
...reactConfig,
];

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

@ -2,46 +2,30 @@
"name": "bot",
"version": "0.0.0",
"type": "module",
"main": "dist/index.js",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "tsup",
"build": "rimraf ./build & tsc -p tsconfig.json",
"build:watch": "tsc -w -p tsconfig.json",
"start": "node dist/src/index.js",
"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:",
"telegraf": "^4.16.3",
"zod": "catalog:"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/graphql": "workspace:*",
"@repo/lint-staged-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/node": "catalog:",
"dotenv-cli": "catalog:",
"lint-staged": "catalog:",
"tsx": "^4.19.2"
"rimraf": "catalog:",
"tsx": "^4.19.2",
"typescript": "catalog:"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,46 +1,96 @@
import { createBot } from './bot';
/* eslint-disable canonical/id-match */
/* eslint-disable consistent-return */
import { env as environment } from './config/env';
import { logger } from './utils/logger';
import { getRedisInstance } from './utils/redis';
import { run } from '@grammyjs/runner';
import { commandsList, KEYBOARD_REMOVE, KEYBOARD_SHARE_PHONE } from './message';
import { normalizePhoneNumber } from './utils/phone';
import { createOrUpdateUser, getCustomer, updateCustomerMaster } from '@repo/graphql/api';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { Telegraf } from 'telegraf';
import { message } from 'telegraf/filters';
const bot = createBot({
token: environment.BOT_TOKEN,
});
const bot = new Telegraf(environment.BOT_TOKEN);
bot.catch((error) => {
logger.error('Grammy bot error:');
logger.error(`Message: ${error?.message}`);
logger.error(error.error);
});
bot.start(async (context) => {
const customer = await getCustomer({ telegramId: context.from.id });
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 || '');
if (customer) {
return context.reply(
`Приветствуем снова, ${customer.name} 👋.
Чтобы воспользоваться сервисом, откройте приложение.` + commandsList,
KEYBOARD_REMOVE,
);
}
}
process.once('SIGINT', () => gracefulShutdown('SIGINT'));
process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled Rejection: ' + reason);
return context.reply(
'Добро пожаловать! Пожалуйста, поделитесь своим номером телефона.',
KEYBOARD_SHARE_PHONE,
);
});
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception: ' + error);
bot.command('addcontact', async (context) => {
const customer = await getCustomer({ telegramId: context.from.id });
if (!customer) {
return context.reply(
'Чтобы добавить контакт, сначала поделитесь своим номером телефона.',
KEYBOARD_SHARE_PHONE,
);
}
return context.reply('Отправьте контакт клиента, которого вы хотите добавить');
});
logger.info('Bot started');
bot.on(message('contact'), async (context) => {
const customer = await getCustomer({ telegramId: context.from.id });
const isRegistration = !customer;
const { contact } = context.message;
const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim();
const phone = normalizePhoneNumber(contact.phone_number);
if (isRegistration) {
const response = await createOrUpdateUser({
name,
phone,
telegramId: context.from.id,
}).catch((error) => {
context.reply('Произошла ошибка.\n' + error);
});
if (response) {
return context.reply(
`Спасибо! Мы сохранили ваш номер телефона. Теперь можете открыть приложение или воспользоваться командами бота.` +
commandsList,
KEYBOARD_REMOVE,
);
}
} else {
if (customer.role !== Enum_Customer_Role.Master) {
return context.reply(
'Только мастер может добавлять контакты. \nСтать мастером можно на странице профиля в приложении.',
);
}
try {
await createOrUpdateUser({ name, phone });
await updateCustomerMaster({
masterId: customer.documentId,
operation: 'add',
phone,
});
return context.reply(
`Добавили контакт ${name}. Пригласите пользователя в приложение и тогда вы сможете добавлять записи с этим контактом.`,
);
} catch (error) {
context.reply('Произошла ошибка.\n' + error);
}
}
});
bot.launch();
// Enable graceful stop
process.once('SIGINT', () => bot.stop('SIGINT'));
process.once('SIGTERM', () => bot.stop('SIGTERM'));

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

@ -0,0 +1,26 @@
import { type ReplyKeyboardRemove } from 'telegraf/types';
export const commandsList = `
\оступные команды:
/addcontact - Добавить контакт клиента
`;
export const KEYBOARD_SHARE_PHONE = {
reply_markup: {
keyboard: [
[
{
request_contact: true,
text: 'Отправить номер телефона',
},
],
],
one_time_keyboard: true,
},
};
export const KEYBOARD_REMOVE = {
reply_markup: {
remove_keyboard: true,
} as ReplyKeyboardRemove,
};

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

@ -0,0 +1,5 @@
export function normalizePhoneNumber(phone: string): string {
const digitsOnly = phone.replaceAll(/\D/gu, '');
return `+${digitsOnly}`;
}

View File

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

View File

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

View File

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

View File

@ -5,12 +5,12 @@
"outDir": "dist",
"alwaysStrict": true,
"strict": true,
"moduleResolution": "Node",
"module": "CommonJS",
"moduleResolution": "bundler",
"module": "ES2020",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./*"]
}
},
"include": ["."],
"include": [".", "../../packages/graphql/config", "../../packages/graphql/utils", "../../packages/graphql/apollo", "../../packages/graphql/api"],
"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

@ -0,0 +1,30 @@
'use server';
import { authOptions } from '@/config/auth';
import { getCustomerClients, getCustomerMasters } from '@repo/graphql/api';
import { getServerSession } from 'next-auth/next';
export async function getClients() {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const getCustomerClientsResponse = await getCustomerClients({ telegramId: user?.telegramId });
return {
clients: getCustomerClientsResponse?.clients,
};
}
export async function getMasters() {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const getCustomerMastersResponse = await getCustomerMasters({ telegramId: user?.telegramId });
return {
masters: getCustomerMastersResponse?.masters,
};
}

View File

@ -0,0 +1,35 @@
'use server';
import { authOptions } from '@/config/auth';
import { getCustomer, updateCustomerProfile } from '@repo/graphql/api';
import { type CustomerInput, type GetCustomerQueryVariables } from '@repo/graphql/types';
import { getServerSession } from 'next-auth/next';
import { revalidatePath } from 'next/cache';
export async function getProfile(input?: GetCustomerQueryVariables) {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const telegramId = input?.telegramId || user?.telegramId;
const customer = await getCustomer({ telegramId });
return customer;
}
export async function updateProfile(input: CustomerInput) {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const customer = await getCustomer({ telegramId: user?.telegramId });
if (!customer) throw new Error('Customer not found');
await updateCustomerProfile({
data: input,
documentId: customer.documentId,
});
revalidatePath('/profile');
}

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

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

View File

@ -1,5 +1,4 @@
'use client';
import { useClientOnce } from '@/hooks/telegram';
import { isTMA } from '@telegram-apps/sdk-react';
import { redirect } from 'next/navigation';
@ -10,6 +9,4 @@ export default function Page() {
redirect(isTG ? '/telegram' : '/browser');
});
return 'Redirecting...';
}

View File

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

View File

@ -1,84 +1,64 @@
/* eslint-disable promise/prefer-await-to-then */
'use client';
import { getProfile, updateProfile } from '@/actions/profile';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react';
import { signIn, type SignInResponse, useSession } from 'next-auth/react';
import { signIn, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect } from 'react';
import { redirect } from 'next/navigation';
import { useEffect, useState } 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 { status } = useSession();
const { setTheme } = useTheme();
const [isUpdating, setIsUpdating] = useState(true);
useEffect(() => {
setTheme(isDark ? 'dark' : 'light');
}, [isDark, setTheme]);
const update = async () => {
if (initDataUser?.photoUrl) {
await updateProfile({ photoUrl: initDataUser.photoUrl });
}
const customer = await getProfile({ telegramId: initDataUser?.id });
if (!customer?.active) {
await updateProfile({
active: true,
name: `${initDataUser?.firstName || ''} + ' ' + ${initDataUser?.lastName}`.trim(),
});
}
setIsUpdating(false);
};
update();
}, [
initDataUser?.firstName,
initDataUser?.id,
initDataUser?.lastName,
initDataUser?.photoUrl,
isDark,
setTheme,
]);
useEffect(() => {
if (isUpdating) return;
if (status === 'authenticated') {
redirect('/profile');
}
if (status === 'unauthenticated' && initDataUser?.id) {
signIn('telegram', {
callbackUrl: '/profile',
redirect: false,
telegramId: String(initDataUser.id),
});
}
}, [initDataUser?.id, isUpdating, status]);
return <LoadingSpinner />;
}

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

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