1 Commits

Author SHA1 Message Date
1323ed5
8ff01f5f68 Merge pull request 'main' (#35) from main into refactoring
Reviewed-on: #35
2024-12-04 20:10:47 +00:00
125 changed files with 4840 additions and 12168 deletions

View File

@@ -1,18 +0,0 @@
node_modules
db
uploads
.env
.env.*
!.env.example
.git
.kilo
.architect
kilo-meta.json
kilo.jsonc
AGENTS.md
corrupt-photo.jpg
wg/config
*.log
__pycache__
**/__tests__
**/*.test.js

View File

@@ -1,56 +0,0 @@
# ============================================================
# Telegram Shop - Environment Configuration (TEMPLATE)
# ============================================================
# Копируй этот файл в .env и заполни реальными значениями.
# ВНИМАНИЕ: .env файлы НЕ коммитятся — они в .gitignore.
# ============================================================
# --- Telegram Bot ---
BOT_TOKEN=your_bot_token_here
ADMIN_IDS=123456789,987654321
SUPER_ADMIN_IDS=123456789
SUPPORT_LINK=https://t.me/your_support
# --- Catalog ---
CATALOG_PATH=./catalog
# --- Encryption (ОБЯЗАТЕЛЬНО! Без этого приложение упадёт) ---
# Сгенерируй надёжный ключ: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=
# --- Commission ---
COMMISSION_ENABLED=true
COMMISSION_PERCENT=5
# --- Commission Wallets ---
COMMISSION_WALLET_BTC=
COMMISSION_WALLET_LTC=
COMMISSION_WALLET_USDT=
COMMISSION_WALLET_USDC=
COMMISSION_WALLET_ETH=
# --- ChangeNOW Deposit Integration ---
# Optional: your ChangeNOW referral ID (leave empty if none)
CHANGENOW_REF=
# --- WireGuard ---
WG_ENABLED=false
WG_PRIVATE_KEY=
WG_PUBLIC_KEY=
WG_PRESHARED_KEY=
WG_ENDPOINT=
WG_ADDRESS=
WG_DNS=
WG_ALLOWED_IPS=0.0.0.0/0,::/0
# --- Tor Proxy ---
# SSH backend: куда Tor перенаправляет SSH (по умолчанию хост-машина)
SSH_HOST_IP=host.docker.internal
# Имя контейнера магазина (для проброса админки через Tor)
SHOP_CONTAINER=telegram_shop_prod
# Порт админ-панели внутри контейнера магазина
ADMIN_PORT=3001
# --- Gitea API (для CI/CD и пайплайна) ---
GITEA_API_URL=https://git.softuniq.eu/api/v1
GITEA_TOKEN=

47
.gitignore vendored
View File

@@ -1,46 +1 @@
# Dependencies db
node_modules/
# Environment
.env
.env.*
!.env.example
# Secrets & sensitive data
docker-compose.override.yml
wg/
dump/
dump.zip
*.csv
# Database
db/
*.db
# Logs
*.log
# OS
.DS_Store
# Kilo Code — entire directory (agents, skills, rules, workflows, etc.)
.kilo/
# Kilo Code — project-level config files (not part of telegram-shop source)
kilo-meta.json
kilo.jsonc
AGENTS.md
# Architect generated maps
.architect/
# Local workspace / worktrees
.work/
# Tor onion addresses (secret)
tor-proxy/hosts/onion-hosts.txt
# Python cache
__pycache__/
*.pyc
*.pyo

View File

@@ -1,46 +1,11 @@
FROM node:22-alpine AS builder FROM node:22
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json /app/
COPY src/ /app/src/
#COPY db/shop.db /app/shop.db
RUN apk add --no-cache --virtual .build-deps \ RUN npm install
python3 \
make \
g++ \
gcc \
linux-headers \
git \
py3-setuptools \
&& npm install --omit=dev \
&& apk del .build-deps
# ============================================================ CMD ["node", "src/index.js"]
# Runtime image
# ============================================================
FROM node:22-alpine
RUN apk add --no-cache \
bash \
bind-tools \
curl \
iptables \
iproute2 \
openresolv \
wireguard-tools
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
COPY ./src ./src
COPY ./wg/start.sh /app/start.sh
RUN chmod +x /app/start.sh
RUN mkdir -p /app/db /app/uploads
EXPOSE 3001
CMD ["/bin/bash", "/app/start.sh"]

376
README.md
View File

@@ -1,334 +1,90 @@
# Telegram Shop Bot **Универсальный Телеграмм Магазин**
Телеграм-бот для организации онлайн-продаж через Telegram с поддержкой криптовалют, WireGuard VPN и Tor-прокси для доступа к админ-панели через onion-адрес. **Описание проекта**:
"Универсальный Телеграмм Магазин" — это телеграмм-бот, предназначенный для организации и управления онлайн-продажами товаров и услуг через популярную платформу Telegram. Магазин включает функционал как для пользователей, так и для администраторов, обеспечивая удобное взаимодействие с товарами, балансами, кошельками и покупками.
## Возможности Проект включает несколько ключевых разделов для удобной работы пользователей и администраторов, а также позволяет интегрировать систему криптокошельков для расчетов, управления товарами и отслеживания покупок.
- Каталог товаров с категориями и фильтрацией по локациям ### Цели проекта:
- Покупки с оплатой криптовалютами (BTC, ETH, LTC, USDT, USDC) - Создание удобного и универсального интерфейса для покупок через Telegram.
- Управление криптокошельками (создание, пополнение, баланс) - Обеспечение безопасности и простоты транзакций с использованием криптовалют и традиционных средств.
- История транзакций и покупок - Внедрение эффективной системы управления для администраторов, с возможностью мониторинга пользователей, товаров, кошельков и комиссий.
- SaaS-система с автоматическим расчётом комиссий - Реализация системы профилей с возможностью редактирования, управления балансами и удаления аккаунтов.
- **Мультиязычность (i18n)** — английский, испанский, немецкий с переключением в боте
- Админ-панель на порту 3001 с вкладкой локализации
- Tor-прокси с двумя onion-сервисами (SSH + админка)
- WireGuard VPN для безопасных транзакций
## Быстрый старт (одна команда) ---
### Требования ### Структура проекта:
- Любое устройство с Docker: x86_64 (PC, сервер) или ARM64 (Orange Pi, Raspberry Pi) #### 1. **Пользовательский раздел**
- 512 МБ RAM минимум (Orange Pi Zero 2 поддерживается) Пользователи могут:
- Просматривать и покупать товары, управлять своим балансом.
- Следить за историей покупок.
- Пополнять свои криптокошельки.
- Управлять своим профилем, изменяя локацию и удаляя аккаунт.
### Установка #### 2. **Административный раздел**
Администраторы могут:
- Управлять пользователями: блокировать, удалять и редактировать балансы.
- Управлять товарами: добавлять, редактировать, удалять товары и категории.
- Управлять кошельками: контролировать пополнения и комиссионные платежи.
- Создавать дампы для переноса базы данных магазина.
```bash ---
git clone <repo-url> && cd telegram-shop
bash install.sh
```
Скрипт автоматически: ### Основной функционал:
1. Определит архитектуру (x86_64 / ARM64 / ARMv7)
2. Установит Docker если не установлен
3. Создаст `.env` из шаблона
4. Проверит обязательные переменные
5. Соберёт Docker-образ под текущую архитектуру
6. Запустит контейнер и проверит health-check
### Ручная установка #### 1. **Покупки и товары**
- **Продукты**: Пользователи могут выбирать товары по категориям, проверять наличие средств и совершать покупки.
- **Профиль**: В разделе профиля можно изменять локацию, а также удалять аккаунт.
- **История покупок**: Пользователи могут отслеживать свои покупки с описанием товаров и статусов.
- **Кошельки**: Возможность добавлять новые криптокошельки, пополнять их через QR-коды и просматривать историю транзакций.
```bash #### 2. **Администрирование**
# 1. Клонировать - **Управление пользователями**: Администратор может просматривать информацию о пользователях, управлять их балансами, блокировать или удалять аккаунты.
git clone <repo-url> && cd telegram-shop - **Управление товарами**: Добавление новых товаров, редактирование существующих и управление их категориями.
- **Создание дампов**: Администратор может создать дамп магазина, чтобы перенести данные на другой сервер или сохранить их для архивации.
# 2. Создать .env из шаблона #### 3. **Работа с криптовалютами**
cp .env.example .env - Поддержка различных типов криптокошельков (биткойн, эфириум, лайткоин и другие).
nano .env # заполнить BOT_TOKEN, ADMIN_IDS, ENCRYPTION_KEY - Проверка баланса кошельков через общедоступные API.
- Управление комиссионными, которые необходимы для загрузки дампа магазина.
# 3. Собрать и запустить ---
docker compose up -d --build
# 4. Проверить статус ### Требования к системе:
docker compose ps 1. **Интерфейс пользователя**:
curl http://localhost:3001/health - Интуитивно понятный и удобный интерфейс для покупок.
``` - Легкость в управлении профилем и кошельками.
- Информация о товарах и статусах покупок должна быть легко доступна.
## Настройка .env 2. **Интерфейс администратора**:
- Возможность редактировать товары, категории и управлять локациями.
- Инструменты для контроля баланса и управления пользователями.
- Функционал для создания и загрузки дампов данных.
Скопируйте `.env.example` в `.env` и заполните: 3. **Безопасность**:
- Защищенные транзакции.
- Надежная система для хранения данных пользователей и кошельков.
- Механизмы для предотвращения мошенничества и атак.
| Переменная | Обязательно | Описание | 4. **Производительность**:
|---|---|---| - Система должна быть способна обрабатывать большое количество пользователей и транзакций одновременно.
| `BOT_TOKEN` | ✅ | Токен Telegram бота (@BotFather) | - Пагинация данных, чтобы обеспечить быструю загрузку и обработку.
| `ADMIN_IDS` | ✅ | ID администраторов через запятую |
| `ENCRYPTION_KEY` | ✅ | Ключ шифрования (32 байта hex) |
| `ADMIN_SECRET` | ✅ | Секрет для админ-панели |
| `ADMIN_PORT` | — | Порт админ-панели (по умолчанию 3001) |
| `ADMIN_URL` | — | Полный URL админ-панели (для фото товаров) |
| `SUPER_ADMIN_IDS` | — | ID супер-админов |
| `SUPPORT_LINK` | — | Ссылка на поддержку |
| `DEFAULT_LANGUAGE` | — | Язык по умолчанию (`en`, `es`, `de`; по умолчанию `en`) |
| `SSH_HOST_IP` | — | Куда Tor перенаправляет SSH (по умолчанию host.docker.internal) |
| `SHOP_CONTAINER` | — | Имя контейнера магазина (по умолчанию telegram_shop_prod) |
| `WG_ENABLED` | — | `true` / `false` (по умолчанию `false`) |
| `WG_PRIVATE_KEY` | — | Приватный ключ WireGuard |
| `WG_PUBLIC_KEY` | — | Публичный ключ WireGuard |
| `WG_PRESHARED_KEY` | — | Pre-shared ключ WireGuard |
| `WG_ENDPOINT` | — | Адрес сервера WireGuard |
| `WG_ADDRESS` | — | Адрес интерфейса WireGuard |
| `WG_DNS` | — | DNS для WireGuard |
Генерация ключа шифрования: ---
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
## Tor Proxy ### Риски и возможные проблемы:
1. **Зависимость от сторонних сервисов**:
- Интеграция с криптокошельками и сторонними сервисами для проверки баланса может быть подвержена сбоям, если эти сервисы не работают корректно.
Проект включает Tor-прокси для доступа к SSH и админ-панели через onion-адреса. 2. **Поддержка разных криптовалют**:
- Необходимо следить за изменениями в протоколах криптовалют и своевременно обновлять систему.
### Архитектура 3. **Безопасность и защита данных**:
- Важно следить за актуальностью средств защиты данных и предотвратить утечку информации о пользователях и их балансе.
``` ---
Internet → Tor Network → tor-proxy контейнер
├── Onion #1 :22 → хост SSH
└── Onion #2 :80 → telegram_shop_prod:3001
(через Docker сеть tor_proxy_net)
```
### Файлы Tor-прокси ### Заключение:
**Универсальный Телеграмм Магазин** предоставляет эффективное решение для организации торговых процессов в Telegram, с возможностью работы с криптовалютами и традиционными средствами. Проект ориентирован на пользователей, которые ценят удобство, безопасность и скорость совершения покупок. Для администраторов — это мощный инструмент для управления товаром, пользователями и финансовыми потоками магазина.
| Файл | Назначение |
|---|---|
| `tor-proxy/Dockerfile` | Alpine + Tor образ |
| `tor-proxy/entrypoint.sh` | Генерация torrc из env vars, валидация, запись onion-адресов |
| `tor-proxy/get-onions.sh` | Скрипт чтения onion-адресов и обновления .env |
| `tor-proxy/hosts/` | Директория для onion-hosts.txt (bind mount) |
### После запуска
Onion-адреса автоматически сохраняются в `tor-proxy/hosts/onion-hosts.txt`. Обновить `.env`:
```bash
./tor-proxy/get-onions.sh
```
Вывод:
```
============================================================
Onion services
============================================================
SSH : xxxxx.onion (port 22 -> host SSH)
Admin : yyyyy.onion (port 80 -> telegram_shop_prod:3001)
============================================================
Usage:
SSH : torify ssh user@xxxxx.onion
Admin : open http://yyyyy.onion in Tor Browser
```
### Переменные Tor
| Переменная | По умолчанию | Описание |
|---|---|---|
| `SSH_HOST_IP` | `host.docker.internal` | Куда Tor перенаправляет SSH |
| `SHOP_CONTAINER` | `telegram_shop_prod` | Контейнер магазина |
| `ADMIN_PORT` | `3001` | Порт админки |
## Поддерживаемые устройства
| Устройство | Архитектура | RAM | Статус |
|---|---|---|---|
| PC / Сервер | x86_64 | ≥ 512 МБ | ✅ |
| Orange Pi Zero 2 | ARM64 (H616) | 512 МБ | ✅ |
| Raspberry Pi 4 | ARM64 | ≥ 1 ГБ | ✅ |
| Raspberry Pi 3 | ARM64 | 1 ГБ | ✅ |
| Raspberry Pi 2 | ARMv7 | 1 ГБ | ✅ |
Docker автоматически собирает нативные модули (`better-sqlite3`, `tiny-secp256k1`) под архитектуру хоста.
## Архитектура Docker
```
┌──────────────────────────────────────────────────────────┐
│ docker-compose │
│ │
│ ┌────────────────────────┐ ┌─────────────────────────┐ │
│ │ telegram_shop_prod │ │ tor-proxy │ │
│ │ (node:22-alpine) │ │ (alpine:3.18 + tor) │ │
│ │ │ │ │ │
│ │ Port 3001 │◄─┤ HiddenService :80 │ │
│ │ Bot + Admin Panel │ │ HiddenService :22 → SSH│ │
│ │ │ │ │ │
│ │ Net: default │ │ Net: default + proxy_net │ │
│ │ + tor_proxy_net │ │ │ │
│ └────────────────────────┘ └─────────────────────────┘ │
│ │ │ │
│ Volumes: Volumes: │
│ db/, uploads/, .env tor_data, hosts/ │
└──────────────────────────────────────────────────────────┘
```
### Сети Docker
| Сеть | Назначение |
|---|---|
| `default` | Внутренняя связь между контейнерами |
| `tor_proxy_net` | Связь tor-proxy ↔ telegram_shop_prod |
## Команды управления
```bash
# Запуск
docker compose up -d
# Пересборка после изменений
docker compose up -d --build
# Логи
docker compose logs -f
# Логи конкретного сервиса
docker compose logs -f tor-proxy
docker compose logs -f telegram_shop_prod
# Стоп
docker compose down
# Рестарт
docker compose restart
# Статус
docker compose ps
# Health-check
curl http://localhost:3001/health
# Onion-адреса
docker exec tor-proxy cat /var/lib/tor/ssh/hostname
docker exec tor-proxy cat /var/lib/tor/admin/hostname
# Обновить .env с onion-адресами
./tor-proxy/get-onions.sh
```
## WireGuard
WireGuard по умолчанию отключен (`WG_ENABLED=false`). Для включения:
1. Установите `WG_ENABLED=true` в `.env`
2. Заполните ключи WireGuard в `.env`
3. Перезапустите: `docker compose restart`
Контейнер требует `NET_ADMIN` и `sysctl net.ipv4.conf.all.src_valid_mark=1` для WireGuard. Эти привилегии заданы в `docker-compose.yml`.
## Мультиязычность (i18n)
Бот поддерживает 3 языка: **🇬🇧 English**, **🇪🇸 Español**, **🇩🇪 Deutsch**.
### Как это работает
- **`/start`** — всегда показывает выбор языка с флагами
- **`/language`** — команда для смены языка в любой момент
- **Профиль** — кнопка «🌐 Change Language» рядом с «Set Location»
- Выбранный язык сохраняется в БД (`users.language`) и используется во всех сообщениях
- Интерполяция: `t('key', { param: value })``{{param}}` в строках
- Fallback: запрошенный язык → English → ключ
### Структура i18n
```
src/i18n/
├── index.js # tForUser(), tForLang(), LANGUAGE_NAMES, AVAILABLE_LANGUAGES
└── locales/
├── en.json # 201 ключ, английский
├── es.json # 201 ключ, испанский
└── de.json # 201 ключ, немецкий
```
### Админ-панель локализации
Вкладка «Локализация» в админ-панели (`/locales`) позволяет просматривать и редактировать все ключи перевода в таблице с сохранением в JSON-файлы.
### Добавление нового языка
1. Создать `src/i18n/locales/<code>.json` по шаблону `en.json`
2. Добавить код в `AVAILABLE_LANGUAGES` и `LANGUAGE_NAMES` в `src/i18n/index.js`
3. Язык автоматически появится в выборе при `/start` и `/language`
## Безопасность
- `.env` монтируется только для чтения (`:ro`)
- Порт 3001 доступен из LAN и через Tor onion
- Onion-адреса сохраняются в volume (персистентность при перезапуске)
- Tor hidden services с валидацией env vars
- Нативные модули компилируются в builder-стейдже
- `devDependencies` не попадают в production-образ
- Тестовые файлы исключены из Docker-образа (`.dockerignore`)
- `node_modules` хоста не попадают в образ (`.dockerignore`)
## Устойчивость к ошибкам
- Бот не крашит контейнер при невалидном `BOT_TOKEN`: 5 попыток с задержкой 5с, затем бот отключается, админка продолжает работать
- Комиссионные кошельки не обязательны для старта: при отсутствии логируется предупреждение
- При потере связи с Telegram API: polling ошибки логируются, процесс продолжает работать
## Структура проекта
```
├── src/
│ ├── admin/ # Админ-панель (Express)
│ │ ├── routes/ # Роуты админ-панели (вкл. /locales для i18n)
│ │ ├── views/ # Шаблоны HTML
│ │ ├── public/ # Статические файлы (CSS)
│ │ ├── auth.js # Авторизация
│ │ └── server.js # Express-сервер
│ ├── config/ # Конфигурация (БД, крипто)
│ ├── context/ # Контекст и состояния бота
│ ├── handlers/ # Обработчики команд
│ │ ├── adminHandlers/ # Обработчики админа
│ │ └── userHandlers/ # Обработчики пользователя
│ ├── i18n/ # Интернационализация
│ │ ├── index.js # tForUser(), tForLang(), LANGUAGE_NAMES
│ │ └── locales/ # en.json, es.json, de.json (201 ключ)
│ ├── middleware/ # Промежуточные обработчики
│ ├── migrations/ # Миграции БД
│ ├── models/ # Модели данных
│ ├── router/ # Роутинг Express
│ ├── services/ # Бизнес-логика
│ ├── utils/ # Утилиты (логирование, валидация, ошибки)
│ └── index.js # Точка входа
├── tor-proxy/ # Tor прокси для SSH и админки
│ ├── Dockerfile # Alpine + Tor образ
│ ├── entrypoint.sh # Генерация torrc, валидация env vars
│ ├── get-onions.sh # Скрипт обновления .env с onion-адресами
│ └── hosts/ # Директория для onion-hosts.txt
├── wg/ # WireGuard конфигурация
│ └── start.sh # Скрипт запуска контейнера
├── db/ # SQLite база данных (volume)
├── uploads/ # Загруженные фото (volume)
├── Dockerfile # Multi-stage сборка магазина
├── docker-compose.yml # Конфигурация обоих контейнеров
├── install.sh # Установщик (POSIX sh)
├── .dockerignore # Исключения из образа
├── .env.example # Шаблон переменных
└── package.json
```
## Разработка
```bash
# Установка зависимостей
npm install
# Запуск в режиме разработки
npm run dev
# Тесты
npm test
```
## Лицензия
MIT

View File

@@ -1,3 +1,5 @@
version: "3.3"
services: services:
telegram_shop_prod: telegram_shop_prod:
build: build:
@@ -5,63 +7,11 @@ services:
dockerfile: ./Dockerfile dockerfile: ./Dockerfile
hostname: telegram_shop_prod hostname: telegram_shop_prod
container_name: telegram_shop_prod container_name: telegram_shop_prod
ports: restart: always
- "3001:3001" environment:
restart: unless-stopped - BOT_TOKEN=7626758249:AAEdcbXJpW1VsnJJtc8kZ5VBsYMFR242wgk
- ADMIN_IDS=732563549,390431690,217546867
- SUPPORT_LINK=https://t.me/neroworm
- CATALOG_PATH=./catalog
volumes: volumes:
- ./db:/app/db/ - ./db:/app/db/
- ./uploads:/app/uploads/
- ./.env:/app/.env:ro
cap_add:
- NET_ADMIN
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
dns:
- 8.8.8.8
- 1.1.1.1
mem_limit: 384m
cpus: "1.0"
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
networks:
- default
- tor_proxy_net
tor-proxy:
build:
context: ./tor-proxy
dockerfile: Dockerfile
container_name: tor-proxy
environment:
SSH_HOST_IP: ${SSH_HOST_IP:-host.docker.internal}
SHOP_CONTAINER: ${SHOP_CONTAINER:-telegram_shop_prod}
ADMIN_PORT: ${ADMIN_PORT:-3001}
volumes:
- tor_data:/var/lib/tor
- ./tor-proxy/hosts:/onion-hosts
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- default
- tor_proxy_net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "test -s /var/lib/tor/ssh/hostname && test -s /var/lib/tor/admin/hostname"]
interval: 60s
timeout: 10s
retries: 2
start_period: 120s
networks:
tor_proxy_net:
name: tor_proxy_net
driver: bridge
attachable: true
volumes:
tor_data:
name: tor_proxy_data

View File

@@ -1,313 +0,0 @@
#!/bin/sh
set -euo pipefail
# ============================================================
# Telegram Shop — установщик
# Скрипт для развёртывания на x86_64 и ARM64 (Orange Pi, RPi)
# Совместим с POSIX sh (Alpine ash)
# ============================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
print_header() {
printf "${CYAN}${BOLD}\n"
printf "╔═══════════════════════════════════════════════════════════════╗\n"
printf "║ Telegram Shop — Установщик v1.1 ║\n"
printf "║ Поддержка x86_64 и ARM64 (Orange Pi, Raspberry Pi) ║\n"
printf "╚═══════════════════════════════════════════════════════════════╝\n"
printf "${NC}\n"
}
print_ok() { printf "${GREEN}${NC} %s\n" "$1"; }
print_warn() { printf "${YELLOW}${NC} %s\n" "$1"; }
print_err() { printf "${RED}${NC} %s\n" "$1"; }
print_info() { printf "${BLUE}${NC} %s\n" "$1"; }
print_step() { printf "\n${CYAN}${BOLD}▶ %s${NC}\n" "$1"; }
trap 'print_err "Установка прервана ошибкой (строка $LINENO)"; exit 1' ERR
# ============================================================
# 0. Определение окружения
# ============================================================
print_header
print_step "Определение окружения"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH_NAME="x86_64 (Intel/AMD)" ;;
aarch64|arm64) ARCH_NAME="ARM64 (Orange Pi / RPi)" ;;
armv7l) ARCH_NAME="ARMv7 (Raspberry Pi 2)" ;;
*)
print_err "Неподдерживаемая архитектура: $ARCH"
exit 1
;;
esac
print_ok "Архитектура: $ARCH_NAME"
print_info "Docker автоматически соберёт образ под текущую архитектуру"
# ============================================================
# 1. Проверка / установка Docker
# ============================================================
print_step "Проверка Docker"
DOCKER_MISSING=0
if ! command -v docker >/dev/null 2>&1; then
DOCKER_MISSING=1
elif ! docker version >/dev/null 2>&1; then
DOCKER_MISSING=1
fi
if [ "$DOCKER_MISSING" -eq 1 ]; then
print_warn "Docker не установлен или не запущен"
printf "\n"
printf "Установить Docker сейчас? (y/N): "
read -r INSTALL_DOCKER
case "$INSTALL_DOCKER" in
[Yy]|[Yy][Ee][Ss])
;;
*)
print_err "Docker обязателен. Установите вручную: https://docs.docker.com/engine/install/"
exit 1
;;
esac
print_info "Установка Docker..."
if command -v apk >/dev/null 2>&1; then
# Alpine
apk add --no-cache docker docker-cli-compose
rc-service docker start 2>/dev/null || service docker start 2>/dev/null || true
if ! docker version >/dev/null 2>&1; then
print_err "Docker не запустился. Запустите вручную: rc-service docker start"
exit 1
fi
elif command -v apt-get >/dev/null 2>&1; then
# Debian / Ubuntu / Armbian
apt-get update
apt-get install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
printf "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable\n" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
service docker start 2>/dev/null || systemctl start docker 2>/dev/null || true
if ! docker version >/dev/null 2>&1; then
print_err "Docker не запустился. Запустите вручную: systemctl start docker"
exit 1
fi
else
print_err "Неизвестный пакетный менеджер. Установите Docker вручную."
exit 1
fi
print_ok "Docker установлен"
else
print_ok "Docker установлен: $(docker --version)"
fi
# ============================================================
# 2. Проверка docker compose
# ============================================================
print_step "Проверка docker compose"
COMPOSE_CMD=""
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
COMPOSE_VER=$(docker compose version --short 2>/dev/null || echo "v2")
print_ok "docker compose v2 доступен: $COMPOSE_VER"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
print_ok "docker-compose v1 доступен: $(docker-compose --version)"
else
print_err "docker compose не найден. Установите docker-compose-plugin или docker-compose."
exit 1
fi
# ============================================================
# 3. Подготовка файлов окружения
# ============================================================
print_step "Подготовка .env"
if [ ! -f ".env" ]; then
if [ -f ".env.example" ]; then
cp .env.example .env
# Убираем CRLF если файл скопирован из Windows
if command -v sed >/dev/null 2>&1; then
sed -i 's/\r$//' .env
fi
print_ok ".env создан из .env.example"
else
print_err ".env.example не найден!"
exit 1
fi
else
print_ok ".env уже существует — пропускаю создание"
# Убираем CRLF в существующем файле тоже
if command -v sed >/dev/null 2>&1; then
sed -i 's/\r$//' .env
fi
fi
# ============================================================
# 4. Проверка обязательных переменных
# ============================================================
print_step "Проверка переменных окружения"
MISSING_COUNT=0
MISSING_LIST=""
for VAR in BOT_TOKEN ADMIN_IDS ENCRYPTION_KEY; do
VAL=$(grep -E "^${VAR}=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d '\r' || true)
if [ -z "$VAL" ] || [ "$VAL" = "your_bot_token_here" ] || [ "$VAL" = "123456789,987654321" ]; then
MISSING_COUNT=$((MISSING_COUNT + 1))
MISSING_LIST="$MISSING_LIST $VAR"
fi
done
if [ "$MISSING_COUNT" -ne 0 ]; then
print_warn "Не заполнены или имеют placeholder значения:$MISSING_LIST"
printf "\n"
printf "${YELLOW}Откройте .env в редакторе и заполните:${NC}\n"
for V in $MISSING_LIST; do
printf " - %s\n" "$V"
done
printf "\n"
printf "Продолжить всё равно? (y/N): "
read -r CONTINUE
case "$CONTINUE" in
[Yy]|[Yy][Ee][Ss])
;;
*)
print_info "Откройте .env, заполните значения и запустите install.sh снова."
exit 0
;;
esac
else
print_ok "Все обязательные переменные заполнены"
fi
# ============================================================
# 5. Создание директорий
# ============================================================
print_step "Создание директорий"
mkdir -p db uploads
print_ok "db/, uploads/ готовы"
# ============================================================
# 6. Сборка образа
# ============================================================
print_step "Сборка Docker-образа (это может занять несколько минут)"
print_info "Архитектура: $ARCH_NAME"
print_info "Нативные модули: better-sqlite3, tiny-secp256k1 (компилируются в builder)"
# Docker автоматически собирает под текущую архитектуру хоста
# buildx нужен только для multi-arch push в registry, для локальной сборки — обычный build
docker build -t telegram-shop:latest .
print_ok "Образ telegram-shop:latest собран"
# ============================================================
# 7. Запуск контейнеров
# ============================================================
print_step "Запуск контейнеров"
$COMPOSE_CMD up -d
print_ok "Контейнеры запущены"
# ============================================================
# 8. Проверка статуса
# ============================================================
print_step "Проверка статуса"
sleep 3
$COMPOSE_CMD ps
# ============================================================
# 9. Показ логов
# ============================================================
print_step "Последние логи"
$COMPOSE_CMD logs --tail=20
# ============================================================
# 10. Health-check с повторными попытками
# ============================================================
print_step "Проверка работоспособности"
HEALTH_URL="http://localhost:3001/health"
print_info "Запрос: $HEALTH_URL"
HEALTH_OK=0
for ATTEMPT in 1 2 3 4 5 6; do
sleep 5
if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
HEALTH_OK=1
break
fi
print_info "Попытка $ATTEMPT/6 — сервис ещё запускается..."
done
if [ "$HEALTH_OK" -eq 1 ]; then
RESPONSE=$(curl -sf "$HEALTH_URL" 2>/dev/null || echo "ok")
print_ok "Сервис отвечает!"
printf " ${GREEN}Ответ: %s${NC}\n" "$RESPONSE"
else
print_warn "Health-check не ответил за 30 секунд. Проверьте логи:"
printf " curl %s\n" "$HEALTH_URL"
printf " %s logs -f\n" "$COMPOSE_CMD"
fi
# ============================================================
# 9. Tor Proxy — onion-адреса
# ============================================================
printf "\n"
print_step "Проверка Tor-прокси"
TOR_RUNNING=0
for ATTEMPT in 1 2 3 4 5 6; do
sleep 5
if docker exec tor-proxy test -s /var/lib/tor/ssh/hostname 2>/dev/null && \
docker exec tor-proxy test -s /var/lib/tor/admin/hostname 2>/dev/null; then
TOR_RUNNING=1
break
fi
print_info "Попытка $ATTEMPT/6 — Tor генерирует onion-адреса..."
done
if [ "$TOR_RUNNING" -eq 1 ]; then
SSH_ONION=$(docker exec tor-proxy cat /var/lib/tor/ssh/hostname 2>/dev/null || echo "")
ADMIN_ONION=$(docker exec tor-proxy cat /var/lib/tor/admin/hostname 2>/dev/null || echo "")
if [ -n "$SSH_ONION" ] && [ -n "$ADMIN_ONION" ]; then
print_ok "Tor-прокси работает!"
printf "\n ${CYAN}${BOLD}SSH onion:${NC} %s\n" "$SSH_ONION"
printf " ${CYAN}${BOLD}Admin onion:${NC} %s\n" "$ADMIN_ONION"
printf "\n ${BOLD}Подключение:${NC}\n"
printf " SSH: torify ssh root@%s\n" "$SSH_ONION"
printf " Admin: откройте http://%s в Tor Browser\n" "$ADMIN_ONION"
if [ -f "./tor-proxy/get-onions.sh" ]; then
sh ./tor-proxy/get-onions.sh 2>/dev/null || true
fi
else
print_warn "Tor запущен, но onion-адреса не найдены"
fi
else
print_warn "Tor-прокси не стартовал за 30 секунд. Проверьте логи:"
printf " docker logs tor-proxy\n"
fi
# ============================================================
# Готово
# ============================================================
printf "\n"
printf "${GREEN}${BOLD}╔═══════════════════════════════════════════════════════════════╗${NC}\n"
printf "${GREEN}${BOLD}║ Установка завершена! ║${NC}\n"
printf "${GREEN}${BOLD}╚═══════════════════════════════════════════════════════════════╝${NC}\n"
printf "\n"
printf "${BOLD}Полезные команды:${NC}\n"
printf " docker compose ps # статус контейнеров\n"
printf " docker compose logs -f # логи в реальном времени\n"
printf " docker compose restart # перезапустить\n"
printf " docker compose down # остановить\n"
printf " docker compose up -d --build # пересобрать и запустить\n"
printf "\n"
printf "${BOLD}Health-check:${NC} %s\n" "$HEALTH_URL"

2606
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,22 +9,18 @@
"dependencies": { "dependencies": {
"archiver": "^7.0.1", "archiver": "^7.0.1",
"axios": "^1.7.7", "axios": "^1.7.7",
"better-sqlite3": "^11.10.0",
"bip39": "^3.1.0", "bip39": "^3.1.0",
"bitcoinjs-lib": "^6.1.6", "bitcoinjs-lib": "^6.1.6",
"cookie-parser": "^1.4.6", "crypto-js": "^4.2.0",
"csv-writer": "^1.6.0",
"decompress": "^4.2.1", "decompress": "^4.2.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"ecpair": "^2.1.0", "ecpair": "^2.1.0",
"ethereumjs-util": "^7.1.5", "ethereumjs-util": "^7.1.5",
"express": "^4.21.0",
"hdkey": "^2.1.0", "hdkey": "^2.1.0",
"multer": "^2.2.0",
"node-telegram-bot-api": "^0.64.0", "node-telegram-bot-api": "^0.64.0",
"pino": "^8.21.0", "sqlite3": "^5.1.6",
"pino-pretty": "^13.1.3", "tiny-secp256k1": "^2.2.3",
"tiny-secp256k1": "^2.2.3" "tronweb": "^5.3.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

@@ -1,34 +0,0 @@
import { describe, it, expect } from 'vitest';
describe('SQL Injection Prevention', () => {
it('should reject invalid table name in checkColumnExists', () => {
const ALLOWED_TABLES = new Set([
'users', 'crypto_wallets', 'transactions', 'products',
'purchases', 'locations', 'categories'
]);
expect(ALLOWED_TABLES.has('users')).toBe(true);
expect(ALLOWED_TABLES.has('DROP TABLE users;--')).toBe(false);
expect(ALLOWED_TABLES.has('1=1')).toBe(false);
});
it('should filter out disallowed user fields', () => {
const ALLOWED_USER_FIELDS = new Set([
'telegram_id', 'username', 'country', 'city',
'district', 'status', 'total_balance', 'bonus_balance'
]);
const maliciousData = {
telegram_id: '123',
username: 'test',
admin: true,
role: 'superadmin'
};
const safeFields = Object.keys(maliciousData).filter(key => ALLOWED_USER_FIELDS.has(key));
expect(safeFields).toEqual(['telegram_id', 'username']);
expect(safeFields).not.toContain('admin');
expect(safeFields).not.toContain('role');
});
});

View File

@@ -1,83 +0,0 @@
import crypto from 'crypto';
import config from '../config/config.js';
const TOKEN_SECRET = process.env.ADMIN_SECRET || config.ADMIN_IDS[0] || 'change-me';
const COOKIE_NAME = 'admin_token';
const MAX_AGE = 24 * 60 * 60 * 1000;
function signToken(data) {
const payload = JSON.stringify({ ...data, exp: Date.now() + MAX_AGE });
const b64 = Buffer.from(payload).toString('base64');
const sig = crypto.createHmac('sha256', TOKEN_SECRET).update(b64).digest('hex');
return `${b64}.${sig}`;
}
function verifyToken(token) {
try {
const [b64, sig] = token.split('.');
const expected = crypto.createHmac('sha256', TOKEN_SECRET).update(b64).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
const payload = JSON.parse(Buffer.from(b64, 'base64').toString());
if (payload.exp < Date.now()) return null;
return payload;
} catch {
return null;
}
}
export function requireAuth(req, res, next) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return res.redirect('/login');
const payload = verifyToken(token);
if (!payload) {
res.clearCookie(COOKIE_NAME);
return res.redirect('/login');
}
req.admin = payload;
next();
}
export function handleLogin(req, res) {
const { token } = req.body || {};
if (token !== TOKEN_SECRET) {
return res.status(401).send(renderLogin('Invalid token'));
}
const signed = signToken({ role: 'admin' });
res.cookie(COOKIE_NAME, signed, {
httpOnly: true,
sameSite: 'strict',
maxAge: MAX_AGE,
secure: false
});
res.redirect('/');
}
export function handleLogout(req, res) {
res.clearCookie(COOKIE_NAME);
res.redirect('/login');
}
function renderLogin(error) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<link rel="stylesheet" href="/admin/style.css">
</head>
<body class="login-page">
<div class="login-box">
<h1>Admin Panel</h1>
${error ? `<p class="error">${error}</p>` : ''}
<form method="POST" action="/login">
<label for="token">Admin Token</label>
<input type="password" id="token" name="token" required autofocus>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>`;
}
export { renderLogin };

View File

@@ -1,818 +0,0 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f5;
--card: #fff;
--text: #1a1a1a;
--muted: #666;
--primary: #2563eb;
--danger: #dc2626;
--success: #16a34a;
--border: #e5e5e5;
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.topnav {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: var(--card);
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.brand { font-weight: 700; font-size: 1.1rem; }
.nav-links { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.nav-links a {
padding: 0.4rem 0.75rem;
text-decoration: none;
color: var(--muted);
border-radius: var(--radius);
font-size: 0.9rem;
}
.nav-links a:hover, .nav-links a.active {
background: var(--primary);
color: #fff;
}
.logout-btn {
margin-left: auto;
padding: 0.4rem 0.75rem;
background: var(--danger);
color: #fff;
text-decoration: none;
border-radius: var(--radius);
font-size: 0.9rem;
}
.content { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
h2 { font-size: 1.2rem; margin-bottom: 0.75rem; }
h3 { font-size: 1rem; margin: 1rem 0 0.5rem; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--card);
padding: 1.25rem;
border-radius: var(--radius);
border: 1px solid var(--border);
text-align: center;
}
.stat-value { display: block; font-size: 1.75rem; font-weight: 700; color: var(--primary); }
.stat-label { display: block; font-size: 0.85rem; color: var(--muted); margin-top: 0.25rem; }
table {
width: 100%;
border-collapse: collapse;
background: var(--card);
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
}
th, td { padding: 0.6rem 0.75rem; text-align: left; font-size: 0.9rem; }
th { background: #fafafa; font-weight: 600; border-bottom: 2px solid var(--border); }
td { border-bottom: 1px solid var(--border); }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f9fafb; }
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-active { background: #dcfce7; color: var(--success); }
.badge-banned { background: #fee2e2; color: var(--danger); }
.btn, .btn-sm, button {
display: inline-block;
padding: 0.5rem 1rem;
background: var(--primary);
color: #fff;
border: none;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
font-size: 0.9rem;
}
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.8rem; }
.btn:hover, .btn-sm:hover, button:hover { opacity: 0.9; }
.btn-danger, .btn-danger:hover { background: var(--danger); }
.btn-success, .btn-success:hover { background: var(--success); }
.btn-secondary { background: var(--muted); }
.btn-secondary:hover { background: #555; }
.settings-readonly {
border-color: #d1d5db;
background: #f9fafb;
}
.settings-readonly input[disabled] {
background: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
border-color: #d1d5db;
}
.settings-readonly input[disabled]:hover {
background: #f3f4f6;
}
.readonly-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.4rem;
border-radius: 999px;
background: #fee2e2;
color: #b91c1c;
vertical-align: middle;
margin-left: 0.5rem;
}
.form, .inline-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 500px;
}
.inline-form { flex-direction: row; flex-wrap: wrap; align-items: end; max-width: none; }
.form label { font-weight: 600; font-size: 0.9rem; }
input, select, textarea {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
font-family: inherit;
}
textarea { min-height: 80px; resize: vertical; }
.form-section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 1rem;
}
.form-section summary {
font-weight: 600;
cursor: pointer;
margin-bottom: 0.75rem;
}
.detail-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
margin-bottom: 1rem;
}
.detail-card p { margin-bottom: 0.4rem; }
.flash {
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.flash-info { background: #dbeafe; color: #1e40af; }
.flash-error { background: #fee2e2; color: #991b1b; }
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #e5e7eb;
}
.login-box {
background: var(--card);
padding: 2rem;
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
width: 100%;
max-width: 360px;
}
.login-box h1 { text-align: center; margin-bottom: 1.5rem; }
.login-box label { display: block; margin-bottom: 0.25rem; font-weight: 600; }
.login-box input { width: 100%; margin-bottom: 1rem; }
.login-box button { width: 100%; }
.error { color: var(--danger); text-align: center; margin-bottom: 1rem; }
code { background: #f1f5f9; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width: 300px; }
.wallet-addr {
cursor: pointer;
transition: background 0.2s, box-shadow 0.2s;
word-break: break-all;
user-select: all;
position: relative;
}
.wallet-addr:hover {
background: #dbeafe;
box-shadow: 0 0 0 2px #93c5fd;
}
.wallet-addr.copied {
background: #bbf7d0;
box-shadow: 0 0 0 2px #4ade80;
}
.wallet-addr.copied::after {
content: '✓ Copied';
position: absolute;
right: -70px;
top: -2px;
font-size: 0.75rem;
color: #16a34a;
font-weight: 600;
pointer-events: none;
}
.seed-cell.wallet-addr {
cursor: pointer;
}
.seed-cell.wallet-addr:hover {
background: #dbeafe;
}
.seed-cell.wallet-addr.copied::after {
right: auto;
left: 105%;
top: 0;
}
.muted { color: var(--muted); font-size: 0.85rem; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 1.2rem;
height: 1.2rem;
accent-color: var(--primary);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
.seed-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.seed-card.danger {
border-color: var(--danger);
border-width: 2px;
}
.seed-card h2 { margin-bottom: 0.5rem; }
.seed-card p { margin-bottom: 1rem; color: var(--muted); }
.catalog-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.catalog-layout { grid-template-columns: 1fr; }
}
.catalog-tree h2 { margin-bottom: 0.75rem; }
.catalog-main { min-width: 0; }
.tree-node { margin-left: 0; }
.tree-toggle {
user-select: none;
padding: 0.4rem 0;
display: flex;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
}
.tree-toggle:hover { background: #f0f4ff; }
.tree-toggle .arrow {
display: inline-block;
font-size: 0.7rem;
transition: transform 0.15s;
width: 12px;
text-align: center;
cursor: pointer;
}
.tree-toggle .arrow.open { transform: rotate(90deg); }
.tree-toggle .node-label {
cursor: pointer;
flex: 1;
}
.tree-children { display: none; }
.tree-children.open { display: block; }
.tree-children {
margin-left: 1.5rem;
border-left: 2px solid var(--border);
padding-left: 0.75rem;
}
.tree-count {
font-size: 0.75rem;
color: #888;
margin-left: 0.3rem;
}
.tree-actions {
margin-left: auto;
display: flex;
gap: 0.25rem;
}
.tree-add {
margin: 0.3rem 0 0.3rem 1.5rem;
}
.modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--card);
border-radius: var(--radius);
padding: 0;
max-width: 600px;
width: 95%;
max-height: 88vh;
overflow-y: auto;
}
.modal-content h2 {
position: sticky;
top: 0;
background: var(--card);
padding: 1rem 1.5rem;
margin: 0;
border-bottom: 1px solid var(--border);
z-index: 1;
}
.product-form {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pf-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.pf-group label {
font-weight: 600;
font-size: 0.85rem;
color: var(--text);
}
.pf-group input,
.pf-group select,
.pf-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.9rem;
font-family: inherit;
}
.pf-group textarea {
min-height: 60px;
resize: vertical;
}
.pf-group input:focus,
.pf-group select:focus,
.pf-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.pf-file {
font-size: 0.85rem;
}
.pf-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.pf-section-title {
font-weight: 700;
font-size: 0.9rem;
color: var(--primary);
margin-top: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--border);
}
.pf-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.pf-location-row {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.pf-location-selects {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.pf-location-selects select {
flex: 1;
min-width: 120px;
padding: 0.4rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.85rem;
background: #fff;
}
.pf-loc-tag {
display: inline-block;
padding: 0.2rem 0.5rem;
background: #e0e7ff;
color: #3730a3;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.pf-location {
margin-bottom: 0.25rem;
}
.form-row {
display: flex;
gap: 0.5rem;
}
.form-row input { flex: 1; }
.wallet-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
.wallet-layout { grid-template-columns: 1fr; }
}
.wallet-sidebar {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0;
position: sticky;
top: 1rem;
height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
overflow: hidden;
}
.wallet-sidebar-header {
padding: 0.75rem 1rem 0.5rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.wallet-sidebar-header h3 {
margin: 0 0 0.5rem 0;
}
.wallet-search {
width: 100%;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.85rem;
margin: 0;
box-sizing: border-box;
}
.wallet-search:focus {
outline: none;
border-color: var(--primary);
}
.wallet-user-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.wallet-user-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
text-decoration: none;
color: var(--text);
font-size: 0.85rem;
transition: background 0.15s;
}
.wallet-user-item:hover {
background: #f0f4ff;
}
.wallet-user-item.selected {
background: var(--primary);
color: #fff;
}
.wallet-user-id {
font-weight: 600;
min-width: 28px;
}
.wallet-user-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wallet-user-meta {
font-size: 0.75rem;
opacity: 0.7;
white-space: nowrap;
}
.wallet-main {
min-width: 0;
}
.wallet-page {
display: flex;
flex-direction: column;
min-height: 0;
}
.wallet-bottom {
margin-top: 2rem;
}
.stats-section {
padding-top: 1.5rem;
border-top: 2px solid var(--border);
}
.stats-section h2 {
margin-bottom: 1rem;
}
.owner-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 900px) {
.owner-grid { grid-template-columns: 1fr; }
}
table.compact th, table.compact td {
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
}
.total-row td {
border-top: 2px solid var(--border);
font-weight: 700;
}
.commission-wallet {
margin: 0.3rem 0;
padding: 0.4rem 0.6rem;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.85rem;
word-break: break-all;
}
.commission-wallet code {
background: #eef1f5;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
.seed-section {
border-color: var(--danger);
border-width: 2px;
}
.seed-section h3 {
color: var(--danger);
}
.seed-locked {
text-align: center;
padding: 1.5rem;
}
.seed-locked p {
margin-bottom: 0.5rem;
}
.seed-table-wrap {
overflow-x: auto;
}
.seed-cell {
font-size: 0.8rem;
word-break: break-all;
max-width: 300px;
background: #fff3f3;
padding: 0.3rem;
border-radius: 3px;
}
.highlight-row td {
background: #fef3c7;
font-weight: 700;
}
.stat-warning {
border: 2px solid #f59e0b;
background: #fffbeb;
}
.stat-warning .stat-value {
color: #d97706;
}
.commission-wallet {
margin: 0.3rem 0;
padding: 0.4rem 0.6rem;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.85rem;
word-break: break-all;
}
.commission-wallet code {
background: #eef1f5;
padding: 0.1rem 0.3rem;
border-radius: 3px;
}
@media (max-width: 640px) {
.topnav { flex-direction: column; align-items: flex-start; }
.logout-btn { margin-left: 0; }
.stats-grid { grid-template-columns: 1fr 1fr; }
table { font-size: 0.8rem; }
th, td { padding: 0.4rem; }
.catalog-layout { grid-template-columns: 1fr; }
}
.locale-actions {
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 15px;
}
.save-status {
font-size: 14px;
color: #666;
}
.locale-section {
margin-bottom: 30px;
}
.locale-section h3 {
text-transform: capitalize;
margin-bottom: 10px;
color: #333;
}
.locale-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 15px;
}
.locale-table th,
.locale-table td {
padding: 8px 12px;
border: 1px solid #ddd;
text-align: left;
}
.locale-table th {
background: #f5f5f5;
font-weight: 600;
}
.locale-table .key-cell {
width: 200px;
}
.locale-table .key-cell code {
font-size: 12px;
color: #666;
}
.locale-input {
width: 100%;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.locale-input:focus {
border-color: #4CAF50;
outline: none;
box-shadow: 0 0 3px rgba(76, 175, 80, 0.3);
}

View File

@@ -1,14 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderAuditLog } from '../views/audit.js';
const router = Router();
router.get('/', async (req, res) => {
const entries = await db.allAsync(
'SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200'
);
res.send(renderAuditLog(entries));
});
export default router;

View File

@@ -1,103 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderCatalog } from '../views/catalog.js';
const router = Router();
router.get('/', async (req, res) => {
const { loc, cat, sub, msg, msg_type } = req.query;
const [locations, categories, subcategories] = await Promise.all([
db.allAsync('SELECT * FROM locations ORDER BY country, city, district'),
db.allAsync(`SELECT c.*, (SELECT COUNT(*) FROM products WHERE category_id=c.id) as pc,
(SELECT COUNT(*) FROM subcategories WHERE category_id=c.id) as sc FROM categories c`),
db.allAsync(`SELECT s.*, (SELECT COUNT(*) FROM products WHERE subcategory_id=s.id) as pc
FROM subcategories s`)
]);
let psql = `SELECT p.*, c.name as cn, s.name as sn FROM products p
LEFT JOIN categories c ON p.category_id=c.id LEFT JOIN subcategories s ON p.subcategory_id=s.id WHERE 1=1`;
const params = [];
if (loc) { psql += ' AND p.location_id=?'; params.push(loc); }
if (cat) { psql += ' AND p.category_id=?'; params.push(cat); }
if (sub) { psql += ' AND p.subcategory_id=?'; params.push(sub); }
const products = await db.allAsync(psql + ' ORDER BY p.id DESC LIMIT 200', params);
const cl = {}; for (const c of categories) (cl[c.location_id]??=[]).push(c);
const sc = {}; for (const s of subcategories) (sc[s.category_id]??=[]).push(s);
const tree = {};
for (const l of locations) {
(tree[l.country]??={cities:{}}).cities[l.city]??={districts:{}};
tree[l.country].cities[l.city].districts[l.district] = {
id: l.id, cats: (cl[l.id]||[]).map(c=>({...c, subs: sc[c.id]||[]}))
};
}
res.send(renderCatalog(tree, products, { loc, cat, sub }, categories, subcategories, locations, msg||'', msg_type||'info'));
});
router.post('/locations', async (req, res) => {
const { country, city, district } = req.body;
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), (district||'').trim()]);
res.redirect('/catalog?msg=Location+added&msg_type=success');
});
router.post('/locations/:id/delete', async (req, res) => {
const c = await db.getAsync('SELECT COUNT(*) as n FROM categories WHERE location_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+categories&msg_type=error');
await db.runAsync('DELETE FROM locations WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Location+deleted&msg_type=success');
});
router.post('/locations/add-city', async (req, res) => {
const { country, city } = req.body;
if (!country || !city) return res.redirect('/catalog?msg=Country+and+city+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), '']);
res.redirect('/catalog?msg=City+added&msg_type=success');
});
router.post('/locations/add-district', async (req, res) => {
const { country, city, district } = req.body;
if (!country || !city || !district) return res.redirect('/catalog?msg=All+fields+required&msg_type=error');
await db.runAsync('INSERT INTO locations (country,city,district) VALUES (?,?,?)',
[country.trim(), city.trim(), district.trim()]);
res.redirect('/catalog?msg=District+added&msg_type=success');
});
router.post('/categories', async (req, res) => {
const { name, location_id } = req.body;
if (!name || !location_id) return res.redirect('/catalog?msg=Name+and+location+required&msg_type=error');
await db.runAsync('INSERT INTO categories (name,location_id) VALUES (?,?)', [name.trim(), location_id]);
res.redirect('/catalog?msg=Category+added&msg_type=success');
});
router.post('/categories/json', async (req, res) => {
const { name, location_id } = req.body;
if (!name || !location_id) return res.status(400).json({ error: 'Name and location required' });
const result = await db.runAsync('INSERT INTO categories (name,location_id) VALUES (?,?)', [name.trim(), location_id]);
const cat = await db.getAsync('SELECT * FROM categories WHERE id=?', [result.lastInsertRowid]);
res.json(cat);
});
router.post('/categories/:id/delete', async (req, res) => {
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE category_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE category_id=?', [req.params.id]);
await db.runAsync('DELETE FROM categories WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Category+deleted&msg_type=success');
});
router.post('/categories/:id/subcategories', async (req, res) => {
const { name } = req.body;
if (!name) return res.redirect('/catalog?msg=Name+required&msg_type=error');
await db.runAsync('INSERT INTO subcategories (category_id,name) VALUES (?,?)', [req.params.id, name.trim()]);
res.redirect('/catalog?msg=Subcategory+added&msg_type=success');
});
router.post('/subcategories/:id/delete', async (req, res) => {
const c = await db.getAsync('SELECT COUNT(*) as n FROM products WHERE subcategory_id=?', [req.params.id]);
if (c?.n > 0) return res.redirect('/catalog?msg=Cannot+delete+has+products&msg_type=error');
await db.runAsync('DELETE FROM subcategories WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Subcategory+deleted&msg_type=success');
});
export default router;

View File

@@ -1,65 +0,0 @@
import { Router } from 'express';
import multer from 'multer';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import db from '../../config/database.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const uploadsDir = join(__dirname, '..', '..', '..', 'uploads');
const storage = multer.diskStorage({
destination: uploadsDir,
filename: (req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`)
});
const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 } });
const router = Router();
router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
await db.runAsync(`INSERT INTO products (name,price,quantity_in_stock,description,photo_url,hidden_photo_url,
hidden_coordinates,hidden_description,private_data,category_id,subcategory_id,location_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock)||0, description||'', pu, hu,
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locId]);
res.redirect('/catalog?msg=Product+added&msg_type=success');
});
router.post('/products/:id/edit', upload.fields([{ name: 'photo_file' }, { name: 'hidden_photo_file' }]), async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url,
hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id } = req.body;
if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error');
const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || '');
const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || '');
const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null;
await db.runAsync(`UPDATE products SET name=?,price=?,quantity_in_stock=?,description=?,photo_url=?,hidden_photo_url=?,
hidden_coordinates=?,hidden_description=?,private_data=?,category_id=?,subcategory_id=?,location_id=? WHERE id=?`,
[name.trim(), parseFloat(price), parseInt(quantity_in_stock)||0, description||'', pu, hu,
hidden_coordinates||'', hidden_description||'', private_data||'', category_id, subcategory_id||null, locId, req.params.id]);
res.redirect('/catalog?msg=Product+updated&msg_type=success');
});
router.post('/products/:id/delete', async (req, res) => {
await db.runAsync('DELETE FROM products WHERE id=?', [req.params.id]);
res.redirect('/catalog?msg=Product+deleted&msg_type=success');
});
router.get('/products/:id/json', async (req, res) => {
const p = await db.getAsync(
`SELECT p.*, l.country, l.city, l.district, c.name as category_name,
COALESCE(sc.name, '') as subcategory_name
FROM products p
LEFT JOIN locations l ON p.location_id = l.id
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN subcategories sc ON p.subcategory_id = sc.id
WHERE p.id = ?`,
[req.params.id]
);
if (!p) return res.status(404).json({ error: 'Not found' });
res.json(p);
});
export default router;

View File

@@ -1,61 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderCategoryList } from '../views/categories.js';
const router = Router();
router.get('/', async (req, res) => {
const [categories, locations, subcategories] = await Promise.all([
db.allAsync(`SELECT c.*, l.country, l.city, l.district,
(SELECT COUNT(*) FROM products WHERE category_id = c.id) as product_count
FROM categories c LEFT JOIN locations l ON c.location_id = l.id ORDER BY c.id`),
db.allAsync('SELECT id, country, city, district FROM locations ORDER BY country, city'),
db.allAsync(`SELECT s.*,
(SELECT COUNT(*) FROM products WHERE subcategory_id = s.id) as product_count
FROM subcategories s ORDER BY s.category_id, s.name`),
]);
res.send(renderCategoryList(categories, locations, subcategories));
});
router.post('/', async (req, res) => {
const { name, location_id } = req.body;
await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name, location_id || null]);
res.redirect('/categories');
});
router.post('/:id/update', async (req, res) => {
const { name, location_id } = req.body;
await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?',
[name, location_id || null, req.params.id]);
res.redirect('/categories');
});
router.post('/:id/delete', async (req, res) => {
const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
if (count && count.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+category+with+products');
}
await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
res.redirect('/categories');
});
router.post('/:id/subcategories', async (req, res) => {
const { name } = req.body;
await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)',
[req.params.id, name]);
res.redirect('/categories');
});
router.post('/subcategories/:id/delete', async (req, res) => {
const count = await db.getAsync(
'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?',
[req.params.id]
);
if (count && count.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+subcategory+with+products');
}
await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]);
res.redirect('/categories');
});
export default router;

View File

@@ -1,23 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderDashboard } from '../views/dashboard.js';
const router = Router();
router.get('/', async (req, res) => {
const [[{ totalUsers }], [{ totalProducts }], [{ totalPurchases }], [{ totalRevenue }], [{ totalSubcategories }]] = await Promise.all([
db.allAsync('SELECT COUNT(*) as totalUsers FROM users'),
db.allAsync('SELECT COUNT(*) as totalProducts FROM products'),
db.allAsync('SELECT COUNT(*) as totalPurchases FROM purchases'),
db.allAsync('SELECT COALESCE(SUM(total_price), 0) as totalRevenue FROM purchases WHERE status = ?', ['completed']),
db.allAsync('SELECT COUNT(*) as totalSubcategories FROM subcategories'),
]);
let message = '';
if (req.query.seeded) message = 'Demo data seeded successfully!';
if (req.query.cleared) message = 'All data cleared successfully!';
res.send(renderDashboard({ totalUsers, totalProducts, totalPurchases, totalRevenue, totalSubcategories }, message));
});
export default router;

View File

@@ -1,171 +0,0 @@
import express, { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import logger from '../../utils/logger.js';
import { layout } from '../views/layout.js';
import { AVAILABLE_LANGUAGES } from '../../i18n/index.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.join(__dirname, '..', '..', 'i18n', 'locales');
const router = Router();
router.get('/', (req, res) => {
try {
const files = fs.readdirSync(LOCALES_DIR).filter(f => f.endsWith('.json'));
const locales = {};
for (const file of files) {
const lang = file.replace('.json', '');
const content = fs.readFileSync(path.join(LOCALES_DIR, file), 'utf-8');
locales[lang] = JSON.parse(content);
}
const html = renderLocalesPage(locales);
res.send(html);
} catch (error) {
logger.error({ err: error }, 'Error loading locales');
res.status(500).send('Error loading locales');
}
});
router.post('/save', express.json(), (req, res) => {
try {
const { lang, key, value } = req.body;
if (!lang || !key || value === undefined) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!AVAILABLE_LANGUAGES.includes(lang)) {
return res.status(400).json({ error: 'Invalid language' });
}
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
const content = fs.readFileSync(filePath, 'utf-8');
const locale = JSON.parse(content);
const keys = key.split('.');
let obj = locale;
for (let i = 0; i < keys.length - 1; i++) {
if (!obj[keys[i]]) obj[keys[i]] = {};
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = value;
fs.writeFileSync(filePath, JSON.stringify(locale, null, 2) + '\n', 'utf-8');
res.json({ success: true });
} catch (error) {
logger.error({ err: error }, 'Error saving locale');
res.status(500).json({ error: 'Error saving locale' });
}
});
function renderLocalesPage(locales) {
const sections = Object.keys(locales.en || {});
let sectionsHtml = '';
for (const section of sections) {
const keys = Object.keys(locales.en[section] || {});
let rowsHtml = '';
for (const key of keys) {
const fullKey = `${section}.${key}`;
const enVal = locales.en?.[section]?.[key] || '';
const deVal = locales.de?.[section]?.[key] || '';
const esVal = locales.es?.[section]?.[key] || '';
rowsHtml += `
<tr data-key="${fullKey}">
<td class="key-cell"><code>${fullKey}</code></td>
<td><input type="text" data-lang="en" data-key="${fullKey}" value="${escapeAttr(enVal)}" class="locale-input"></td>
<td><input type="text" data-lang="de" data-key="${fullKey}" value="${escapeAttr(deVal)}" class="locale-input"></td>
<td><input type="text" data-lang="es" data-key="${fullKey}" value="${escapeAttr(esVal)}" class="locale-input"></td>
</tr>`;
}
sectionsHtml += `
<div class="locale-section">
<h3>${section}</h3>
<table class="locale-table">
<thead>
<tr>
<th>Ключ</th>
<th>English</th>
<th>Deutsch</th>
<th>Español</th>
</tr>
</thead>
<tbody>${rowsHtml}</tbody>
</table>
</div>`;
}
const content = `
<div class="locale-actions">
<button id="saveAllBtn" class="btn btn-primary">💾 Сохранить все изменения</button>
<span id="saveStatus" class="save-status"></span>
</div>
${sectionsHtml}
<script>
document.getElementById('saveAllBtn').addEventListener('click', async () => {
const btn = document.getElementById('saveAllBtn');
const status = document.getElementById('saveStatus');
btn.disabled = true;
status.textContent = 'Сохранение...';
const inputs = document.querySelectorAll('.locale-input');
const changes = [];
for (const input of inputs) {
if (input.dataset.originalValue !== input.value) {
changes.push({
lang: input.dataset.lang,
key: input.dataset.key,
value: input.value
});
}
}
let saved = 0;
let errors = 0;
for (const change of changes) {
try {
const res = await fetch('/locales/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change)
});
if (res.ok) saved++;
else errors++;
} catch (e) {
errors++;
}
}
status.textContent = errors > 0
? 'Сохранено ' + saved + ' из ' + changes.length + '. Ошибок: ' + errors
: 'Сохранено ' + saved + ' из ' + changes.length + ' изменений ✓';
btn.disabled = false;
for (const input of inputs) {
input.dataset.originalValue = input.value;
}
});
document.querySelectorAll('.locale-input').forEach(input => {
input.dataset.originalValue = input.value;
});
</script>
`;
return layout('Локализация', content, 'locales');
}
function escapeAttr(str) {
return String(str).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export default router;

View File

@@ -1,36 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderLocationList } from '../views/locations.js';
const router = Router();
router.get('/', async (req, res) => {
const locations = await db.allAsync(`SELECT l.*,
(SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count,
(SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count
FROM locations l ORDER BY l.country, l.city, l.district`);
res.send(renderLocationList(locations));
});
router.post('/', async (req, res) => {
const { country, city, district } = req.body;
await db.runAsync(
'INSERT INTO locations (country, city, district) VALUES (?, ?, ?)',
[country, city, district || '']
);
res.redirect('/locations');
});
router.post('/:id/delete', async (req, res) => {
const count = await db.getAsync(
'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?',
[req.params.id]
);
if (count && count.cnt > 0) {
return res.redirect('/locations?error=Cannot+delete+location+with+categories');
}
await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]);
res.redirect('/locations');
});
export default router;

View File

@@ -1,15 +0,0 @@
import { Router } from 'express';
import config from '../../config/config.js';
import { renderPaymentWallets } from '../views/paymentWallets.js';
const router = Router();
router.get('/', (req, res) => {
res.send(renderPaymentWallets({
commissionEnabled: config.COMMISSION_ENABLED,
commissionPercent: config.COMMISSION_PERCENT,
wallets: config.COMMISSION_WALLETS,
}));
});
export default router;

View File

@@ -1,55 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderProductList, renderProductEdit } from '../views/products.js';
const router = Router();
router.get('/', async (req, res) => {
const [products, categories, subcategories] = await Promise.all([
db.allAsync(`SELECT p.*, c.name as category_name, s.name as subcategory_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN subcategories s ON p.subcategory_id = s.id
ORDER BY p.id DESC LIMIT 100`),
db.allAsync('SELECT id, name FROM categories ORDER BY name'),
db.allAsync('SELECT id, name, category_id FROM subcategories ORDER BY name'),
]);
res.send(renderProductList(products, categories, subcategories));
});
router.post('/', async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body;
await db.runAsync(
`INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id)
VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?))`,
[name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, subcategory_id || null, category_id]
);
res.redirect('/products');
});
router.get('/:id/edit', async (req, res) => {
const [product, categories, subcategories] = await Promise.all([
db.getAsync('SELECT * FROM products WHERE id = ?', [req.params.id]),
db.allAsync('SELECT id, name FROM categories ORDER BY name'),
db.allAsync('SELECT id, name, category_id FROM subcategories ORDER BY name'),
]);
if (!product) return res.status(404).send('Product not found');
res.send(renderProductEdit(product, categories, subcategories));
});
router.post('/:id/update', async (req, res) => {
const { name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id } = req.body;
await db.runAsync(
`UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=?, subcategory_id=?
WHERE id=?`,
[name, price, quantity_in_stock || 0, description || '', photo_url || '', category_id, subcategory_id || null, req.params.id]
);
res.redirect('/products');
});
router.post('/:id/delete', async (req, res) => {
await db.runAsync('DELETE FROM products WHERE id = ?', [req.params.id]);
res.redirect('/products');
});
export default router;

View File

@@ -1,16 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderPurchaseList } from '../views/purchases.js';
const router = Router();
router.get('/', async (req, res) => {
const purchases = await db.allAsync(
`SELECT p.*, pr.name as product_name FROM purchases p
LEFT JOIN products pr ON p.product_id = pr.id
ORDER BY p.purchase_date DESC LIMIT 200`
);
res.send(renderPurchaseList(purchases));
});
export default router;

View File

@@ -1,140 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderSeedPage } from '../views/seed.js';
const router = Router();
router.get('/', (req, res) => {
res.send(renderSeedPage());
});
router.post('/seed-demo', async (req, res) => {
try {
const delTables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log',
'user_states', 'products', 'subcategories', 'categories', 'users', 'locations'];
for (const t of delTables) {
await db.runAsync(`DELETE FROM ${t}`);
}
await db.runAsync("DELETE FROM sqlite_sequence WHERE name != '_meta'");
await db.runAsync(`INSERT INTO locations (country, city, district) VALUES
('Russia', 'Moscow', 'Center'),
('Russia', 'Saint Petersburg', 'North'),
('Germany', 'Berlin', 'Mitte')`);
const locs = await db.allAsync('SELECT id, city FROM locations');
const locMoscow = locs.find(l => l.city === 'Moscow').id;
const locSPb = locs.find(l => l.city === 'Saint Petersburg').id;
const locBerlin = locs.find(l => l.city === 'Berlin').id;
await db.runAsync('INSERT INTO categories (location_id, name) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)',
[locMoscow, 'Digital', locMoscow, 'Physical', locSPb, 'Premium', locSPb, 'VIP', locBerlin, 'Standard']);
const cats = await db.allAsync('SELECT id, name FROM categories');
const catDigital = cats.find(c => c.name === 'Digital').id;
const catPhysical = cats.find(c => c.name === 'Physical').id;
const catPremium = cats.find(c => c.name === 'Premium').id;
const catVIP = cats.find(c => c.name === 'VIP').id;
const catStandard = cats.find(c => c.name === 'Standard').id;
await db.runAsync(`INSERT INTO subcategories (category_id, name) VALUES
(?, 'VPN'), (?, 'Accounts'), (?, 'Software'),
(?, 'Hardware'), (?, 'Accessories'),
(?, 'Annual'), (?, 'Monthly'),
(?, 'Lifetime'), (?, 'Express'),
(?, 'Basic'), (?, 'Starter')`,
[catDigital, catDigital, catDigital,
catPhysical, catPhysical,
catPremium, catPremium,
catVIP, catVIP,
catStandard, catStandard]);
const subs = await db.allAsync('SELECT id, name, category_id FROM subcategories');
const subVPN = subs.find(s => s.name === 'VPN' && s.category_id === catDigital).id;
const subAccounts = subs.find(s => s.name === 'Accounts' && s.category_id === catDigital).id;
const subHardware = subs.find(s => s.name === 'Hardware' && s.category_id === catPhysical).id;
const subAnnual = subs.find(s => s.name === 'Annual' && s.category_id === catPremium).id;
const subLifetime = subs.find(s => s.name === 'Lifetime' && s.category_id === catVIP).id;
const subMonthly = subs.find(s => s.name === 'Monthly' && s.category_id === catPremium).id;
const subBasic = subs.find(s => s.name === 'Basic' && s.category_id === catStandard).id;
const subExpress = subs.find(s => s.name === 'Express' && s.category_id === catVIP).id;
const subStarter = subs.find(s => s.name === 'Starter' && s.category_id === catStandard).id;
const subSoftware = subs.find(s => s.name === 'Software' && s.category_id === catDigital).id;
await db.runAsync(`INSERT INTO users (telegram_id, username, country, city, district, status, total_balance, bonus_balance) VALUES
('1001', 'alice', 'Russia', 'Moscow', 'Center', 0, 150.00, 25.00),
('1002', 'bob', 'Russia', 'Moscow', 'Center', 0, 85.50, 10.00),
('1003', 'charlie', 'Russia', 'Saint Petersburg', 'North', 0, 320.75, 50.00),
('1004', 'diana', 'Germany', 'Berlin', 'Mitte', 0, 45.00, 5.00),
('1005', 'evan', 'Germany', 'Berlin', 'Mitte', 0, 0.00, 0.00)`);
const users = await db.allAsync('SELECT id, username FROM users');
const uAlice = users.find(u => u.username === 'alice').id;
const uBob = users.find(u => u.username === 'bob').id;
const uCharlie = users.find(u => u.username === 'charlie').id;
const uDiana = users.find(u => u.username === 'diana').id;
await db.runAsync(`INSERT INTO products (location_id, category_id, subcategory_id, name, description, price, quantity_in_stock, photo_url) VALUES
(?, ?, ?, 'VPN Subscription 30d', 'Premium VPN access for 30 days', 9.99, 100, ''),
(?, ?, ?, 'VPN Subscription 90d', 'Premium VPN access for 90 days', 24.99, 50, ''),
(?, ?, ?, 'USB Drive 64GB', 'Encrypted USB drive', 29.99, 25, ''),
(?, ?, ?, 'Premium Account 1 Year', 'Full premium access 12 months', 99.99, 10, ''),
(?, ?, ?, 'VIP Access Lifetime', 'Lifetime VIP membership', 199.99, 5, ''),
(?, ?, ?, 'Premium Account 6 Months', 'Premium access 6 months', 59.99, 20, ''),
(?, ?, ?, 'Standard Package', 'Basic package with essentials', 14.99, 200, ''),
(?, ?, ?, 'Security Toolkit', 'Digital security tools', 49.99, 30, ''),
(?, ?, ?, 'VIP Express Pass', 'Priority VIP 3 months', 39.99, 15, ''),
(?, ?, ?, 'Starter Kit', 'Beginner-friendly package', 4.99, 500, '')`,
[locMoscow, catDigital, subVPN, locMoscow, catDigital, subAccounts, locMoscow, catPhysical, subHardware,
locSPb, catPremium, subAnnual, locSPb, catVIP, subLifetime, locSPb, catPremium, subMonthly,
locBerlin, catStandard, subBasic, locMoscow, catDigital, subSoftware,
locSPb, catVIP, subExpress, locBerlin, catStandard, subStarter]);
const prods = await db.allAsync('SELECT id, name FROM products');
const pVPN30 = prods.find(p => p.name.includes('30d')).id;
const pUSB = prods.find(p => p.name.includes('USB')).id;
const pPrem1y = prods.find(p => p.name.includes('1 Year')).id;
const pStd = prods.find(p => p.name.includes('Standard')).id;
const pStarter = prods.find(p => p.name.includes('Starter')).id;
await db.runAsync(`INSERT INTO crypto_wallets (user_id, wallet_type, address, derivation_path, mnemonic, balance) VALUES
(?, 'BTC', 'bc1qexample1addr', 'm/44h/0h/0h/0/0', 'encrypted:1', 0.00543),
(?, 'ETH', '0xExampleEth1addr', 'm/44h/60h/0h/0/0', 'encrypted:2', 0.12500),
(?, 'BTC', 'bc1qexample2addr', 'm/44h/0h/0h/0/1', 'encrypted:3', 0.00210),
(?, 'LTC', 'ltc1qexample3addr', 'm/44h/2h/0h/0/0', 'encrypted:4', 1.50000),
(?, 'ETH', '0xExampleEth4addr', 'm/44h/60h/0h/0/1', 'encrypted:5', 0.50000)`,
[uAlice, uAlice, uBob, uCharlie, uDiana]);
await db.runAsync(`INSERT INTO purchases (user_id, product_id, wallet_type, tx_hash, quantity, total_price, status) VALUES
(?, ?, 'BTC', 'tx_a1b2c3d4', 1, 9.99, 'completed'),
(?, ?, 'ETH', 'tx_b2c3d4e5', 2, 59.98, 'completed'),
(?, ?, 'LTC', 'tx_c3d4e5f6', 1, 99.99, 'pending'),
(?, ?, 'BTC', 'tx_d4e5f6a7', 1, 14.99, 'completed'),
(?, ?, 'ETH', 'tx_e5f6a7b8', 3, 14.97, 'cancelled')`,
[uAlice, pVPN30, uBob, pUSB, uCharlie, pPrem1y, uAlice, pStd, uDiana, pStarter]);
await db.runAsync(`INSERT INTO transactions (user_id, wallet_type, tx_hash, amount) VALUES
(?, 'BTC', 'tx_f6a7b8c9', 0.01000),
(?, 'ETH', 'tx_a7b8c9d0', 0.50000),
(?, 'LTC', 'tx_b8c9d0e1', 2.00000)`,
[uAlice, uBob, uCharlie]);
res.redirect('/?seeded=1');
} catch (err) {
console.error('Seed error:', err.message);
res.redirect('/?seeded=0');
}
});
router.post('/clear-all', async (req, res) => {
try {
const tables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log',
'user_states', 'products', 'subcategories', 'categories', 'users', 'locations'];
for (const t of tables) {
await db.runAsync(`DELETE FROM ${t}`);
}
await db.runAsync("DELETE FROM sqlite_sequence WHERE name != '_meta'");
res.redirect('/?cleared=1');
} catch (err) {
console.error('Clear error:', err.message);
res.redirect('/?cleared=0');
}
});
export default router;

View File

@@ -1,95 +0,0 @@
import { Router } from 'express';
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import config from '../../config/config.js';
import { renderSettings } from '../views/settings.js';
const router = Router();
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..', '..', '..');
const envPath = join(projectRoot, '.env');
const PLACEHOLDER = '••••••••';
const PRESERVE_KEYS = new Set([
'ENCRYPTION_KEY', 'ADMIN_SECRET', 'GITEA_TOKEN',
'WG_PRIVATE_KEY', 'WG_PRESHARED_KEY'
]);
router.get('/', (req, res) => {
const saved = req.query.saved === '1';
const error = req.query.error === '1';
const message = saved ? 'Settings saved. Container restarting...' :
error ? 'Failed to save settings.' : null;
const data = {
botToken: config.BOT_TOKEN,
adminIds: config.ADMIN_IDS,
superAdminIds: config.SUPER_ADMIN_IDS,
supportLink: config.SUPPORT_LINK || '',
commissionEnabled: config.COMMISSION_ENABLED,
commissionPercent: config.COMMISSION_PERCENT,
commissionWallets: config.COMMISSION_WALLETS,
wgEnabled: process.env.WG_ENABLED === 'true',
wgEndpoint: process.env.WG_ENDPOINT || '',
wgAddress: process.env.WG_ADDRESS || '',
wgPublicKey: process.env.WG_PUBLIC_KEY || '',
wgDns: process.env.WG_DNS || '',
adminPort: process.env.ADMIN_PORT || '',
adminUrl: process.env.ADMIN_URL || '',
catalogPath: process.env.CATALOG_PATH || '',
giteaApiUrl: process.env.GITEA_API_URL || '',
encryptionKey: process.env.ENCRYPTION_KEY || '',
adminSecret: process.env.ADMIN_SECRET || '',
giteaToken: process.env.GITEA_TOKEN || '',
saved,
message,
};
res.send(renderSettings(data));
});
router.post('/', (req, res) => {
try {
const body = req.body;
const checkboxKeys = ['COMMISSION_ENABLED', 'WG_ENABLED'];
for (const k of checkboxKeys) {
if (!(k in body)) body[k] = 'false';
}
const original = readFileSync(envPath, 'utf-8');
const lines = original.split('\n');
const updated = lines.map(line => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return line;
const eqIdx = line.indexOf('=');
if (eqIdx === -1) return line;
const key = line.slice(0, eqIdx).trim();
if (key in body) {
if (PRESERVE_KEYS.has(key)) return line;
let val = String(body[key]).replace(/[\r\n]+/g, '');
if (val === PLACEHOLDER) return line;
if (val === '') return `${key}=`;
return `${key}=${val}`;
}
return line;
});
writeFileSync(envPath, updated.join('\n'), 'utf-8');
setTimeout(() => process.exit(0), 500);
res.redirect('/settings?saved=1');
} catch (err) {
console.error('Settings save error:', err);
res.redirect('/settings?error=1');
}
});
export default router;

View File

@@ -1,48 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import { renderUserList, renderUserDetail } from '../views/users.js';
const router = Router();
router.get('/', async (req, res) => {
const users = await db.allAsync('SELECT * FROM users ORDER BY id DESC LIMIT 100');
res.send(renderUserList(users));
});
router.get('/:id', async (req, res) => {
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).send('User not found');
const purchases = await db.allAsync(
`SELECT p.*, pr.name as product_name FROM purchases p
LEFT JOIN products pr ON p.product_id = pr.id
WHERE p.user_id = ? ORDER BY p.purchase_date DESC LIMIT 20`,
[user.id]
);
res.send(renderUserDetail(user, purchases));
});
router.post('/:id/toggle-status', async (req, res) => {
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).send('User not found');
const newStatus = user.status === 0 ? 2 : 0;
await db.runAsync('UPDATE users SET status = ? WHERE id = ?', [newStatus, user.id]);
res.redirect('/users');
});
router.post('/:id/adjust-balance', async (req, res) => {
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', [req.params.id]);
if (!user) return res.status(404).send('User not found');
const amount = parseFloat(req.body.amount) || 0;
const currency = req.body.currency === 'bonus_balance' ? 'bonus_balance' : 'total_balance';
const newVal = (user[currency] || 0) + amount;
await db.runAsync(`UPDATE users SET ${currency} = ? WHERE id = ?`, [newVal, user.id]);
await db.runAsync(
'INSERT INTO audit_log (action, admin_id, details) VALUES (?, ?, ?)',
['balance_adjust', req.admin?.role || 'admin', JSON.stringify({
user_id: user.id, currency, amount, old: user[currency], new: newVal
})]
);
res.redirect(`/users/${user.id}`);
});
export default router;

View File

@@ -1,200 +0,0 @@
import { Router } from 'express';
import db from '../../config/database.js';
import config from '../../config/config.js';
import WalletUtils from '../../utils/walletUtils.js';
import { decrypt } from '../../utils/encryption.js';
import logger from '../../utils/logger.js';
import { renderWalletLayout } from '../views/wallets.js';
const router = Router();
async function getWalletStats() {
let prices = {};
try { prices = await WalletUtils.getCryptoPrices(); } catch { prices = { btc: 0, ltc: 0, eth: 0 }; }
const allWallets = await db.allAsync(
`SELECT w.wallet_type, w.balance, w.address, w.user_id
FROM crypto_wallets w
WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#'`
);
const totals = { btc: 0, ltc: 0, eth: 0, usdt: 0, usdc: 0 };
const walletCounts = { btc: 0, ltc: 0, eth: 0, usdt: 0, usdc: 0 };
for (const w of allWallets) {
const type = (w.wallet_type || '').toLowerCase();
if (totals[type] !== undefined) {
totals[type] += w.balance || 0;
walletCounts[type]++;
}
}
const usdValues = {
btc: totals.btc * (prices.btc || 0),
ltc: totals.ltc * (prices.ltc || 0),
eth: totals.eth * (prices.eth || 0),
usdt: totals.usdt,
usdc: totals.usdc,
};
const totalUsd = Object.values(usdValues).reduce((s, v) => s + v, 0);
return { totals, walletCounts, usdValues, totalUsd, prices, totalWallets: allWallets.length };
}
router.get('/', async (req, res) => {
try {
const users = await db.allAsync(
`SELECT u.id, u.telegram_id, u.username, u.status, u.total_balance, u.bonus_balance,
COUNT(w.id) AS wallet_count
FROM users u
LEFT JOIN crypto_wallets w ON w.user_id = u.id AND w.wallet_type NOT LIKE '%#_%' ESCAPE '#'
GROUP BY u.id
ORDER BY u.id DESC`
);
const selectedId = req.query.user ? parseInt(req.query.user, 10) : (users.length > 0 ? users[0].id : null);
let wallets = [];
let selectedUser = null;
if (selectedId) {
selectedUser = await db.getAsync('SELECT * FROM users WHERE id = ?', [selectedId]);
wallets = await db.allAsync(
`SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#' ORDER BY wallet_type`,
[selectedId]
);
}
const walletStats = await getWalletStats();
const commissionRate = config.COMMISSION_PERCENT / 100;
const currentCommission = walletStats.totalUsd * commissionRate;
const lastPayment = await db.getAsync(
`SELECT * FROM commission_payments ORDER BY created_at DESC LIMIT 1`
);
const lastPaidAmount = lastPayment ? lastPayment.commission_amount_usd : 0;
const commissionDue = Math.max(0, currentCommission - lastPaidAmount);
const payments = await db.allAsync(
`SELECT * FROM commission_payments ORDER BY created_at DESC LIMIT 20`
);
const seedsRequested = req.query.seeds === '1';
const seedsPaid = lastPaidAmount >= currentCommission && currentCommission > 0;
const seedsUnlocked = seedsRequested && seedsPaid;
let seedPhrases = [];
if (seedsUnlocked) {
const walletsWithSeeds = await db.allAsync(
`SELECT w.*, u.telegram_id, u.username
FROM crypto_wallets w
JOIN users u ON w.user_id = u.id
WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#'
ORDER BY u.id, w.wallet_type`
);
for (const w of walletsWithSeeds) {
try {
const mnemonic = decrypt(w.mnemonic, w.user_id);
seedPhrases.push({
userId: w.user_id, username: w.username, telegramId: w.telegram_id,
type: w.wallet_type, address: w.address, derivation: w.derivation_path, mnemonic,
});
} catch {
seedPhrases.push({
userId: w.user_id, username: w.username, telegramId: w.telegram_id,
type: w.wallet_type, address: w.address, derivation: w.derivation_path, mnemonic: '[decrypt error]',
});
}
}
}
const stats = {
...walletStats,
commissionRate: config.COMMISSION_PERCENT,
currentCommission,
lastPaidAmount,
commissionDue,
commissionEnabled: config.COMMISSION_ENABLED,
commissionWallets: config.COMMISSION_WALLETS,
totalUsers: users.length,
payments,
seedsPaid,
};
res.send(renderWalletLayout(users, selectedUser, wallets, stats, seedPhrases, seedsUnlocked));
} catch (error) {
logger.error({ err: error }, 'Error loading wallets page');
res.status(500).send('Error loading wallets page');
}
});
router.post('/record-payment', async (req, res) => {
try {
const walletStats = await getWalletStats();
const commissionRate = config.COMMISSION_PERCENT / 100;
const currentCommission = walletStats.totalUsd * commissionRate;
const paidAmount = parseFloat(req.body.paid_amount) || 0;
const note = (req.body.note || '').trim();
if (paidAmount <= 0) {
return res.redirect('/wallets?error=invalid_amount');
}
await db.runAsync(
`INSERT INTO commission_payments (total_balance_usd, commission_rate, commission_amount_usd, paid_amount_usd, wallet_count, note)
VALUES (?, ?, ?, ?, ?, ?)`,
[walletStats.totalUsd.toFixed(2), commissionRate, currentCommission.toFixed(2), paidAmount.toFixed(2), walletStats.totalWallets, note]
);
logger.info({ totalBalanceUsd: walletStats.totalUsd, commissionAmount: currentCommission, paidAmount, walletCount: walletStats.totalWallets }, 'Commission payment recorded');
res.redirect('/wallets?payment=recorded');
} catch (error) {
logger.error({ err: error }, 'Error recording commission payment');
res.redirect('/wallets?error=payment_failed');
}
});
router.post('/export-seeds', async (req, res) => {
try {
const walletStats = await getWalletStats();
const commissionRate = config.COMMISSION_PERCENT / 100;
const currentCommission = walletStats.totalUsd * commissionRate;
const lastPayment = await db.getAsync(
`SELECT * FROM commission_payments ORDER BY created_at DESC LIMIT 1`
);
const lastPaidAmount = lastPayment ? lastPayment.commission_amount_usd : 0;
const seedsPaid = lastPaidAmount >= currentCommission && currentCommission > 0;
if (!seedsPaid) {
logger.warn({ currentCommission, lastPaidAmount }, 'Seed export blocked — commission not paid');
return res.status(403).send('Seed export is locked until commission is paid. Due: $' + Math.max(0, currentCommission - lastPaidAmount).toFixed(2));
}
const walletsWithSeeds = await db.allAsync(
`SELECT w.*, u.telegram_id, u.username
FROM crypto_wallets w
JOIN users u ON w.user_id = u.id
WHERE w.wallet_type NOT LIKE '%#_%' ESCAPE '#'
ORDER BY u.id, w.wallet_type`
);
const rows = [['User', 'Telegram ID', 'Type', 'Address', 'Derivation', 'Mnemonic']];
for (const w of walletsWithSeeds) {
let mnemonic = '';
try { mnemonic = decrypt(w.mnemonic, w.user_id); } catch { mnemonic = '[decrypt error]'; }
rows.push([w.username || w.telegram_id, w.telegram_id, w.wallet_type, w.address, w.derivation_path, mnemonic]);
}
const csv = rows.map(r => r.map(c => {
const s = String(c);
return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s;
}).join(',')).join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename=wallets_seeds.csv');
res.send(csv);
} catch (error) {
logger.error({ err: error }, 'Error exporting seeds');
res.status(500).send('Error exporting seeds');
}
});
export default router;

View File

@@ -1,63 +0,0 @@
import express from 'express';
import cookieParser from 'cookie-parser';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import logger from '../utils/logger.js';
import { requireAuth, handleLogin, handleLogout, renderLogin } from './auth.js';
import dashboardRouter from './routes/dashboard.js';
import catalogRouter from './routes/catalog.js';
import catalogProductsRouter from './routes/catalogProducts.js';
import usersRouter from './routes/users.js';
import productsRouter from './routes/products.js';
import walletsRouter from './routes/wallets.js';
import purchasesRouter from './routes/purchases.js';
import auditRouter from './routes/audit.js';
import settingsRouter from './routes/settings.js';
import categoriesRouter from './routes/categories.js';
import paymentWalletsRouter from './routes/paymentWallets.js';
import locationsRouter from './routes/locations.js';
import seedRouter from './routes/seed.js';
import localesRouter from './routes/locales.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));
app.use('/admin/style.css', express.static(join(__dirname, 'public', 'style.css')));
app.use('/uploads', express.static(join(__dirname, '..', '..', 'uploads')));
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/login', (req, res) => {
res.send(renderLogin());
});
app.post('/login', handleLogin);
app.get('/logout', handleLogout);
app.use(requireAuth);
app.use('/', dashboardRouter);
app.use('/catalog', catalogRouter);
app.use('/catalog', catalogProductsRouter);
app.use('/users', usersRouter);
app.use('/products', productsRouter);
app.use('/wallets', walletsRouter);
app.use('/purchases', purchasesRouter);
app.use('/audit', auditRouter);
app.use('/settings', settingsRouter);
app.use('/categories', categoriesRouter);
app.use('/locations', locationsRouter);
app.use('/payment-wallets', paymentWalletsRouter);
app.use('/seed', seedRouter);
app.use('/locales', localesRouter);
export function startAdminPanel() {
const port = parseInt(process.env.ADMIN_PORT || '3001', 10);
app.listen(port, () => {
logger.info({ port }, 'Admin panel started');
});
}

View File

@@ -1,17 +0,0 @@
import { layout, table } from './layout.js';
import { escapeHtml } from './escape.js';
export function renderAuditLog(entries) {
const headers = ['ID', 'Action', 'Admin ID', 'Details', 'Date'];
const rows = entries.map(e => `<tr>
<td>${e.id}</td>
<td>${e.action}</td>
<td>${e.admin_id}</td>
<td><pre>${escapeHtml(e.details || '')}</pre></td>
<td>${e.created_at || '-'}</td>
</tr>`).join('');
const content = table(headers, entries, () => '')
.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
return layout('Audit Log', content, 'audit');
}

View File

@@ -1,215 +0,0 @@
import { layout, flash } from './layout.js';
import { renderProductEditForm } from './catalogProduct.js';
export function renderCatalog(tree, products, filter, categories, subcategories, locations, msg, msgType) {
const { loc, cat, sub } = filter;
const catOptions = categories.map(c => `<option value="${c.id}" data-loc="${c.location_id}">${esc(c.name)}</option>`).join('');
const subcatJson = JSON.stringify(subcategories.map(s => ({ id: s.id, name: s.name, category_id: s.category_id })));
const locJson = JSON.stringify(locations || []);
const catJson = JSON.stringify(categories.map(c => ({ id: c.id, name: c.name, location_id: c.location_id })));
const addFormHtml = renderProductEditForm('/catalog/products', catOptions, subcatJson, locations)
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
const editFormHtml = renderProductEditForm('/catalog/products/__ID__/edit', catOptions, subcatJson, locations)
.replace(/`/g, '\\`').replace(/\$/g, '\\$');
let treeHtml = '<div class="tree-node"><div class="tree-toggle"><span class="arrow" data-toggle="1">▶</span> <span class="node-label" data-all="1"><strong>All Products</strong></span><span class="tree-count">(' + products.length + ')</span></div></div>';
for (const [country, cdata] of Object.entries(tree)) {
let countryCount = 0, cityHtml = '';
for (const [city, ddata] of Object.entries(cdata.cities)) {
let cityCount = 0, districtHtml = '';
for (const [district, ldata] of Object.entries(ddata.districts)) {
let districtCount = 0, catHtml = '';
for (const c of ldata.cats) {
const catCount = (c.pc||0) + (c.subs||[]).reduce((a,s)=>a+(s.pc||0),0);
districtCount += catCount;
let subHtml = '';
for (const s of (c.subs||[])) {
subHtml += `<div class="tree-node"><div class="tree-toggle"><span class="arrow" data-toggle="1">▶</span> <span class="node-label" data-sub="${s.id}">${esc(s.name)}</span><span class="tree-count">(${s.pc||0})</span><span class="tree-actions"><form method="POST" action="/catalog/subcategories/${s.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children"><form method="POST" action="/catalog/categories/${c.id}/subcategories" class="inline-form tree-add"><input name="name" placeholder="+ Subcategory" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
}
catHtml += `<div class="tree-node"><div class="tree-toggle"><span class="arrow" data-toggle="1">▶</span> <span class="node-label" data-cat="${c.id}">${esc(c.name)}</span><span class="tree-count">(${catCount})</span><span class="tree-actions"><form method="POST" action="/catalog/categories/${c.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children">${subHtml}<form method="POST" action="/catalog/categories/${c.id}/subcategories" class="inline-form tree-add"><input name="name" placeholder="+ Subcategory" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
}
districtHtml += `<div class="tree-node"><div class="tree-toggle"><span class="arrow" data-toggle="1">▶</span> <span class="node-label" data-loc="${ldata.id}">${esc(district)}</span><span class="tree-count">(${districtCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/${ldata.id}/delete" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></span></div><div class="tree-children">${catHtml}<form method="POST" action="/catalog/categories" class="inline-form tree-add"><input type="hidden" name="location_id" value="${ldata.id}"><input name="name" placeholder="+ Category" required size="12"><button class="btn-sm">Add</button></form></div></div>`;
cityCount += districtCount;
}
cityHtml += `<div class="tree-node"><div class="tree-toggle"><span class="arrow" data-toggle="1">▶</span> <span class="node-label" data-city="${city}" data-country="${esc(country)}">${esc(city)}</span><span class="tree-count">(${cityCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/add-district" class="inline-form tree-add"><input type="hidden" name="country" value="${esc(country)}"><input type="hidden" name="city" value="${esc(city)}"><input name="district" placeholder="+ District" required size="12"><button class="btn-sm">Add</button></form></span></div><div class="tree-children">${districtHtml}</div></div>`;
countryCount += cityCount;
}
treeHtml += `<div class="tree-node"><div class="tree-toggle"><span class="arrow" data-toggle="1">▶</span> <span class="node-label" data-country="${esc(country)}"><strong>${esc(country)}</strong></span><span class="tree-count">(${countryCount})</span><span class="tree-actions"><form method="POST" action="/catalog/locations/add-city" class="inline-form tree-add"><input type="hidden" name="country" value="${esc(country)}"><input name="city" placeholder="+ City" required size="10"><button class="btn-sm">Add</button></form></span></div><div class="tree-children">${cityHtml}</div></div>`;
}
let tableHtml = '<p class="muted">No products found.</p>';
if (products.length) {
tableHtml = '<table><thead><tr><th>ID</th><th>Photo</th><th>Name</th><th>Category</th><th>Subcategory</th><th>Price</th><th>Stock</th><th>Actions</th></tr></thead><tbody>';
for (const p of products) {
const img = p.photo_url ? `<img src="${esc(p.photo_url)}" width="40" height="40" style="object-fit:cover;border-radius:4px">` : '<span class="muted">—</span>';
tableHtml += `<tr><td>${p.id}</td><td>${img}</td><td>${esc(p.name)}</td><td>${esc(p.cn||'')}</td><td>${esc(p.sn||'')}</td><td>$${(p.price||0).toFixed(2)}</td><td>${p.quantity_in_stock||0}</td><td><button class="btn-sm" onclick="openEdit(${p.id})">✎</button><form method="POST" action="/catalog/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete?')"><button class="btn-sm btn-danger">✕</button></form></td></tr>`;
}
tableHtml += '</tbody></table>';
}
const content = `${flash(msg, msgType)}
<div class="catalog-layout">
<div class="catalog-tree">
<h2>Catalog</h2>
<details class="form-section"><summary>+ Add Location</summary>
<form method="POST" action="/catalog/locations" class="inline-form">
<input name="country" placeholder="Country" required><input name="city" placeholder="City" required><input name="district" placeholder="District"><button class="btn-sm">Add</button>
</form>
</details>
${treeHtml}
</div>
<div class="catalog-main">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<h2>Products</h2>
<button class="btn" onclick="openAdd()">+ Add Product</button>
</div>
${tableHtml}
</div>
</div>
<div id="product-modal" class="modal" style="display:none"><div class="modal-content" id="modal-body"></div></div>
<script>
const subcats = ${subcatJson};
const allLocations = ${locJson};
const allCategories = ${catJson};
const addFormTpl = \`${addFormHtml}\`;
const editFormTpl = \`${editFormHtml}\`;
function initLocationSelects(selectedLocId) {
const cs = document.getElementById('loc-country');
const ci = document.getElementById('loc-city');
const di = document.getElementById('loc-district');
if (!cs) return;
const countries = [...new Set(allLocations.map(l => l.country))].sort();
cs.innerHTML = '<option value="">-- Country --</option>' + countries.map(c => '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>').join('');
ci.innerHTML = '<option value="">-- City --</option>'; ci.disabled = true;
di.innerHTML = '<option value="">-- District --</option>';
if (selectedLocId) {
const sel = allLocations.find(l => l.id == selectedLocId);
if (sel) {
cs.value = sel.country;
locOnCountryChange();
ci.value = sel.city;
locOnCityChange();
di.value = sel.id;
locOnDistrictChange();
}
} else {
updateCategoryOptions(null);
}
}
function locOnCountryChange() {
const cs = document.getElementById('loc-country');
const ci = document.getElementById('loc-city');
const di = document.getElementById('loc-district');
const country = cs.value;
ci.innerHTML = '<option value="">-- City --</option>';
di.innerHTML = '<option value="">-- District --</option>';
if (!country) { ci.disabled = true; updateCategoryOptions(null); return; }
ci.disabled = false;
const cities = [...new Set(allLocations.filter(l => l.country === country).map(l => l.city))].sort();
ci.innerHTML = '<option value="">-- City --</option>' + cities.map(c => '<option value="'+escHtml(c)+'">'+escHtml(c)+'</option>').join('');
updateCategoryOptions(null);
}
function locOnCityChange() {
const cs = document.getElementById('loc-country');
const ci = document.getElementById('loc-city');
const di = document.getElementById('loc-district');
const country = cs.value;
const city = ci.value;
di.innerHTML = '<option value="">-- District --</option>';
if (!city) { updateCategoryOptions(null); return; }
const locs = allLocations.filter(l => l.country === country && l.city === city);
di.innerHTML = '<option value="">-- District --</option>' + locs.map(l => '<option value="'+l.id+'">'+escHtml(l.district || l.city)+'</option>').join('');
updateCategoryOptions(null);
}
function locOnDistrictChange() {
const di = document.getElementById('loc-district');
const locId = di ? di.value : '';
updateCategoryOptions(locId || null);
}
function updateCategoryOptions(locId) {
const catSel = document.getElementById('pf-category');
if (!catSel) return;
const currentVal = catSel.value;
const filtered = locId ? allCategories.filter(c => c.location_id == locId) : allCategories;
catSel.innerHTML = '<option value="">-- Select Category --</option>' + filtered.map(c => '<option value="'+c.id+'">'+escHtml(c.name)+'</option>').join('');
if (filtered.find(c => c.id == currentVal)) catSel.value = currentVal;
updateSubcats(catSel.value);
updateNewCatVisibility(locId);
}
function updateNewCatVisibility(locId) {
const newCatRow = document.getElementById('new-cat-row');
if (!newCatRow) return;
newCatRow.style.display = locId ? '' : 'none';
if (locId) {
const loc = allLocations.find(l => l.id == locId);
const locName = loc ? (loc.district ? loc.country + ', ' + loc.city + ', ' + loc.district : loc.country + ', ' + loc.city) : '';
document.getElementById('new-cat-loc-label').textContent = locName;
document.getElementById('new-cat-location-id').value = locId;
}
}
async function addCategoryInline() {
const nameInput = document.getElementById('new-cat-name');
const locIdInput = document.getElementById('new-cat-location-id');
const name = nameInput.value.trim();
const locId = locIdInput.value;
if (!name || !locId) return;
try {
const res = await fetch('/catalog/categories/json', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, location_id: parseInt(locId)})
});
const cat = await res.json();
if (cat.error) { alert(cat.error); return; }
allCategories.push(cat);
nameInput.value = '';
updateCategoryOptions(parseInt(locId));
const catSel = document.getElementById('pf-category');
catSel.value = cat.id;
updateSubcats(cat.id);
} catch(e) { alert('Error adding category'); }
}
function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
document.querySelectorAll('.tree-toggle .arrow').forEach(el=>{el.addEventListener('click',e=>{
e.stopPropagation();
const toggle=el.closest('.tree-toggle');
const ch=toggle.nextElementSibling; if(!ch||!ch.classList.contains('tree-children')) return;
ch.classList.toggle('open'); el.classList.toggle('open');
});});
document.querySelectorAll('.tree-toggle .node-label').forEach(el=>{el.addEventListener('click',e=>{
e.stopPropagation();
const loc=el.dataset.loc, cat=el.dataset.cat, sub=el.dataset.sub, all=el.dataset.all;
if(loc||cat||sub||all){ let u='/catalog?'; if(loc) u+='loc='+loc; if(cat) u+='cat='+cat; if(sub) u+='sub='+sub; location.href=u; }
});});
function openAdd(){ document.getElementById('modal-body').innerHTML=addFormTpl; initLocationSelects(null); document.getElementById('product-modal').style.display='flex'; }
async function openEdit(id){ const r=await fetch('/catalog/products/'+id+'/json'); const p=await r.json();
document.getElementById('modal-body').innerHTML=editFormTpl.replace('/__ID__/','/'+p.id+'/'); fillEditForm(p); document.getElementById('product-modal').style.display='flex'; }
function fillEditForm(p){ const f=document.getElementById('product-modal').querySelector('form'); if(!f)return;
f.querySelector('[name=name]').value=p.name||''; f.querySelector('[name=price]').value=p.price||'';
f.querySelector('[name=quantity_in_stock]').value=p.quantity_in_stock||''; f.querySelector('[name=description]').value=p.description||'';
f.querySelector('[name=photo_url]').value=p.photo_url||''; f.querySelector('[name=hidden_photo_url]').value=p.hidden_photo_url||'';
f.querySelector('[name=hidden_coordinates]').value=p.hidden_coordinates||''; f.querySelector('[name=hidden_description]').value=p.hidden_description||'';
f.querySelector('[name=private_data]').value=p.private_data||'';
initLocationSelects(p.location_id);
if(p.category_id) { setTimeout(()=>{ f.querySelector('[name=category_id]').value=p.category_id; updateSubcats(p.category_id, p.subcategory_id); },50); }
}
function updateSubcats(catId,selSub){ const ss=document.getElementById('product-modal').querySelector('[name=subcategory_id]'); if(!ss)return;
ss.innerHTML='<option value="">-- Subcategory --</option>'; subcats.forEach(s=>{if(s.category_id==catId){const o=document.createElement('option');o.value=s.id;o.textContent=s.name;if(s.id==selSub)o.selected=true;ss.appendChild(o)}}); }
document.getElementById('product-modal').addEventListener('click',e=>{if(e.target===document.getElementById('product-modal'))document.getElementById('product-modal').style.display='none'});
document.addEventListener('change',e=>{if(e.target.name==='category_id'&&e.target.closest('#product-modal'))updateSubcats(e.target.value);});
</script>`;
return layout('Catalog', content, 'catalog');
}
function esc(str) { return String(str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }

View File

@@ -1,93 +0,0 @@
export function renderProductEditForm(action, catOptions, subcatJson, locations) {
const isEdit = action.includes('/edit');
const title = isEdit ? 'Edit Product' : 'Add Product';
return `<h2>${title}</h2>
<form method="POST" action="${action}" enctype="multipart/form-data" class="product-form">
<div class="pf-group">
<label>Location</label>
<div class="pf-location-selects">
<select name="location_country" id="loc-country" onchange="locOnCountryChange()">
<option value="">-- Country --</option>
</select>
<select name="location_city" id="loc-city" onchange="locOnCityChange()" disabled>
<option value="">-- City --</option>
</select>
<select name="location_id" id="loc-district" onchange="locOnDistrictChange()">
<option value="">-- District --</option>
</select>
</div>
</div>
<div class="pf-group">
<label>Name</label>
<input name="name" required placeholder="Product name">
</div>
<div class="pf-row">
<div class="pf-group">
<label>Price ($)</label>
<input name="price" type="number" step="0.01" min="0" required placeholder="0.00">
</div>
<div class="pf-group">
<label>Stock</label>
<input name="quantity_in_stock" type="number" min="0" value="0" placeholder="0">
</div>
</div>
<div class="pf-row">
<div class="pf-group">
<label>Category</label>
<select name="category_id" id="pf-category" required onchange="updateSubcats(this.value)">
<option value="">-- Select --</option>${catOptions}
</select>
</div>
<div class="pf-group">
<label>Subcategory</label>
<select name="subcategory_id"><option value="">-- Subcategory --</option></select>
</div>
</div>
<div class="pf-group" id="new-cat-row" style="display:none">
<label>No category? Create one for: <strong id="new-cat-loc-label"></strong></label>
<div style="display:flex;gap:0.5rem">
<input id="new-cat-name" placeholder="Category name" style="flex:1">
<input type="hidden" id="new-cat-location-id" value="">
<button type="button" class="btn-sm" onclick="addCategoryInline()">+ Add Category</button>
</div>
</div>
<div class="pf-group">
<label>Description</label>
<textarea name="description" rows="3" placeholder="Public description"></textarea>
</div>
<div class="pf-section-title">Public Photo</div>
<div class="pf-group">
<label>Photo URL</label>
<input name="photo_url" placeholder="https://...">
</div>
<div class="pf-group">
<label>Or Upload Photo</label>
<input type="file" name="photo_file" accept="image/*" class="pf-file">
</div>
<div class="pf-section-title">Hidden Content (shown after purchase)</div>
<div class="pf-group">
<label>Hidden Photo URL</label>
<input name="hidden_photo_url" placeholder="https://...">
</div>
<div class="pf-group">
<label>Or Upload Hidden Photo</label>
<input type="file" name="hidden_photo_file" accept="image/*" class="pf-file">
</div>
<div class="pf-group">
<label>Hidden Coordinates</label>
<input name="hidden_coordinates" placeholder="lat,lng">
</div>
<div class="pf-group">
<label>Hidden Description</label>
<textarea name="hidden_description" rows="2" placeholder="Shown after purchase"></textarea>
</div>
<div class="pf-group">
<label>Private Data</label>
<textarea name="private_data" rows="2" placeholder="Internal notes, not shown to users"></textarea>
</div>
<div class="pf-actions">
<button type="submit" class="btn btn-success">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('product-modal').style.display='none'">Cancel</button>
</div>
</form>`;
}

View File

@@ -1,79 +0,0 @@
import { layout, flash } from './layout.js';
import { escapeHtml } from './escape.js';
export function renderCategoryList(categories, locations, subcategories) {
const locOptions = locations.map(l =>
`<option value="${l.id}">${escapeHtml(l.country)} / ${escapeHtml(l.city)} / ${escapeHtml(l.district || '')}</option>`
).join('');
const subcatsByCategory = {};
for (const s of subcategories) {
if (!subcatsByCategory[s.category_id]) subcatsByCategory[s.category_id] = [];
subcatsByCategory[s.category_id].push(s);
}
const rows = categories.map(c => {
const subs = subcatsByCategory[c.id] || [];
const subRows = subs.map(s => `<tr class="sub-row">
<td></td>
<td style="padding-left:2em">↳ ${escapeHtml(s.name)}</td>
<td></td>
<td>${s.product_count || 0}</td>
<td>
<form method="POST" action="/categories/subcategories/${s.id}/delete" style="display:inline" onsubmit="return confirm('Delete subcategory?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).join('');
const addSubForm = `<tr class="sub-row">
<td></td>
<td style="padding-left:2em">
<form method="POST" action="/categories/${c.id}/subcategories" class="inline-form">
<input name="name" placeholder="Subcategory name" required size="15">
<button class="btn-sm">Add</button>
</form>
</td>
<td></td><td></td><td></td>
</tr>`;
return `<tr>
<td>${c.id}</td>
<td>${escapeHtml(c.name)}</td>
<td>${c.country ? escapeHtml(c.country) + ' / ' + escapeHtml(c.city) : '-'}</td>
<td>${c.product_count || 0}</td>
<td>
<form method="POST" action="/categories/${c.id}/update" style="display:inline" class="inline-form">
<input name="name" value="${escapeHtml(c.name)}" required size="12">
<select name="location_id">
<option value="">-- None --</option>
${locations.map(l => `<option value="${l.id}" ${l.id === c.location_id ? 'selected' : ''}>${escapeHtml(l.country)} / ${escapeHtml(l.city)}</option>`).join('')}
</select>
<button class="btn-sm">Save</button>
</form>
<form method="POST" action="/categories/${c.id}/delete" style="display:inline" onsubmit="return confirm('Delete category?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>${subRows}${addSubForm}`;
}).join('');
const content = `
${flash('')}
<details class="form-section">
<summary>Add Category</summary>
<form method="POST" action="/categories" class="inline-form">
<input name="name" placeholder="Category name" required>
<select name="location_id">
<option value="">-- No location --</option>
${locOptions}
</select>
<button type="submit" class="btn">Add</button>
</form>
</details>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Location</th><th>Products</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="5">No categories</td></tr>'}</tbody>
</table>`;
return layout('Categories', content, 'categories');
}

View File

@@ -1,14 +0,0 @@
import { layout, statCard, flash } from './layout.js';
export function renderDashboard(stats, message) {
const cards = [
statCard('Total Users', stats.totalUsers),
statCard('Total Products', stats.totalProducts),
statCard('Subcategories', stats.totalSubcategories),
statCard('Total Purchases', stats.totalPurchases),
statCard('Revenue', `$${(stats.totalRevenue || 0).toFixed(2)}`),
].join('');
const content = `${flash(message, 'info')}<div class="stats-grid">${cards}</div>`;
return layout('Dashboard', content, 'dashboard');
}

View File

@@ -1,3 +0,0 @@
export function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

View File

@@ -1,53 +0,0 @@
export function layout(title, content, activeTab = '') {
const nav = [
{ href: '/', label: 'Dashboard', id: 'dashboard' },
{ href: '/catalog', label: 'Catalog', id: 'catalog' },
{ href: '/users', label: 'Users', id: 'users' },
{ href: '/wallets', label: 'Wallets', id: 'wallets' },
{ href: '/purchases', label: 'Purchases', id: 'purchases' },
{ href: '/audit', label: 'Audit Log', id: 'audit' },
{ href: '/settings', label: 'Settings', id: 'settings' },
{ href: '/payment-wallets', label: 'Payment Wallets', id: 'payment-wallets' },
{ href: '/seed', label: 'Seed & Reset', id: 'seed' },
{ href: '/locales', label: 'Локализация', id: 'locales' },
];
const navHtml = nav.map(n =>
`<a href="${n.href}" class="${n.id === activeTab ? 'active' : ''}">${n.label}</a>`
).join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title} — Admin Panel</title>
<link rel="stylesheet" href="/admin/style.css">
</head>
<body>
<nav class="topnav">
<span class="brand">Shop Admin</span>
<div class="nav-links">${navHtml}</div>
<a href="/logout" class="logout-btn">Logout</a>
</nav>
<main class="content">
<h1>${title}</h1>
${content}
</main>
</body>
</html>`;
}
export function table(headers, rows, renderRow) {
const thead = `<thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead>`;
const tbody = `<tbody>${rows.map(renderRow).join('')}</tbody>`;
return `<table>${thead}${tbody}</table>`;
}
export function statCard(label, value) {
return `<div class="stat-card"><span class="stat-value">${value}</span><span class="stat-label">${label}</span></div>`;
}
export function flash(message, type = 'info') {
return message ? `<div class="flash flash-${type}">${message}</div>` : '';
}

View File

@@ -1,35 +0,0 @@
import { layout, flash } from './layout.js';
import { escapeHtml } from './escape.js';
export function renderLocationList(locations) {
const rows = locations.map(l => `<tr>
<td>${l.id}</td>
<td>${escapeHtml(l.country)}</td>
<td>${escapeHtml(l.city)}</td>
<td>${escapeHtml(l.district || '')}</td>
<td>${l.category_count || 0}</td>
<td>${l.product_count || 0}</td>
<td>
<form method="POST" action="/locations/${l.id}/delete" style="display:inline" onsubmit="return confirm('Delete location?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).join('');
const content = `
${flash('')}
<details class="form-section">
<summary>Add Location</summary>
<form method="POST" action="/locations" class="inline-form">
<input name="country" placeholder="Country" required>
<input name="city" placeholder="City" required>
<input name="district" placeholder="District">
<button type="submit" class="btn">Add</button>
</form>
</details>
<table>
<thead><tr><th>ID</th><th>Country</th><th>City</th><th>District</th><th>Categories</th><th>Products</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="7">No locations</td></tr>'}</tbody>
</table>`;
return layout('Locations', content, 'locations');
}

View File

@@ -1,29 +0,0 @@
import { layout } from './layout.js';
import { escapeHtml } from './escape.js';
export function renderPaymentWallets(data) {
const walletRows = Object.entries(data.wallets).map(([type, addr]) =>
`<tr>
<td><strong>${type}</strong></td>
<td><code>${escapeHtml(addr || 'Not set')}</code></td>
</tr>`
).join('');
const content = `
<div class="detail-card">
<h2>Commission Status</h2>
<p><strong>Commission:</strong> <span class="badge badge-${data.commissionEnabled ? 'active' : 'banned'}">${data.commissionEnabled ? 'ON' : 'OFF'}</span></p>
<p><strong>Percentage:</strong> ${data.commissionPercent}%</p>
</div>
<div class="detail-card">
<h2>Commission Wallet Addresses</h2>
<p class="muted">Edit wallet addresses on the <a href="/settings">Settings</a> page.</p>
<table>
<thead><tr><th>Currency</th><th>Address</th></tr></thead>
<tbody>${walletRows}</tbody>
</table>
</div>
`;
return layout('Payment Wallets', content, 'payment-wallets');
}

View File

@@ -1,107 +0,0 @@
import { layout, flash } from './layout.js';
import { escapeHtml } from './escape.js';
export function renderProductList(products, categories, subcategories) {
const catOptions = categories.map(c =>
`<option value="${c.id}">${c.name}</option>`
).join('');
const subcatOptions = subcategories.map(s =>
`<option value="${s.id}" data-cat="${s.category_id}">${s.name}</option>`
).join('');
const rows = products.map(p => `<tr>
<td>${p.id}</td>
<td>${p.name}</td>
<td>${p.category_name || '-'}</td>
<td>${p.subcategory_name || '-'}</td>
<td>$${(p.price || 0).toFixed(2)}</td>
<td>${p.quantity_in_stock || 0}</td>
<td>
<a href="/products/${p.id}/edit" class="btn-sm">Edit</a>
<form method="POST" action="/products/${p.id}/delete" style="display:inline" onsubmit="return confirm('Delete?')">
<button class="btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>`).join('');
const content = `${flash('')}
<details class="form-section">
<summary>Add Product</summary>
<form method="POST" action="/products" class="inline-form">
<input name="name" placeholder="Name" required>
<input name="price" type="number" step="0.01" placeholder="Price" required>
<input name="quantity_in_stock" type="number" placeholder="Stock" value="0">
<input name="description" placeholder="Description">
<input name="photo_url" placeholder="Photo URL">
<select name="category_id" required id="cat-select">
<option value="">-- Category --</option>
${catOptions}
</select>
<select name="subcategory_id" id="subcat-select">
<option value="">-- Subcategory --</option>
${subcatOptions}
</select>
<button type="submit" class="btn">Add</button>
</form>
</details>
<table>
<thead><tr><th>ID</th><th>Name</th><th>Category</th><th>Subcategory</th><th>Price</th><th>Stock</th><th>Actions</th></tr></thead>
<tbody>${rows || '<tr><td colspan="7">No products</td></tr>'}</tbody>
</table>
<script>
const catSel = document.getElementById('cat-select');
const subSel = document.getElementById('subcat-select');
const allOpts = Array.from(subSel.options);
catSel.addEventListener('change', () => {
const catId = catSel.value;
subSel.innerHTML = '<option value="">-- Subcategory --</option>';
allOpts.forEach(o => { if (o.dataset.cat === catId || !o.dataset.cat) subSel.appendChild(o.cloneNode(true)); });
});
</script>`;
return layout('Products', content, 'products');
}
export function renderProductEdit(product, categories, subcategories) {
const catOptions = categories.map(c =>
`<option value="${c.id}" ${c.id === product.category_id ? 'selected' : ''}>${c.name}</option>`
).join('');
const subcatOptions = subcategories.map(s =>
`<option value="${s.id}" data-cat="${s.category_id}" ${s.id === product.subcategory_id ? 'selected' : ''}>${s.name}</option>`
).join('');
const content = `
<form method="POST" action="/products/${product.id}/update" class="form">
<label>Name</label>
<input name="name" value="${escapeHtml(product.name)}" required>
<label>Price</label>
<input name="price" type="number" step="0.01" value="${product.price}" required>
<label>Stock</label>
<input name="quantity_in_stock" type="number" value="${product.quantity_in_stock || 0}">
<label>Description</label>
<textarea name="description">${escapeHtml(product.description || '')}</textarea>
<label>Photo URL</label>
<input name="photo_url" value="${escapeHtml(product.photo_url || '')}">
<label>Category</label>
<select name="category_id" required id="cat-select">${catOptions}</select>
<label>Subcategory</label>
<select name="subcategory_id" id="subcat-select">
<option value="">-- None --</option>
${subcatOptions}
</select>
<button type="submit" class="btn">Save</button>
<a href="/products" class="btn btn-secondary">Cancel</a>
</form>
<script>
const catSel = document.getElementById('cat-select');
const subSel = document.getElementById('subcat-select');
const allOpts = Array.from(subSel.options);
catSel.addEventListener('change', () => {
const catId = catSel.value;
subSel.innerHTML = '<option value="">-- None --</option>';
allOpts.forEach(o => { if (o.dataset.cat === catId || !o.dataset.cat) subSel.appendChild(o.cloneNode(true)); });
});
</script>`;
return layout('Edit Product', content, 'products');
}

View File

@@ -1,19 +0,0 @@
import { layout, table } from './layout.js';
export function renderPurchaseList(purchases) {
const headers = ['ID', 'User', 'Product', 'Qty', 'Price', 'Wallet', 'Date', 'Status'];
const rows = purchases.map(p => `<tr>
<td>${p.id}</td>
<td><a href="/users/${p.user_id}">${p.user_id}</a></td>
<td>${p.product_name || p.product_id}</td>
<td>${p.quantity}</td>
<td>$${(p.total_price || 0).toFixed(2)}</td>
<td>${p.wallet_type || '-'}</td>
<td>${p.purchase_date || '-'}</td>
<td><span class="badge badge-${p.status === 'completed' ? 'active' : 'banned'}">${p.status}</span></td>
</tr>`).join('');
const content = table(headers, purchases, () => '')
.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
return layout('Purchases', content, 'purchases');
}

View File

@@ -1,23 +0,0 @@
import { layout } from './layout.js';
export function renderSeedPage() {
const content = `
<div class="detail-card">
<h2>Seed Demo Data</h2>
<p>Insert sample data: 5 users, 3 locations, 5 categories, 11 subcategories, 10 products, 5 wallets, 5 purchases, 3 transactions.</p>
<form method="POST" action="/seed/seed-demo" onsubmit="return confirm('Insert demo data? This will add records to existing tables.')">
<button type="submit" class="btn btn-success">Seed Demo Data</button>
</form>
</div>
<div class="detail-card" style="border-color: var(--danger);">
<h2 style="color: var(--danger);">Clear All Data</h2>
<p>This will DELETE ALL records from: users, products, categories, purchases, transactions, crypto_wallets, locations, audit_log, user_states.</p>
<p><strong>The _meta table will be preserved.</strong></p>
<form method="POST" action="/seed/clear-all" onsubmit="return confirm('DELETE ALL DATA? This cannot be undone!')">
<button type="submit" class="btn btn-danger">Clear All Data</button>
</form>
</div>
`;
return layout('Seed & Reset', content, 'seed');
}

View File

@@ -1,104 +0,0 @@
import { layout, flash } from './layout.js';
import { escapeHtml } from './escape.js';
const PLACEHOLDER = '••••••••';
const SECRET_KEYS = new Set(['ENCRYPTION_KEY', 'ADMIN_SECRET', 'GITEA_TOKEN']);
function maskSecret(val) {
return val ? PLACEHOLDER : '';
}
export function renderSettings(data) {
const content = `
${data.message ? flash(data.message, data.saved ? 'info' : 'error') : ''}
<form method="POST" action="/settings">
<div class="settings-grid">
<div class="detail-card">
<h2>Bot Configuration</h2>
<div class="form">
<label>Bot Token</label>
<input type="password" name="BOT_TOKEN" value="${escapeHtml(data.botToken)}" autocomplete="off" placeholder="Enter new token to change">
<label>Admin IDs (comma-separated)</label>
<input type="text" name="ADMIN_IDS" value="${escapeHtml(data.adminIds.join(','))}">
<label>Super Admin IDs (comma-separated)</label>
<input type="text" name="SUPER_ADMIN_IDS" value="${escapeHtml(data.superAdminIds.join(','))}">
<label>Support Link</label>
<input type="text" name="SUPPORT_LINK" value="${escapeHtml(data.supportLink)}">
</div>
</div>
<div class="detail-card settings-readonly">
<h2>Commission Settings <span class="readonly-badge">Platform Owner</span></h2>
<p class="muted">Set by the platform owner. Contact them to change commission rate.</p>
<div class="form">
<label class="checkbox-label">
<input type="checkbox" name="COMMISSION_ENABLED" value="true" ${data.commissionEnabled ? 'checked' : ''} disabled>
Commission Enabled
</label>
<label>Commission Percent</label>
<input type="number" name="COMMISSION_PERCENT_DISPLAY" value="${data.commissionPercent}" min="0" max="100" step="0.1" disabled>
</div>
</div>
<div class="detail-card settings-readonly">
<h2>Commission Wallets <span class="readonly-badge">Platform Owner</span></h2>
<p class="muted">These wallets receive commission payments. Set by the platform owner.</p>
<div class="form">
<label>BTC</label>
<input type="text" name="COMMISSION_WALLET_BTC_DISPLAY" value="${escapeHtml(data.commissionWallets.BTC)}" disabled>
<label>LTC</label>
<input type="text" name="COMMISSION_WALLET_LTC_DISPLAY" value="${escapeHtml(data.commissionWallets.LTC)}" disabled>
<label>USDT</label>
<input type="text" name="COMMISSION_WALLET_USDT_DISPLAY" value="${escapeHtml(data.commissionWallets.USDT)}" disabled>
<label>USDC</label>
<input type="text" name="COMMISSION_WALLET_USDC_DISPLAY" value="${escapeHtml(data.commissionWallets.USDC)}" disabled>
<label>ETH</label>
<input type="text" name="COMMISSION_WALLET_ETH_DISPLAY" value="${escapeHtml(data.commissionWallets.ETH)}" disabled>
</div>
</div>
<div class="detail-card">
<h2>WireGuard</h2>
<div class="form">
<label class="checkbox-label">
<input type="checkbox" name="WG_ENABLED" value="true" ${data.wgEnabled ? 'checked' : ''}>
WireGuard Enabled
</label>
<label>Endpoint</label>
<input type="text" name="WG_ENDPOINT" value="${escapeHtml(data.wgEndpoint)}">
<label>Address</label>
<input type="text" name="WG_ADDRESS" value="${escapeHtml(data.wgAddress)}">
<label>Private Key</label>
<input type="password" name="WG_PRIVATE_KEY" placeholder="Enter new key to change" autocomplete="off">
<label>Public Key</label>
<input type="text" name="WG_PUBLIC_KEY" value="${escapeHtml(data.wgPublicKey)}">
<label>Preshared Key</label>
<input type="password" name="WG_PRESHARED_KEY" placeholder="Enter new key to change" autocomplete="off">
<label>DNS</label>
<input type="text" name="WG_DNS" value="${escapeHtml(data.wgDns)}">
</div>
</div>
</div>
<input type="hidden" name="ENCRYPTION_KEY" value="${maskSecret(data.encryptionKey)}">
<input type="hidden" name="ADMIN_SECRET" value="${maskSecret(data.adminSecret)}">
<input type="hidden" name="GITEA_TOKEN" value="${maskSecret(data.giteaToken)}">
<input type="hidden" name="ADMIN_PORT" value="${escapeHtml(data.adminPort)}">
<input type="hidden" name="ADMIN_URL" value="${escapeHtml(data.adminUrl)}">
<input type="hidden" name="CATALOG_PATH" value="${escapeHtml(data.catalogPath)}">
<input type="hidden" name="GITEA_API_URL" value="${escapeHtml(data.giteaApiUrl)}">
<input type="hidden" name="COMMISSION_ENABLED" value="${data.commissionEnabled ? 'true' : 'false'}">
<input type="hidden" name="COMMISSION_PERCENT" value="${data.commissionPercent}">
<input type="hidden" name="COMMISSION_WALLET_BTC" value="${escapeHtml(data.commissionWallets.BTC)}">
<input type="hidden" name="COMMISSION_WALLET_LTC" value="${escapeHtml(data.commissionWallets.LTC)}">
<input type="hidden" name="COMMISSION_WALLET_USDT" value="${escapeHtml(data.commissionWallets.USDT)}">
<input type="hidden" name="COMMISSION_WALLET_USDC" value="${escapeHtml(data.commissionWallets.USDC)}">
<input type="hidden" name="COMMISSION_WALLET_ETH" value="${escapeHtml(data.commissionWallets.ETH)}">
<div style="margin-top: 1rem;">
<button type="submit" class="btn btn-success">Save Settings &amp; Restart</button>
</div>
</form>
`;
return layout('Settings', content, 'settings');
}

View File

@@ -1,63 +0,0 @@
import { layout, table, flash } from './layout.js';
export function renderUserList(users, message) {
const headers = ['ID', 'Telegram ID', 'Username', 'Country', 'City', 'Status', 'Balance', 'Actions'];
const rows = users.map(u => `<tr>
<td>${u.id}</td>
<td>${u.telegram_id}</td>
<td>${u.username || '-'}</td>
<td>${u.country || '-'}</td>
<td>${u.city || '-'}</td>
<td><span class="badge badge-${u.status === 0 ? 'active' : 'banned'}">${u.status === 0 ? 'Active' : u.status === 2 ? 'Blocked' : 'Deleted'}</span></td>
<td>$${(u.total_balance || 0).toFixed(2)}</td>
<td>
<a href="/users/${u.id}" class="btn-sm">View</a>
<form method="POST" action="/users/${u.id}/toggle-status" style="display:inline">
<button class="btn-sm btn-${u.status === 0 ? 'danger' : 'success'}">${u.status === 0 ? 'Ban' : 'Unban'}</button>
</form>
</td>
</tr>`).join('');
const content = `${flash(message)}${table(headers, users, () => '')}`.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
return layout('Users', content, 'users');
}
export function renderUserDetail(user, purchases) {
const rows = purchases.map(p => `<tr>
<td>${p.id}</td>
<td>${p.product_name || '-'}</td>
<td>$${(p.total_price || 0).toFixed(2)}</td>
<td>${p.purchase_date || '-'}</td>
<td>${p.status || '-'}</td>
</tr>`).join('');
const content = `
<div class="detail-card">
<h2>${user.username || 'User #' + user.id}</h2>
<p><strong>Telegram ID:</strong> ${user.telegram_id}</p>
<p><strong>Country:</strong> ${user.country || '-'}</p>
<p><strong>City:</strong> ${user.city || '-'}</p>
<p><strong>Status:</strong> ${user.status === 0 ? 'Active' : user.status === 2 ? 'Blocked' : 'Deleted'}</p>
<p><strong>Balance:</strong> $${(user.total_balance || 0).toFixed(2)}</p>
<p><strong>Bonus:</strong> $${(user.bonus_balance || 0).toFixed(2)}</p>
</div>
<details class="form-section">
<summary>Adjust Balance</summary>
<form method="POST" action="/users/${user.id}/adjust-balance" class="inline-form">
<input name="amount" type="number" step="0.01" placeholder="Amount (+/-)" required>
<select name="currency">
<option value="total_balance">Total Balance</option>
<option value="bonus_balance">Bonus Balance</option>
</select>
<button type="submit" class="btn">Adjust</button>
</form>
</details>
<h3>Recent Purchases</h3>
<table>
<thead><tr><th>ID</th><th>Product</th><th>Price</th><th>Date</th><th>Status</th></tr></thead>
<tbody>${rows || '<tr><td colspan="5">No purchases</td></tr>'}</tbody>
</table>
<a href="/users" class="btn">Back to Users</a>
`;
return layout(`User: ${user.username || user.id}`, content, 'users');
}

View File

@@ -1,235 +0,0 @@
import { layout } from './layout.js';
import { escapeHtml } from './escape.js';
function fmt(n, decimals = 2) {
return Number(n).toFixed(decimals);
}
function fmtCrypto(n) {
return Number(n).toFixed(8).replace(/0+$/, '').replace(/\.$/, '.0');
}
function fmtDate(d) {
if (!d) return '-';
return new Date(d).toLocaleString();
}
export function renderWalletLayout(users, selectedUser, wallets, stats, seedPhrases, seedsUnlocked) {
const hasStats = stats && stats.totalUsd !== undefined;
const seedsParam = seedsUnlocked ? '&seeds=1' : '';
const userListHtml = users.map(u => {
const isActive = selectedUser && u.id === selectedUser.id;
const statusText = u.status === 0 ? '✅' : '❌';
const dataAttr = `data-id="${u.id}" data-name="${escapeHtml((u.username || '').toLowerCase())}" data-tgid="${u.telegram_id}"`;
return `<a href="/wallets?user=${u.id}${seedsParam}" class="wallet-user-item ${isActive ? 'selected' : ''}" ${dataAttr}>
<span class="wallet-user-id">#${u.id}</span>
<span class="wallet-user-name">${escapeHtml(u.username || u.telegram_id)}</span>
<span class="wallet-user-meta">${statusText} ${u.wallet_count}w</span>
</a>`;
}).join('');
const walletRows = wallets.length > 0
? wallets.map(w => `<tr>
<td><strong>${escapeHtml(w.wallet_type)}</strong></td>
<td><code class="wallet-addr" data-addr="${escapeHtml(w.address || '')}" title="Click to copy">${escapeHtml(w.address || '')}</code></td>
<td>${fmtCrypto(w.balance || 0)}</td>
<td>${w.created_at || '-'}</td>
</tr>`).join('')
: '<tr><td colspan="4" class="muted">No wallets yet</td></tr>';
const balanceCard = selectedUser ? `
<div class="detail-card">
<h3>Balances</h3>
<p><strong>Main:</strong> $${fmt(selectedUser.total_balance || 0)}</p>
<p><strong>Bonus:</strong> $${fmt(selectedUser.bonus_balance || 0)}</p>
<p><strong>Available:</strong> $${fmt((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0))}</p>
<p><strong>Status:</strong> <span class="badge badge-${selectedUser.status === 0 ? 'active' : 'banned'}">${selectedUser.status === 0 ? 'Active' : selectedUser.status === 2 ? 'Blocked' : 'Deleted'}</span></p>
</div>` : '';
const ownerSection = hasStats ? `
<div class="stats-section">
<h2>Owner Summary</h2>
<div class="stats-grid">
<div class="stat-card"><span class="stat-value">$${fmt(stats.totalUsd)}</span><span class="stat-label">Total Wallet Balance (USD)</span></div>
<div class="stat-card"><span class="stat-value">${stats.totalUsers}</span><span class="stat-label">Users</span></div>
<div class="stat-card"><span class="stat-value">${stats.totalWallets}</span><span class="stat-label">Active Wallets</span></div>
<div class="stat-card ${stats.commissionDue > 0 ? 'stat-warning' : ''}"><span class="stat-value">$${fmt(stats.commissionDue)}</span><span class="stat-label">Commission Due Now</span></div>
</div>
<div class="owner-grid">
<div class="detail-card">
<h3>Wallet Balances by Currency</h3>
<table class="compact">
<thead><tr><th>Coin</th><th>Wallets</th><th>Balance</th><th>USD Value</th></tr></thead>
<tbody>
${Object.entries(stats.totals).map(([coin, balance]) => {
if (balance <= 0 && !(stats.walletCounts[coin] > 0)) return '';
return `<tr>
<td><strong>${coin.toUpperCase()}</strong></td>
<td>${stats.walletCounts[coin] || 0}</td>
<td>${fmtCrypto(balance)}</td>
<td>$${fmt(stats.usdValues[coin] || 0)}</td>
</tr>`;
}).join('')}
<tr class="total-row">
<td><strong>Total</strong></td>
<td>${stats.totalWallets}</td>
<td></td>
<td><strong>$${fmt(stats.totalUsd)}</strong></td>
</tr>
</tbody>
</table>
</div>
<div class="detail-card">
<h3>Commission ${stats.commissionEnabled ? '' : '(Disabled)'}</h3>
<table class="compact">
<tbody>
<tr><td>Rate</td><td><strong>${stats.commissionRate}%</strong> of total wallet balances</td></tr>
<tr><td>Total Balances</td><td>$${fmt(stats.totalUsd)}</td></tr>
<tr><td>Full Commission</td><td>$${fmt(stats.currentCommission)}</td></tr>
<tr><td>Last Paid</td><td>$${fmt(stats.lastPaidAmount)}</td></tr>
<tr class="highlight-row"><td><strong>Due Now</strong></td><td><strong>$${fmt(stats.commissionDue)}</strong></td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:0.75rem;">Commission is ${stats.commissionRate}% of total wallet balances. Each payment records a snapshot — if the shop continues running and balances grow, the difference becomes the next payment due.</p>
<form method="POST" action="/wallets/record-payment" class="inline-form" style="margin-top:0.75rem;">
<input name="paid_amount" type="number" step="0.01" placeholder="Amount paid (USD)" required style="max-width:180px;">
<input name="note" type="text" placeholder="Note (optional)" style="max-width:200px;">
<button type="submit" class="btn">Record Payment</button>
</form>
<div style="margin-top:0.75rem;">
<strong>Pay to:</strong>
${Object.entries(stats.commissionWallets).filter(([,v]) => v).map(([coin, addr]) => `
<div class="commission-wallet"><strong>${coin}:</strong> <code class="wallet-addr" data-addr="${escapeHtml(addr)}">${escapeHtml(addr)}</code></div>
`).join('')}
</div>
</div>
</div>
${stats.payments && stats.payments.length > 0 ? `
<div class="detail-card">
<h3>Payment History</h3>
<table class="compact">
<thead><tr><th>Date</th><th>Total Balances</th><th>Commission @${stats.commissionRate}%</th><th>Paid</th><th>Delta</th><th>Note</th></tr></thead>
<tbody>
${stats.payments.map(p => {
const delta = p.commission_amount_usd - (stats.payments[stats.payments.indexOf(p) + 1]?.commission_amount_usd || 0);
return `<tr>
<td>${fmtDate(p.created_at)}</td>
<td>$${fmt(p.total_balance_usd)}</td>
<td>$${fmt(p.commission_amount_usd)}</td>
<td>$${fmt(p.paid_amount_usd)}</td>
<td>${delta > 0 ? '+' : ''}$${fmt(delta)}</td>
<td>${escapeHtml(p.note || '-')}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
` : ''}
<div class="detail-card seed-section">
<h3>Seed Phrases & Wallet Access</h3>
${seedsUnlocked ? `
<p class="muted">Showing all decrypted mnemonics. Keep this data secure.</p>
<form method="POST" action="/wallets/export-seeds" style="margin-bottom:1rem;">
<button type="submit" class="btn btn-danger">📥 Export All Seeds as CSV</button>
</form>
<div class="seed-table-wrap">
<table class="compact">
<thead><tr><th>User</th><th>Type</th><th>Address</th><th>Derivation</th><th>Seed Phrase</th></tr></thead>
<tbody>
${seedPhrases.map(s => `<tr>
<td>${escapeHtml(s.username || s.telegramId)} <span class="muted">(#${s.userId})</span></td>
<td>${escapeHtml(s.type)}</td>
<td><code class="wallet-addr" data-addr="${escapeHtml(s.address || '')}" title="Click to copy">${escapeHtml(s.address || '')}</code></td>
<td><code>${escapeHtml(s.derivation)}</code></td>
<td class="seed-cell wallet-addr" data-addr="${escapeHtml(s.mnemonic || '')}" title="Click to copy">${escapeHtml(s.mnemonic)}</td>
</tr>`).join('')}
</tbody>
</table>
</div>
` : `
<div class="seed-locked">
<p>Seed phrases are encrypted and locked until commission is paid.</p>
${stats.commissionDue > 0 ? `
<p class="muted">Commission owed: <strong>$${fmt(stats.commissionDue)}</strong> (${stats.commissionRate}% of current total balances minus last payment).</p>
<p class="muted">Record a payment above to unlock access.</p>
` : `
<p class="muted">No wallet balances to calculate commission. Commission will be calculated once users deposit funds.</p>
`}
${stats.seedsPaid ? `<a href="/wallets?seeds=1&user=${selectedUser ? selectedUser.id : ''}" class="btn btn-danger">🔓 Unlock Seed Phrases</a>` : `<span class="btn btn-secondary" style="opacity:0.5;cursor:not-allowed;">🔒 Unlock requires commission payment</span>`}
</div>
`}
</div>
</div>` : '';
const content = `
<div class="wallet-page">
<div class="wallet-layout">
<aside class="wallet-sidebar">
<div class="wallet-sidebar-header">
<h3>Users</h3>
<input type="text" id="user-search" placeholder="Search by name or ID..." class="wallet-search" autocomplete="off">
</div>
<div class="wallet-user-list" id="user-list">${userListHtml || '<p class="muted">No users</p>'}</div>
</aside>
<section class="wallet-main">
${selectedUser ? `
<h2>${escapeHtml(selectedUser.username || 'User #' + selectedUser.id)}
<span class="muted" style="font-weight:normal;font-size:0.85rem;"> — ${selectedUser.telegram_id}</span>
</h2>
${balanceCard}
<h3>Crypto Wallets (${wallets.length})</h3>
<table>
<thead><tr><th>Type</th><th>Address</th><th>Balance</th><th>Created</th></tr></thead>
<tbody>${walletRows}</tbody>
</table>
` : '<p class="muted">Select a user to view wallets</p>'}
</section>
</div>
${ownerSection ? `<div class="wallet-bottom">${ownerSection}</div>` : ''}
</div>
<script>
(function() {
var search = document.getElementById('user-search');
var items = document.querySelectorAll('.wallet-user-item');
search.addEventListener('input', function() {
var q = this.value.toLowerCase().trim();
items.forEach(function(item) {
var name = (item.dataset.name || '').toLowerCase();
var id = (item.dataset.id || '');
var tgid = (item.dataset.tgid || '');
var match = !q || name.indexOf(q) !== -1 || id.indexOf(q) !== -1 || tgid.indexOf(q) !== -1;
item.style.display = match ? '' : 'none';
});
});
document.addEventListener('click', function(e) {
var el = e.target.closest('.wallet-addr');
if (!el) return;
var addr = el.dataset.addr;
if (!addr) return;
navigator.clipboard.writeText(addr).then(function() {
el.classList.add('copied');
setTimeout(function() { el.classList.remove('copied'); }, 1200);
}).catch(function() {
var ta = document.createElement('textarea');
ta.value = addr;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); el.classList.add('copied'); setTimeout(function() { el.classList.remove('copied'); }, 1200); } catch(ex) {}
document.body.removeChild(ta);
});
});
})();
</script>`;
return layout('Wallets', content, 'wallets');
}

View File

@@ -1,49 +1,6 @@
import logger from '../utils/logger.js';
if (!process.env.BOT_TOKEN) {
logger.warn('BOT_TOKEN not set. Bot will not start. Admin panel will continue to work.');
}
if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length < 32) {
logger.fatal(
'ENCRYPTION_KEY environment variable is required and must be at least 32 characters. ' +
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
);
process.exit(1);
}
const adminIdsRaw = process.env.ADMIN_IDS;
const ADMIN_IDS = adminIdsRaw
? adminIdsRaw.split(',').map(id => id.trim()).filter(Boolean)
: [];
if (!adminIdsRaw) {
logger.warn('ADMIN_IDS environment variable is not set. No admins configured.');
}
const superAdminIdsRaw = process.env.SUPER_ADMIN_IDS;
const SUPER_ADMIN_IDS = superAdminIdsRaw
? superAdminIdsRaw.split(',').map(id => id.trim()).filter(Boolean)
: ADMIN_IDS;
export default { export default {
BOT_TOKEN: process.env.BOT_TOKEN, BOT_TOKEN: process.env.BOT_TOKEN,
ADMIN_IDS, ADMIN_IDS: process.env.ADMIN_IDS.split(","),
SUPER_ADMIN_IDS,
SUPPORT_LINK: process.env.SUPPORT_LINK, SUPPORT_LINK: process.env.SUPPORT_LINK,
DEFAULT_LANGUAGE: process.env.DEFAULT_LANGUAGE || 'en', CATALOG_PATH: process.env.CATALOG_PATH
CATALOG_PATH: process.env.CATALOG_PATH,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
COMMISSION_ENABLED: process.env.COMMISSION_ENABLED === 'true',
COMMISSION_PERCENT: parseFloat(process.env.COMMISSION_PERCENT) || 0,
COMMISSION_WALLETS: {
BTC: process.env.COMMISSION_WALLET_BTC,
LTC: process.env.COMMISSION_WALLET_LTC,
USDT: process.env.COMMISSION_WALLET_USDT,
USDC: process.env.COMMISSION_WALLET_USDC,
ETH: process.env.COMMISSION_WALLET_ETH
},
CHANGENOW_REF: process.env.CHANGENOW_REF || ''
}; };

View File

@@ -1,74 +1,274 @@
import Database from 'better-sqlite3'; import sqlite3 from 'sqlite3';
import logger from '../utils/logger.js'; import { promisify } from 'util';
import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
import { pathToFileURL } from 'url'; import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const DB_PATH = new URL('../../db/shop.db', import.meta.url).pathname; const DB_PATH = new URL('../../db/shop.db', import.meta.url).pathname;
let betterDb; // Create database with verbose mode for better error reporting
const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_CREATE | sqlite3.OPEN_READWRITE, (err) => {
try { if (err) {
betterDb = new Database(DB_PATH); console.error('Database connection error:', err);
betterDb.pragma('journal_mode = WAL');
betterDb.pragma('foreign_keys = ON');
logger.info({ path: DB_PATH }, 'Connected to SQLite database (better-sqlite3)');
} catch (err) {
logger.fatal({ err }, 'Database connection error');
process.exit(1); process.exit(1);
} }
console.log('Connected to SQLite database');
// Adapter: provides async interface compatible with sqlite3 callback API
const db = {
_betterDb: betterDb,
runAsync(sql, params = []) {
return new Promise((resolve, reject) => {
try {
const stmt = betterDb.prepare(sql);
const info = stmt.run(...(Array.isArray(params) ? params : [params]));
resolve(info);
} catch (err) {
reject(err);
}
}); });
},
allAsync(sql, params = []) { // Enable foreign keys
return new Promise((resolve, reject) => { db.run('PRAGMA foreign_keys = ON');
try {
const stmt = betterDb.prepare(sql);
const rows = stmt.all(...(Array.isArray(params) ? params : [params]));
resolve(rows);
} catch (err) {
reject(err);
}
});
},
getAsync(sql, params = []) { // Promisify database operations
const runAsync = (sql, params = []) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { db.run(sql, params, function(err) {
const stmt = betterDb.prepare(sql); if (err) reject(err);
const row = stmt.get(...(Array.isArray(params) ? params : [params])); else resolve(this);
resolve(row || undefined);
} catch (err) {
reject(err);
}
}); });
});
};
const allAsync = (sql, params = []) => {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err);
else resolve(rows);
});
});
};
const getAsync = (sql, params = []) => {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
};
// Attach async methods to db object
db.runAsync = runAsync;
db.allAsync = allAsync;
db.getAsync = getAsync;
// Function to check if a column exists in a table
const checkColumnExists = async (tableName, columnName) => {
try {
const result = await db.allAsync(`
PRAGMA table_info(${tableName})
`);
return result.some(column => column.name === columnName);
} catch (error) {
console.error(`Error checking column ${columnName} in table ${tableName}:`, error);
return false;
} }
}; };
process.on('SIGINT', () => { // Function to clean up invalid foreign key references
const cleanUpInvalidForeignKeys = async () => {
try { try {
betterDb.close(); // Clean up invalid foreign key references in crypto_wallets table
logger.info('Database connection closed'); await db.runAsync(`
process.exit(0); DELETE FROM crypto_wallets
} catch (err) { WHERE user_id NOT IN (SELECT id FROM users)
logger.error({ err }, 'Error closing database'); `);
process.exit(1); console.log('Cleaned up invalid foreign key references in crypto_wallets table');
} catch (error) {
console.error('Error cleaning up invalid foreign key references:', error);
} }
};
// Initialize database tables
const initDb = async () => {
try {
// Begin transaction for table creation
await db.runAsync('BEGIN TRANSACTION');
// Create users table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id TEXT UNIQUE NOT NULL,
username TEXT,
country TEXT,
city TEXT,
district TEXT,
status INTEGER DEFAULT 0,
total_balance REAL DEFAULT 0,
bonus_balance REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
// Create crypto_wallets table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS crypto_wallets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
wallet_type TEXT NOT NULL,
address TEXT NOT NULL,
derivation_path TEXT NOT NULL,
encrypted_mnemonic TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, wallet_type)
)
`);
// Create transactions table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
wallet_type TEXT NOT NULL,
tx_hash TEXT NOT NULL,
amount REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Check if user_id column exists in transactions table
const user_idExists = await checkColumnExists('transactions', 'user_id');
if (!user_idExists) {
await db.runAsync(`
ALTER TABLE transactions
ADD COLUMN user_id INTEGER NOT NULL
`);
console.log('Column user_id added to transactions table');
}
// Check if wallet_type column exists in transactions table
const wallet_typeExists = await checkColumnExists('transactions', 'wallet_type');
if (!wallet_typeExists) {
await db.runAsync(`
ALTER TABLE transactions
ADD COLUMN wallet_type TEXT NOT NULL
`);
console.log('Column wallet_type added to transactions table');
}
// Check if tx_hash column exists in transactions table
const tx_hashExists = await checkColumnExists('transactions', 'tx_hash');
if (!tx_hashExists) {
await db.runAsync(`
ALTER TABLE transactions
ADD COLUMN tx_hash TEXT NOT NULL
`);
console.log('Column tx_hash added to transactions table');
}
// Create products table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
subcategory_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
private_data TEXT,
price REAL NOT NULL CHECK (price > 0),
quantity_in_stock INTEGER DEFAULT 0 CHECK (quantity_in_stock >= 0),
photo_url TEXT,
hidden_photo_url TEXT,
hidden_coordinates TEXT,
hidden_description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
FOREIGN KEY (subcategory_id) REFERENCES subcategories(id) ON DELETE CASCADE
)
`);
// Create purchases table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
wallet_type TEXT NOT NULL,
tx_hash TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
total_price REAL NOT NULL CHECK (total_price > 0),
purchase_date DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
)
`);
// Create locations table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
country TEXT NOT NULL,
city TEXT NOT NULL,
district TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(country, city, district)
)
`);
// Create categories table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE,
UNIQUE(location_id, name)
)
`);
// Create subcategories table
await db.runAsync(`
CREATE TABLE IF NOT EXISTS subcategories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(category_id, name)
)
`);
// Commit transaction
await db.runAsync('COMMIT');
console.log('Database tables initialized successfully');
} catch (error) {
// Rollback transaction on error
await db.runAsync('ROLLBACK');
console.error('Error initializing database tables:', error);
throw error;
}
};
// Initialize the database
(async () => {
await initDb();
await cleanUpInvalidForeignKeys();
})().catch(error => {
console.error('Database initialization failed:', error);
process.exit(1);
});
// Handle database errors
db.on('error', (err) => {
console.error('Database error:', err);
});
// Handle process termination
process.on('SIGINT', () => {
db.close((err) => {
if (err) {
console.error('Error closing database:', err);
} else {
console.log('Database connection closed');
}
process.exit(err ? 1 : 0);
});
}); });
export default db; export default db;

View File

@@ -1,46 +1,17 @@
import TelegramBot from "node-telegram-bot-api"; import TelegramBot from "node-telegram-bot-api";
import config from "../config/config.js"; import config from "../config/config.js";
import logger from "../utils/logger.js";
const MAX_RETRIES = 5; const initBot = () => {
const RETRY_DELAY_MS = 5000;
let bot = null;
let botAvailable = false;
const initBot = async () => {
if (!config.BOT_TOKEN) {
logger.warn('No BOT_TOKEN configured. Running in admin-only mode.');
return null;
}
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try { try {
const instance = new TelegramBot(config.BOT_TOKEN, {polling: true}); const bot = new TelegramBot(config.BOT_TOKEN, {polling: true});
await new Promise((resolve, reject) => { console.log('Bot initialized successfully');
const timeout = setTimeout(() => { return bot;
instance.stopPolling();
reject(new Error('Bot initialization timeout'));
}, 15000);
instance.getMe().then(() => {
clearTimeout(timeout);
resolve();
}).catch(reject);
});
logger.info({ attempt }, 'Bot initialized successfully');
botAvailable = true;
return instance;
} catch (error) { } catch (error) {
logger.warn({ attempt, maxRetries: MAX_RETRIES, err: error.message }, 'Bot initialization failed'); console.error('Failed to initialize bot:', error);
if (attempt < MAX_RETRIES) { process.exit(1);
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
} }
}
}
logger.error({ maxRetries: MAX_RETRIES }, 'All bot initialization attempts failed. Running in admin-only mode.');
return null;
}; };
bot = await initBot(); const bot = initBot();
export { botAvailable };
export default bot; export default bot;

View File

@@ -1,11 +1,2 @@
import { get, set, del, has, initStates } from '../services/stateService.js'; const userStates = new Map();
const userStates = {
get: (chatId) => get(chatId),
set: (chatId, value) => set(chatId, value),
delete: (chatId) => del(chatId),
has: (chatId) => has(chatId),
initStates,
};
export default userStates; export default userStates;

View File

@@ -1,4 +1,4 @@
import { isAdmin } from '../../middleware/auth.js'; import config from '../../config/config.js';
import fs from "fs"; import fs from "fs";
import db from "../../config/database.js"; import db from "../../config/database.js";
import archiver from "archiver"; import archiver from "archiver";
@@ -7,11 +7,14 @@ import bot from "../../context/bot.js";
import userStates from "../../context/userStates.js"; import userStates from "../../context/userStates.js";
export default class AdminDumpHandler { export default class AdminDumpHandler {
static isAdmin(userId) {
return config.ADMIN_IDS.includes(userId.toString());
}
static async handleDump(msg) { static async handleDump(msg) {
const chatId = msg.chat.id; const chatId = msg.chat.id;
if (!isAdmin(msg.from.id)) { if (!this.isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.'); await bot.sendMessage(chatId, 'Unauthorized access.');
return; return;
} }
@@ -30,13 +33,12 @@ export default class AdminDumpHandler {
} }
static async handleExportDatabase(callbackQuery) { static async handleExportDatabase(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
// Table names must match ALLOWED_TABLES whitelist in database.js
const tables = [ const tables = [
"categories", "categories",
"crypto_wallets", "crypto_wallets",
@@ -78,13 +80,13 @@ export default class AdminDumpHandler {
} }
static async handleImportDatabase(callbackQuery) { static async handleImportDatabase(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
await userStates.set(chatId, { action: 'upload_database_dump' }); userStates.set(chatId, { action: 'upload_database_dump' });
await bot.editMessageText( await bot.editMessageText(
'Please upload database dump', 'Please upload database dump',
@@ -96,7 +98,6 @@ export default class AdminDumpHandler {
} }
static async getDumpStatistic() { static async getDumpStatistic() {
// Table names must match ALLOWED_TABLES whitelist in database.js
const tables = [ const tables = [
"categories", "categories",
"crypto_wallets", "crypto_wallets",
@@ -121,13 +122,13 @@ export default class AdminDumpHandler {
static async handleDumpImport(msg) { static async handleDumpImport(msg) {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const state = await userStates.get(chatId); const state = userStates.get(chatId);
if (!state || state.action !== 'upload_database_dump') { if (!state || state.action !== 'upload_database_dump') {
return false; return false;
} }
if (!isAdmin(msg.from.id)) { if (!this.isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.'); await bot.sendMessage(chatId, 'Unauthorized access.');
return; return;
} }
@@ -145,7 +146,7 @@ export default class AdminDumpHandler {
const statistics = await this.getDumpStatistic(); const statistics = await this.getDumpStatistic();
await bot.sendMessage(chatId, JSON.stringify(statistics, null, 2)); await bot.sendMessage(chatId, JSON.stringify(statistics, null, 2));
await userStates.delete(chatId); userStates.delete(chatId);
} else { } else {
await bot.sendMessage(chatId, 'Please upload a valid .zip file.'); await bot.sendMessage(chatId, 'Please upload a valid .zip file.');
return true; return true;
@@ -153,7 +154,7 @@ export default class AdminDumpHandler {
} }
static async confirmImport(callbackQuery) { static async confirmImport(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
} }

View File

@@ -1,15 +1,15 @@
import { isAdmin } from '../../middleware/auth.js'; import config from '../../config/config.js';
import bot from "../../context/bot.js"; import bot from "../../context/bot.js";
export default class AdminHandler { export default class AdminHandler {
static isAdmin(userId) { static isAdmin(userId) {
return isAdmin(userId); return config.ADMIN_IDS.includes(userId.toString());
} }
static async handleAdminCommand(msg) { static async handleAdminCommand(msg) {
const chatId = msg.chat.id; const chatId = msg.chat.id;
if (!isAdmin(msg.from.id)) { if (!this.isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.'); await bot.sendMessage(chatId, 'Unauthorized access.');
return; return;
} }

View File

@@ -1,20 +1,22 @@
import db from '../../config/database.js'; import db from '../../config/database.js';
import Validators from '../../utils/validators.js'; import Validators from '../../utils/validators.js';
import { isAdmin } from '../../middleware/auth.js'; import config from '../../config/config.js';
import userStates from "../../context/userStates.js"; import userStates from "../../context/userStates.js";
import bot from "../../context/bot.js"; import bot from "../../context/bot.js";
import logger from '../../utils/logger.js';
export default class AdminLocationHandler { export default class AdminLocationHandler {
static isAdmin(userId) {
return config.ADMIN_IDS.includes(userId.toString());
}
static async handleAddLocation(callbackQuery) { static async handleAddLocation(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
await userStates.set(chatId, { action: 'add_location' }); userStates.set(chatId, { action: 'add_location' });
await bot.editMessageText( await bot.editMessageText(
'Please enter the location in the following format:\nCountry|City|District', 'Please enter the location in the following format:\nCountry|City|District',
@@ -30,13 +32,13 @@ export default class AdminLocationHandler {
static async handleLocationInput(msg) { static async handleLocationInput(msg) {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const state = await userStates.get(chatId); const state = userStates.get(chatId);
if (!state || state.action !== 'add_location') { if (!state || state.action !== 'add_location') {
return false; return false;
} }
if (!isAdmin(msg.from.id)) { if (!this.isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.'); await bot.sendMessage(chatId, 'Unauthorized access.');
return; return;
} }
@@ -87,7 +89,7 @@ export default class AdminLocationHandler {
throw new Error('Failed to insert location'); throw new Error('Failed to insert location');
} }
await userStates.delete(chatId); userStates.delete(chatId);
} catch (error) { } catch (error) {
await db.runAsync('ROLLBACK'); await db.runAsync('ROLLBACK');
@@ -104,7 +106,7 @@ export default class AdminLocationHandler {
} }
); );
} else { } else {
logger.error({ err: error }, 'Error adding location'); console.error('Error adding location:', error);
await bot.sendMessage( await bot.sendMessage(
chatId, chatId,
'❌ Error adding location. Please try again.', '❌ Error adding location. Please try again.',
@@ -122,51 +124,16 @@ export default class AdminLocationHandler {
return true; return true;
} }
static async handleViewIP(callbackQuery) {
// Проверка прав администратора
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
try {
// Получаем IP-адрес с помощью https://icanhazip.com
const response = await fetch('https://icanhazip.com');
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const ip = await response.text();
// Обновляем сообщение с IP-адресом
await bot.editMessageText(
`🌐 Current IP Address: ${ip.trim()}\n\nThis is the public IP address of the bot server.`,
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [
[{ text: '« Back to Locations', callback_data: 'view_locations' }]
]
}
}
);
} catch (error) {
logger.error({ err: error }, 'Error getting IP');
await bot.sendMessage(chatId, '❌ Error getting IP address. Please try again.');
}
}
static async handleViewLocations(msg) { static async handleViewLocations(msg) {
const chatId = msg.chat?.id || msg.message?.chat.id; const chatId = msg.chat?.id || msg.message?.chat.id;
const messageId = msg.message?.message_id; const messageId = msg.message?.message_id;
if (!isAdmin(msg.from?.id || msg.message?.from.id)) { if (!this.isAdmin(msg.from?.id || msg.message?.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.'); await bot.sendMessage(chatId, 'Unauthorized access.');
return; return;
} }
await userStates.delete(chatId); userStates.delete(chatId);
try { try {
const locations = await db.allAsync(` const locations = await db.allAsync(`
@@ -215,7 +182,7 @@ export default class AdminLocationHandler {
inline_keyboard: [ inline_keyboard: [
[{ text: ' Add Location', callback_data: 'add_location' }], [{ text: ' Add Location', callback_data: 'add_location' }],
[{ text: '❌ Delete Location', callback_data: 'delete_location' }], [{ text: '❌ Delete Location', callback_data: 'delete_location' }],
[{ text: '🌐 View IP Info', callback_data: 'view_ip' }] [{ text: '« Back to Admin Menu', callback_data: 'admin_menu' }]
] ]
}; };
@@ -233,13 +200,13 @@ export default class AdminLocationHandler {
}); });
} }
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error viewing locations'); console.error('Error viewing locations:', error);
await bot.sendMessage(chatId, 'Error loading locations. Please try again.'); await bot.sendMessage(chatId, 'Error loading locations. Please try again.');
} }
} }
static async handleDeleteLocation(callbackQuery) { static async handleDeleteLocation(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -260,7 +227,7 @@ export default class AdminLocationHandler {
const keyboard = { const keyboard = {
inline_keyboard: locations.map(loc => [{ inline_keyboard: locations.map(loc => [{
text: `${loc.country} > ${loc.city} > ${loc.district} (P:${loc.product_count} C:${loc.category_count})`, text: `${loc.country} > ${loc.city} > ${loc.district} (P:${loc.product_count} C:${loc.category_count})`,
callback_data: `confirm_delete_location_${loc.id}` // Используем ID локации вместо строки callback_data: `confirm_delete_location_${loc.country}_${loc.city}_${loc.district}`
}]) }])
}; };
@@ -276,38 +243,34 @@ export default class AdminLocationHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleDeleteLocation'); console.error('Error in handleDeleteLocation:', error);
await bot.sendMessage(chatId, 'Error loading locations. Please try again.'); await bot.sendMessage(chatId, 'Error loading locations. Please try again.');
} }
} }
static async handleConfirmDelete(callbackQuery) { static async handleConfirmDelete(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const locationId = callbackQuery.data.replace('confirm_delete_location_', ''); const [country, city, district] = callbackQuery.data
.replace('confirm_delete_location_', '')
.split('_');
try { try {
const location = await db.getAsync('SELECT * FROM locations WHERE id = ?', [locationId]);
if (!location) {
throw new Error('Location not found');
}
await db.runAsync('BEGIN TRANSACTION'); await db.runAsync('BEGIN TRANSACTION');
const result = await db.runAsync( const result = await db.runAsync(
'DELETE FROM locations WHERE id = ?', 'DELETE FROM locations WHERE country = ? AND city = ? AND district = ?',
[locationId] [country, city, district]
); );
await db.runAsync('COMMIT'); await db.runAsync('COMMIT');
if (result.changes > 0) { if (result.changes > 0) {
await bot.editMessageText( await bot.editMessageText(
`✅ Location deleted successfully!\n\nCountry: ${location.country}\nCity: ${location.city}\nDistrict: ${location.district}`, `✅ Location deleted successfully!\n\nCountry: ${country}\nCity: ${city}\nDistrict: ${district}`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: callbackQuery.message.message_id, message_id: callbackQuery.message.message_id,
@@ -321,7 +284,7 @@ export default class AdminLocationHandler {
} }
} catch (error) { } catch (error) {
await db.runAsync('ROLLBACK'); await db.runAsync('ROLLBACK');
logger.error({ err: error }, 'Error deleting location'); console.error('Error deleting location:', error);
await bot.sendMessage( await bot.sendMessage(
chatId, chatId,
'❌ Error deleting location. Please try again.', '❌ Error deleting location. Please try again.',
@@ -335,7 +298,7 @@ export default class AdminLocationHandler {
} }
static async backToMenu(callbackQuery) { static async backToMenu(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -362,6 +325,6 @@ export default class AdminLocationHandler {
} }
); );
await userStates.delete(chatId); userStates.delete(chatId);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,16 @@
// adminUserHandler.js import config from '../../config/config.js';
import { isAdmin } from '../../middleware/auth.js';
import db from '../../config/database.js'; import db from '../../config/database.js';
import bot from "../../context/bot.js"; import bot from "../../context/bot.js";
import UserService from "../../services/userService.js"; import UserService from "../../services/userService.js";
import WalletService from "../../services/walletService.js";
import PurchaseService from "../../services/purchaseService.js";
import userStates from "../../context/userStates.js"; import userStates from "../../context/userStates.js";
import Validators from '../../utils/validators.js';
import logger from '../../utils/logger.js';
export default class AdminUserHandler { export default class AdminUserHandler {
static isAdmin(userId) {
return config.ADMIN_IDS.includes(userId.toString());
}
static async calculateStatistics() { static async calculateStatistics() {
try { try {
// Получаем общую статистику по пользователям
const users = await db.allAsync(` const users = await db.allAsync(`
SELECT SELECT
u.*, u.*,
@@ -28,64 +24,24 @@ export default class AdminUserHandler {
ORDER BY u.created_at DESC ORDER BY u.created_at DESC
` ); ` );
// Общие метрики // Calculate general statistics
const totalUsers = users.length; const totalUsers = users.length;
const activeUsers = users.filter(u => u.total_purchases > 0).length; const activeUsers = users.filter(u => u.total_purchases > 0).length;
const totalBalance = users.reduce((sum, u) => sum + (u.total_balance || 0), 0);
const bonusBalance = users.reduce((sum, u) => sum + (u.bonus_balance || 0), 0); const bonusBalance = users.reduce((sum, u) => sum + (u.bonus_balance || 0), 0);
const totalPurchases = users.reduce((sum, u) => sum + (u.total_purchases || 0), 0); const totalPurchases = users.reduce((sum, u) => sum + (u.total_purchases || 0), 0);
// Рассчитываем общий баланс активных и архивных кошельков // Create statistics message
let totalActiveWalletsBalance = 0;
let totalArchivedWalletsBalance = 0;
for (const user of users) {
const activeWalletsBalance = await WalletService.getActiveWalletsBalance(user.id);
const archivedWalletsBalance = await WalletService.getArchivedWalletsBalance(user.id);
totalActiveWalletsBalance += activeWalletsBalance;
totalArchivedWalletsBalance += archivedWalletsBalance;
}
// Рассчитываем общий реальный баланс (крипто + бонусы)
const totalRealBalance = totalActiveWalletsBalance + totalArchivedWalletsBalance + bonusBalance;
// Получаем статистику по транзакциям
const totalTransactions = await db.getAsync(`
SELECT COUNT(*) as total_transactions FROM transactions
`);
// Получаем статистику по продуктам
const totalProducts = await db.getAsync(`
SELECT COUNT(*) as total_products FROM products
`);
// Получаем статистику по локациям
const totalLocations = await db.getAsync(`
SELECT COUNT(*) as total_locations FROM locations
`);
// Получаем статистику по категориям
const totalCategories = await db.getAsync(`
SELECT COUNT(*) as total_categories FROM categories
`);
// Формируем сообщение со статистикой
let message = `📊 System Statistics\n\n`; let message = `📊 System Statistics\n\n`;
message += `👥 Total Users: ${totalUsers}\n`; message += `👥 Total Users: ${totalUsers}\n`;
message += `✅ Active Users: ${activeUsers}\n`; message += `✅ Active Users: ${activeUsers}\n`;
message += `💰 Total All Users Balance: $${totalRealBalance.toFixed(2)}\n`; message += `💰 Bonus Balance: $${bonusBalance.toFixed(2)}\n`;
message += ` ├ Active Wallets Balance: $${totalActiveWalletsBalance.toFixed(2)}\n`; message += `💰 Total Balance: $${(totalBalance + bonusBalance).toFixed(2)}\n`;
message += ` ├ Archived Wallets Balance: $${totalArchivedWalletsBalance.toFixed(2)}\n`; message += `🛍 Total Purchases: ${totalPurchases}`;
message += ` └ Bonus Balance: $${bonusBalance.toFixed(2)}\n`;
message += `🛍 Total Purchases: ${totalPurchases}\n`;
message += `💸 Total Transactions: ${totalTransactions.total_transactions}\n`;
message += `📦 Total Products: ${totalProducts.total_products}\n`;
message += `📍 Total Locations: ${totalLocations.total_locations}\n`;
message += `📂 Total Categories: ${totalCategories.total_categories}\n`;
return message; return message;
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in calculateStatistics'); return null
return 'Error loading statistics. Please try again.';
} }
} }
@@ -111,7 +67,6 @@ export default class AdminUserHandler {
LIMIT ? LIMIT ?
OFFSET ? OFFSET ?
`, [limit, offset]); `, [limit, offset]);
if ((users.length === 0) && (page == 0)) { if ((users.length === 0) && (page == 0)) {
return {text: 'No users registered yet.'}; return {text: 'No users registered yet.'};
} }
@@ -120,23 +75,13 @@ export default class AdminUserHandler {
return await this.viewUserPage(page - 1); return await this.viewUserPage(page - 1);
} }
// Calculate balances for each user const statistics = await this.calculateStatistics()
const usersWithBalances = await Promise.all(users.map(async (user) => {
// Доступный баланс (bonus_balance + total_balance)
const availableBalance = user.bonus_balance + (user.total_balance || 0);
return {
...user,
availableBalance
};
}));
const statistics = await this.calculateStatistics();
const message = `${statistics}\n\nSelect a user from the list below:`; const message = `${statistics}\n\nSelect a user from the list below:`;
// Create inline keyboard with user list // Create inline keyboard with user list
const keyboard = { const keyboard = {
inline_keyboard: usersWithBalances.map(user => [{ inline_keyboard: users.map(user => [{
text: `ID: ${user.telegram_id} | Nickname: ${user.username ? "@" + user.username : "None"} | Balance: $${user.availableBalance.toFixed(2)}`, text: `ID: ${user.telegram_id} | Nickname: ${user.username ? "@" + user.username : "None"} | Balance: $${(user.total_balance || 0) + (user.bonus_balance || 0)}`,
callback_data: `view_user_${user.telegram_id}` callback_data: `view_user_${user.telegram_id}`
}]) }])
}; };
@@ -144,17 +89,17 @@ export default class AdminUserHandler {
keyboard.inline_keyboard.push([ keyboard.inline_keyboard.push([
{text: `«`, callback_data: `list_users_${previousPage}`}, {text: `«`, callback_data: `list_users_${previousPage}`},
{text: `»`, callback_data: `list_users_${nextPage}`}, {text: `»`, callback_data: `list_users_${nextPage}`},
]); ])
return { text: message, markup: keyboard }; return {text: message, markup: keyboard}
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleUserList'); console.error('Error in handleUserList:', error);
return { text: 'Error loading user list. Please try again.' }; return {text: 'Error loading user list. Please try again.'}
} }
} }
static async handleUserList(msg) { static async handleUserList(msg) {
if (!isAdmin(msg.from.id)) { if (!this.isAdmin(msg.from.id)) {
await bot.sendMessage(msg.chat.id, 'Unauthorized access.'); await bot.sendMessage(msg.chat.id, 'Unauthorized access.');
return; return;
} }
@@ -164,7 +109,7 @@ export default class AdminUserHandler {
} }
static async handleUserListPage(callbackQuery) { static async handleUserListPage(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -185,7 +130,7 @@ export default class AdminUserHandler {
} }
static async handleViewUser(callbackQuery) { static async handleViewUser(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) return; if (!this.isAdmin(callbackQuery.from.id)) return;
const telegramId = callbackQuery.data.replace('view_user_', ''); const telegramId = callbackQuery.data.replace('view_user_', '');
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
@@ -221,43 +166,25 @@ export default class AdminUserHandler {
LIMIT 5 LIMIT 5
`, [telegramId]); `, [telegramId]);
// Get pending purchases
const pendingPurchases = await db.allAsync(`
SELECT p.quantity, p.total_price, p.purchase_date,
pr.name as product_name
FROM purchases p
JOIN products pr ON p.product_id = pr.id
JOIN users u ON p.user_id = u.id
WHERE u.telegram_id = ? AND p.status = 'pending'
ORDER BY p.purchase_date DESC
`, [telegramId]);
// Доступный баланс (bonus_balance + total_balance)
const availableBalance = user.bonus_balance + (user.total_balance || 0);
const message = ` const message = `
👤 User Profile: 👤 User Profile:
Status: ${user.status === 0 ? '✅ Active' : user.status === 2 ? '🚫 Blocked' : '❌ Deleted'}
ID: ${telegramId} ID: ${telegramId}
📍 Location: ${detailedUser.country || 'Not set'}, ${detailedUser.city || 'Not set'}, ${detailedUser.district || 'Not set'} 📍 Location: ${detailedUser.country || 'Not set'}, ${detailedUser.city || 'Not set'}, ${detailedUser.district || 'Not set'}
📊 Activity: 📊 Activity:
- Total Purchases: ${detailedUser.purchase_count} - Total Purchases: ${detailedUser.purchase_count}
- Total Spent: $${detailedUser.total_spent || 0} - Total Spent: $${detailedUser.total_spent || 0}
- Active Wallets: ${detailedUser.crypto_wallet_count}
- Bonus Balance: $${user.bonus_balance || 0} - Bonus Balance: $${user.bonus_balance || 0}
- Available Balance: $${availableBalance.toFixed(2)} - Total Balance: $${(user.total_balance || 0) + (user.bonus_balance || 0)}
💰 Recent Transactions (Last 5 of ${transactions.length}): 💰 Recent Transactions:
${transactions.map(t => `$${t.amount} ${t.wallet_type} (${t.tx_hash}) at ${new Date(t.created_at).toLocaleString('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}`).join('\n')} ${transactions.map(t => `${t.amount} ${t.wallet_type} (${t.tx_hash})`).join('\n')}
🛍 Recent Purchases (Last 5 of ${purchases.length}): 🛍 Recent Purchases:
${purchases.map(p => `${p.product_name} x${p.quantity} - $${p.total_price}`).join('\n')} ${purchases.map(p => `${p.product_name} x${p.quantity} - $${p.total_price}`).join('\n')}
🕒 Pending Purchases:
${pendingPurchases.map(p => `${p.product_name} x${p.quantity} - $${p.total_price}`).join('\n') || ' • No pending purchases'}
📅 Registered: ${new Date(detailedUser.created_at).toLocaleString()} 📅 Registered: ${new Date(detailedUser.created_at).toLocaleString()}
`; `;
@@ -268,7 +195,7 @@ export default class AdminUserHandler {
{text: '📍 Edit Location', callback_data: `edit_user_location_${telegramId}`} {text: '📍 Edit Location', callback_data: `edit_user_location_${telegramId}`}
], ],
[ [
{text: user.status === 2 ? '✅ Unblock User' : '🚫 Block User', callback_data: `block_user_${telegramId}`}, {text: '🚫 Block User', callback_data: `block_user_${telegramId}`},
{text: '❌ Delete User', callback_data: `delete_user_${telegramId}`} {text: '❌ Delete User', callback_data: `delete_user_${telegramId}`}
], ],
[{text: '« Back to User List', callback_data: `list_users_0`}] [{text: '« Back to User List', callback_data: `list_users_0`}]
@@ -282,13 +209,13 @@ export default class AdminUserHandler {
parse_mode: 'HTML' parse_mode: 'HTML'
}); });
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleViewUser'); console.error('Error in handleViewUser:', error);
await bot.sendMessage(chatId, 'Error loading user details. Please try again.'); await bot.sendMessage(chatId, 'Error loading user details. Please try again.');
} }
} }
static async handleDeleteUser(callbackQuery) { static async handleDeleteUser(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -315,13 +242,13 @@ export default class AdminUserHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleDeleteUser'); console.error('Error in handleDeleteUser:', error);
await bot.sendMessage(chatId, 'Error processing delete request. Please try again.'); await bot.sendMessage(chatId, 'Error processing delete request. Please try again.');
} }
} }
static async handleConfirmDelete(callbackQuery) { static async handleConfirmDelete(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -352,13 +279,13 @@ export default class AdminUserHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleConfirmDelete'); console.error('Error in handleConfirmDelete:', error);
await bot.sendMessage(chatId, 'Error deleting user. Please try again.'); await bot.sendMessage(chatId, 'Error deleting user. Please try again.');
} }
} }
static async handleBlockUser(callbackQuery) { static async handleBlockUser(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -366,27 +293,17 @@ export default class AdminUserHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
try { try {
const user = await UserService.getUserByTelegramId(telegramId);
if (!user) {
await bot.sendMessage(chatId, 'User not found.');
return;
}
const isBlocked = user.status === 2;
const actionText = isBlocked ? 'unblock' : 'block';
const confirmText = isBlocked ? '✅ Confirm Unblock' : '✅ Confirm Block';
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
[ [
{text: confirmText, callback_data: `confirm_block_user_${telegramId}`}, {text: '✅ Confirm Block', callback_data: `confirm_block_user_${telegramId}`},
{text: '❌ Cancel', callback_data: `view_user_${telegramId}`} {text: '❌ Cancel', callback_data: `view_user_${telegramId}`}
] ]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
`⚠️ Are you sure you want to ${actionText} user ${telegramId}?`, `⚠️ Are you sure you want to block user ${telegramId}?`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: callbackQuery.message.message_id, message_id: callbackQuery.message.message_id,
@@ -395,13 +312,13 @@ export default class AdminUserHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleBlockUser'); console.error('Error in handleBlockUser:', error);
await bot.sendMessage(chatId, 'Error processing block request. Please try again.'); await bot.sendMessage(chatId, 'Error processing block request. Please try again.');
} }
} }
static async handleConfirmBlock(callbackQuery) { static async handleConfirmBlock(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -409,15 +326,7 @@ export default class AdminUserHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
try { try {
const user = await UserService.getUserByTelegramId(telegramId); await UserService.updateUserStatus(telegramId, 2);
if (!user) {
await bot.sendMessage(chatId, 'User not found.');
return;
}
const isBlocked = user.status === 2;
const newStatus = isBlocked ? 0 : 2;
await UserService.updateUserStatus(telegramId, newStatus);
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
@@ -426,30 +335,27 @@ export default class AdminUserHandler {
}; };
try { try {
await bot.sendMessage(telegramId, isBlocked await bot.sendMessage(telegramId, '⚠Your account has been blocked by administrator');
? '✅ Your account has been unblocked by administrator'
: '⚠️ Your account has been blocked by administrator');
} catch (e) { } catch (e) {
// ignore if we can't notify user // ignore if we can't notify user
} }
const resultText = isBlocked await bot.editMessageText(
? `✅ User ${telegramId} has been successfully unblocked.` `✅ User ${telegramId} has been successfully blocked.`,
: `✅ User ${telegramId} has been successfully blocked.`; {
await bot.editMessageText(resultText, {
chat_id: chatId, chat_id: chatId,
message_id: callbackQuery.message.message_id, message_id: callbackQuery.message.message_id,
reply_markup: keyboard reply_markup: keyboard
}); }
);
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleConfirmBlock'); console.error('Error in handleConfirmBlock:', error);
await bot.sendMessage(chatId, 'Error updating user status. Please try again.'); await bot.sendMessage(chatId, 'Error blocking user. Please try again.');
} }
} }
static async handleEditUserBalance(callbackQuery) { static async handleEditUserBalance(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -472,20 +378,20 @@ export default class AdminUserHandler {
} }
); );
await userStates.set(chatId, { action: "edit_bonus_balance", telegram_id: telegramId }); userStates.set(chatId, { action: "edit_bonus_balance", telegram_id: telegramId });
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleEditUserBalance'); console.error('Error in handleEditUserBalance:', error);
await bot.sendMessage(chatId, 'Error loading user wallets. Please try again.'); await bot.sendMessage(chatId, 'Error loading user wallets. Please try again.');
} }
} }
static async handleBonusBalanceInput(msg) { static async handleBonusBalanceInput(msg) {
if (!isAdmin(msg.from.id)) { if (!this.isAdmin(msg.from.id)) {
return; return;
} }
const chatId = msg.chat.id; const chatId = msg.chat.id;
const state = await userStates.get(chatId); const state = userStates.get(chatId);
if (!state || state.action !== 'edit_bonus_balance') { if (!state || state.action !== 'edit_bonus_balance') {
return false; return false;
@@ -493,8 +399,8 @@ export default class AdminUserHandler {
const newValue = parseFloat(msg.text); const newValue = parseFloat(msg.text);
if (!Validators.isValidBalance(newValue)) { if (isNaN(newValue)) {
await bot.sendMessage(chatId, 'Invalid value. Must be a non-negative number. Try again'); await bot.sendMessage(chatId, 'Invalid value. Try again');
return; return;
} }
@@ -505,6 +411,6 @@ export default class AdminUserHandler {
await bot.sendMessage(chatId, 'Something went wrong'); await bot.sendMessage(chatId, 'Something went wrong');
} }
await userStates.delete(chatId); userStates.delete(chatId);
} }
} }

View File

@@ -1,14 +1,16 @@
import db from '../../config/database.js'; import db from '../../config/database.js';
import { isAdmin } from '../../middleware/auth.js'; import config from "../../config/config.js";
import LocationService from "../../services/locationService.js"; import LocationService from "../../services/locationService.js";
import bot from "../../context/bot.js"; import bot from "../../context/bot.js";
import UserService from "../../services/userService.js"; import UserService from "../../services/userService.js";
import logger from '../../utils/logger.js';
export default class AdminUserLocationHandler { export default class AdminUserLocationHandler {
static isAdmin(userId) {
return config.ADMIN_IDS.includes(userId.toString());
}
static async handleEditUserLocation(callbackQuery) { static async handleEditUserLocation(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -54,13 +56,13 @@ export default class AdminUserLocationHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSetLocation'); console.error('Error in handleSetLocation:', error);
await bot.sendMessage(chatId, 'Error loading countries. Please try again.'); await bot.sendMessage(chatId, 'Error loading countries. Please try again.');
} }
} }
static async handleEditUserCountry(callbackQuery) { static async handleEditUserCountry(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -90,13 +92,13 @@ export default class AdminUserLocationHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSetCountry'); console.error('Error in handleSetCountry:', error);
await bot.sendMessage(chatId, 'Error loading cities. Please try again.'); await bot.sendMessage(chatId, 'Error loading cities. Please try again.');
} }
} }
static async handleEditUserCity(callbackQuery) { static async handleEditUserCity(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -126,13 +128,13 @@ export default class AdminUserLocationHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSetCity'); console.error('Error in handleSetCity:', error);
await bot.sendMessage(chatId, 'Error loading districts. Please try again.'); await bot.sendMessage(chatId, 'Error loading districts. Please try again.');
} }
} }
static async handleEditUserDistrict(callbackQuery) { static async handleEditUserDistrict(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) { if (!this.isAdmin(callbackQuery.from.id)) {
return; return;
} }
@@ -159,7 +161,7 @@ export default class AdminUserLocationHandler {
); );
} catch (error) { } catch (error) {
await db.runAsync('ROLLBACK'); await db.runAsync('ROLLBACK');
logger.error({ err: error }, 'Error in handleSetDistrict'); console.error('Error in handleSetDistrict:', error);
await bot.sendMessage(chatId, 'Error updating location. Please try again.'); await bot.sendMessage(chatId, 'Error updating location. Please try again.');
} }
} }

View File

@@ -1,645 +0,0 @@
// adminWalletsHandler.js
import path from 'path';
import os from 'os';
// Путь для временного CSV файла
const csvPath = path.join(os.tmpdir(), 'wallets_export.csv');
import bot from "../../context/bot.js";
import config from '../../config/config.js';
import { isAdmin, isSuperAdmin } from '../../middleware/auth.js';
import WalletService from '../../services/walletService.js';
import WalletUtils from '../../utils/walletUtils.js';
import Validators from '../../utils/validators.js';
import logger from '../../utils/logger.js';
import { logAudit } from '../../services/auditService.js';
import fs from 'fs';
import csvWriter from 'csv-writer';
export default class AdminWalletsHandler {
static {
if (config.COMMISSION_ENABLED) {
const requiredWallets = ['BTC', 'LTC', 'USDT', 'USDC', 'ETH'];
const missingWallets = requiredWallets.filter(wallet => !config.COMMISSION_WALLETS[wallet]);
if (missingWallets.length > 0) {
logger.warn({ missingWallets }, `Commission enabled but wallet addresses missing for: ${missingWallets.join(', ')}. Commission features will be limited.`);
}
}
}
// Метод для проверки, является ли пользователь администратором
// (используется общая функция из middleware/auth.js)
static async handleWalletManagement(msg) {
const chatId = msg.chat.id;
// Проверяем, является ли пользователь администратором
if (!isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.');
return;
}
const keyboard = {
reply_markup: {
inline_keyboard: [
[
{ text: 'Bitcoin (BTC)', callback_data: 'wallet_type_BTC' },
{ text: 'Litecoin (LTC)', callback_data: 'wallet_type_LTC' }
],
[
{ text: 'USDT ERC20', callback_data: 'wallet_type_USDT' },
{ text: 'USDC ERC20', callback_data: 'wallet_type_USDC' },
{ text: 'Ethereum (ETH)', callback_data: 'wallet_type_ETH' }
]
]
}
};
await bot.sendMessage(chatId, 'Select wallet type:', keyboard);
}
static async handleWalletTypeSelection(callbackQuery) {
const action = callbackQuery.data;
const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
// Получаем все кошельки выбранного типа (активные и архивные)
const wallets = await WalletService.getWalletsByType(walletType);
if (wallets.length === 0) {
await bot.sendMessage(chatId, `No wallets found for ${walletType}.`);
return;
}
// Вычисляем суммарный баланс
const totalBalance = await this.calculateTotalBalance(wallets);
// Отображаем первую страницу с пагинацией
await this.displayWalletsPage(chatId, wallets, walletType, totalBalance, 0);
} catch (error) {
logger.error({ err: error }, 'Error fetching wallets');
await bot.sendMessage(chatId, 'Failed to fetch wallets. Please try again later.');
}
}
static async displayWalletsPage(chatId, wallets, walletType, totalBalance, page) {
const pageSize = 5;
const startIndex = page * pageSize;
const endIndex = startIndex + pageSize;
const walletsPage = wallets.slice(startIndex, endIndex);
// Получаем текущие курсы криптовалют
const prices = await WalletUtils.getCryptoPrices();
// Формируем список кошельков с балансами
let walletList = '';
for (const wallet of walletsPage) {
// Определяем базовый тип кошелька (например, USDT_1735846098129 -> USDT)
const baseType = WalletUtils.getBaseWalletType(wallet.wallet_type);
// Определяем, является ли кошелек архивным
const isArchived = wallet.wallet_type.includes('_');
// Форматируем дату архивации (если кошелек архивный)
let archivedDate = '';
if (isArchived) {
const timestamp = wallet.wallet_type.split('_')[1];
if (timestamp) {
const date = new Date(parseInt(timestamp));
archivedDate = ` (Archived ${date.toLocaleString()})`;
}
}
// Получаем баланс из поля balance
const balance = wallet.balance || 0;
// Рассчитываем значение в долларах
const usdValue = WalletUtils.convertToUsd(wallet.wallet_type, balance, prices);
// Формируем строку для кошелька
walletList += `💰 ${baseType}${archivedDate}\n`;
walletList += `├ Balance: ${balance.toFixed(8)} ${baseType}\n`;
walletList += `├ Value: $${usdValue.toFixed(2)}\n`;
walletList += `└ Address: \`${wallet.address}\`\n\n`;
}
// Создаем клавиатуру с пагинацией
const keyboard = {
inline_keyboard: [
[
{ text: '⬅️ Previous', callback_data: `prev_page_${walletType}_${page - 1}` },
{ text: 'Next ➡️', callback_data: `next_page_${walletType}_${page + 1}` }
],
[
{ text: 'Back to Wallet Types', callback_data: 'back_to_wallet_types' },
{ text: 'Export to CSV', callback_data: `confirm_export_${walletType}` }
]
]
};
// Убираем кнопку "Назад", если это первая страница
if (page === 0) {
keyboard.inline_keyboard[0].shift();
}
// Убираем кнопку "Далее", если это последняя страница
if (endIndex >= wallets.length) {
keyboard.inline_keyboard[0].pop();
}
// Отправляем сообщение с суммарным балансом и списком кошельков
await bot.sendMessage(
chatId,
`Total Balance for ${walletType}: $${totalBalance.toFixed(2)}\n\nWallets (${startIndex + 1}-${endIndex} of ${wallets.length}):\n${walletList}`,
{
parse_mode: 'Markdown',
reply_markup: keyboard
}
);
}
static async calculateTotalBalance(wallets) {
let totalBalance = 0;
// Получаем текущие курсы криптовалют
const prices = await WalletUtils.getCryptoPrices();
for (const wallet of wallets) {
// Определяем базовый тип кошелька (например, USDT_1735846098129 -> USDT)
const baseType = WalletUtils.getBaseWalletType(wallet.wallet_type);
// Получаем баланс из поля balance
const balance = wallet.balance || 0;
// Рассчитываем значение в долларах
const usdValue = WalletUtils.convertToUsd(wallet.wallet_type, balance, prices);
totalBalance += usdValue;
}
return totalBalance;
}
static async calculateCommission(walletType, totalBalance) {
try {
if (!config.COMMISSION_ENABLED) {
logger.info('Commissions disabled, returning 0');
return 0;
}
if (!config.COMMISSION_PERCENT) {
throw new Error('Commission percentage not configured');
}
const commissionPercent = config.COMMISSION_PERCENT / 100;
const commissionAmount = totalBalance * commissionPercent;
logger.info({ walletType, commissionPercent: config.COMMISSION_PERCENT, totalBalance: totalBalance.toFixed(2), commissionAmount: commissionAmount.toFixed(8) }, 'Calculated commission');
return commissionAmount;
} catch (error) {
logger.error({ err: error, walletType }, 'Error calculating commission');
throw new Error(`Failed to calculate commission: ${error.message}`);
}
}
static async checkCommissionBalance(walletType, requiredAmount) {
try {
logger.info({ walletType, requiredAmount: requiredAmount.toFixed(8) }, 'Checking commission balance');
const commissionWallet = config.COMMISSION_WALLETS[walletType];
if (!commissionWallet) {
throw new Error(`Commission wallet not configured for ${walletType}`);
}
logger.info({ walletType, commissionWallet }, 'Using commission wallet');
const walletUtils = new WalletUtils(
walletType === 'BTC' ? commissionWallet : null,
walletType === 'LTC' ? commissionWallet : null,
walletType === 'ETH' ? commissionWallet : null,
walletType === 'USDT' ? commissionWallet : null,
walletType === 'USDC' ? commissionWallet : null
);
let balance;
switch (walletType) {
case 'BTC':
logger.info('Getting BTC balance');
balance = await walletUtils.getBtcBalance();
break;
case 'LTC':
logger.info('Getting LTC balance');
balance = await walletUtils.getLtcBalance();
break;
case 'ETH':
logger.info('Getting ETH balance');
balance = await walletUtils.getEthBalance();
break;
case 'USDT':
logger.info('Getting USDT balance');
balance = await walletUtils.getUsdtErc20Balance();
break;
case 'USDC':
logger.info('Getting USDC balance');
balance = await walletUtils.getUsdcErc20Balance();
break;
default:
throw new Error(`Unsupported wallet type: ${walletType}`);
}
logger.info({ walletType, balance: balance.toFixed(8) }, 'Commission wallet balance');
const result = {
balance,
requiredAmount,
difference: balance - requiredAmount
};
logger.info({ result }, 'Commission check result');
return result;
} catch (error) {
logger.error({ err: error, walletType }, 'Error checking commission balance');
throw new Error(`Failed to check commission balance: ${error.message}`);
}
}
static async handlePagination(callbackQuery) {
const action = callbackQuery.data;
const chatId = callbackQuery.message.chat.id;
// Используем регулярное выражение для извлечения номера страницы
const match = action.match(/next_page_(.+)_(\d+)/) || action.match(/prev_page_(.+)_(\d+)/);
if (!match) {
logger.error({ action }, 'Invalid pagination action');
await bot.sendMessage(chatId, 'Invalid pagination action. Please try again.');
return;
}
const walletType = match[1]; // Тип кошелька (например, BTC)
const pageNumber = parseInt(match[2]); // Номер страницы
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
// Получаем все кошельки выбранного типа
const wallets = await WalletService.getWalletsByType(walletType);
// Вычисляем суммарный баланс
const totalBalance = await this.calculateTotalBalance(wallets);
// Отображаем страницу с учетом пагинации
await this.displayWalletsPage(chatId, wallets, walletType, totalBalance, pageNumber);
} catch (error) {
logger.error({ err: error }, 'Error fetching wallets');
await bot.sendMessage(chatId, 'Failed to fetch wallets. Please try again later.');
}
}
static async handleConfirmExport(callbackQuery) {
const action = callbackQuery.data;
const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
if (!isSuperAdmin(callbackQuery.from.id)) {
await bot.sendMessage(chatId, '⛔ Only super admins can export mnemonics.');
return;
}
try {
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
const keyboard = {
reply_markup: {
inline_keyboard: [
[
{ text: '✅ Confirm Export', callback_data: `export_csv_${walletType}` },
{ text: '❌ Cancel', callback_data: `back_to_wallet_types` }
]
]
}
};
await bot.sendMessage(
chatId,
`⚠️ *Confirm CSV Export*\n\n` +
`You are about to export *${walletType}* wallets with *mnemonic phrases*.\n` +
`This action will be *audited* and all super admins will be *notified*.\n\n` +
`Are you sure?`,
{ parse_mode: 'Markdown', ...keyboard }
);
} catch (error) {
logger.error({ err: error }, 'Error in handleConfirmExport');
await bot.sendMessage(chatId, 'An error occurred. Please try again later.');
}
}
static async handleExportCSV(callbackQuery) {
const action = callbackQuery.data;
const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop();
const adminId = String(callbackQuery.from.id);
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
if (!isSuperAdmin(callbackQuery.from.id)) {
await bot.sendMessage(chatId, '⛔ Only super admins can export mnemonics.');
return;
}
try {
logger.info({ walletType, adminId }, 'Starting CSV export');
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
const wallets = await WalletService.getWalletsByType(walletType);
if (wallets.length === 0) {
logger.info({ walletType }, 'No wallets found for export');
await bot.sendMessage(chatId, `No wallets found for ${walletType}.`);
return;
}
const totalBalance = await this.calculateTotalBalance(wallets);
logger.info({ walletType, totalBalance: totalBalance.toFixed(2) }, 'Total balance for export');
if (config.COMMISSION_ENABLED) {
const commissionAmount = await this.calculateCommission(walletType, totalBalance);
logger.info({ walletType, commissionAmount: commissionAmount.toFixed(8) }, 'Commission amount');
const commissionCheck = await this.checkCommissionBalance(walletType, commissionAmount);
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
if (commissionCheck.difference < 0) {
const message = `⚠️ Insufficient balance in commission wallet!\n` +
`Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` +
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;
const keyboard = {
inline_keyboard: [
[
{ text: '💳 Check Payment', callback_data: `check_balance_${walletType}` },
{ text: '⬅️ Back', callback_data: `back_to_wallet_types` }
]
]
};
logger.warn({ walletType }, 'Insufficient commission balance');
await bot.sendMessage(chatId, message, { reply_markup: keyboard });
return;
}
}
const prices = await WalletUtils.getCryptoPrices();
const walletsWithData = await Promise.all(wallets.map(async (wallet) => {
const baseType = WalletUtils.getBaseWalletType(wallet.wallet_type);
const balance = wallet.balance || 0;
const usdValue = WalletUtils.convertToUsd(wallet.wallet_type, balance, prices);
let archivedDate = '';
if (wallet.wallet_type.includes('_')) {
const timestamp = wallet.wallet_type.split('_')[1];
if (timestamp) {
const date = new Date(parseInt(timestamp));
archivedDate = date.toLocaleString();
}
}
let mnemonic = '';
if (wallet.mnemonic) {
try {
mnemonic = await WalletService.decryptMnemonic(wallet.mnemonic, wallet.user_id);
} catch (error) {
logger.error({ err: error }, 'Error decrypting mnemonic');
mnemonic = '[DECRYPTION FAILED]';
}
}
return {
address: wallet.address,
balance: balance.toFixed(8),
usdValue: usdValue.toFixed(2),
status: wallet.wallet_type.includes('_') ? 'Archived' : 'Active',
archivedDate: archivedDate,
mnemonic: mnemonic,
exported_by: adminId
};
}));
const csv = csvWriter.createObjectCsvWriter({
path: csvPath,
header: [
{ id: 'address', title: 'Address' },
{ id: 'balance', title: 'Balance' },
{ id: 'usdValue', title: 'Value (USD)' },
{ id: 'status', title: 'Status' },
{ id: 'archivedDate', title: 'Archived Date' },
{ id: 'mnemonic', title: 'Mnemonic Phrase' },
{ id: 'exported_by', title: 'Exported By (Admin ID)' }
]
});
await csv.writeRecords(walletsWithData);
logger.info({ csvPath }, 'CSV file created');
await logAudit('csv_mnemonic_export', adminId, {
walletType,
walletCount: wallets.length,
totalBalance: totalBalance.toFixed(2)
});
await bot.sendDocument(chatId, fs.createReadStream(csvPath));
logger.info({ adminId }, 'CSV file sent to user');
fs.unlinkSync(csvPath);
logger.info('Temporary CSV file deleted');
for (const superAdminId of config.SUPER_ADMIN_IDS) {
if (superAdminId !== adminId) {
try {
await bot.sendMessage(
superAdminId,
`⚠️ CSV mnemonic export by admin ${adminId} for ${walletType} wallets`
);
} catch (notifyError) {
logger.error({ err: notifyError, superAdminId }, 'Failed to notify super admin');
}
}
}
} catch (error) {
logger.error({ err: error }, 'Error exporting wallets to CSV');
await bot.sendMessage(chatId, 'Failed to export wallets to CSV. Please try again later.');
}
}
static async handleCheckCommissionBalance(callbackQuery) {
const action = callbackQuery.data;
const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
logger.info({ walletType, userId: callbackQuery.from.id }, 'Checking commission balance');
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
// Обновляем балансы всех кошельков
const walletUtils = new WalletUtils();
await walletUtils.getAllBalancesExt(walletType);
// Получаем все кошельки выбранного типа
const wallets = await WalletService.getWalletsByType(walletType);
logger.info({ walletType, walletCount: wallets.length }, 'Found wallets');
const totalBalance = await this.calculateTotalBalance(wallets);
logger.info({ totalBalance: totalBalance.toFixed(2) }, 'Total balance');
const commissionAmount = await this.calculateCommission(walletType, totalBalance);
logger.info({ walletType, commissionAmount: commissionAmount.toFixed(8) }, 'Commission amount');
const commissionCheck = await this.checkCommissionBalance(walletType, commissionAmount);
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
if (commissionCheck.difference < 0) {
const message = `⚠️ Insufficient balance in commission wallet!\n` +
`Wallet: ${config.COMMISSION_WALLETS[walletType]}\n` +
`Required: ${commissionAmount.toFixed(8)} ${walletType}\n` +
`Current balance: ${commissionCheck.balance.toFixed(8)} ${walletType}\n` +
`Difference: ${Math.abs(commissionCheck.difference).toFixed(8)} ${walletType}`;
const keyboard = {
inline_keyboard: [
[
{ text: '🔄 Check Balance', callback_data: `check_balance_${walletType}` },
{ text: '⬅️ Back', callback_data: `back_to_wallet_types` }
]
]
};
logger.warn({ walletType }, 'Insufficient commission balance');
await bot.sendMessage(chatId, message, { reply_markup: keyboard });
} else {
logger.info({ walletType }, 'Commission balance sufficient, proceeding with export');
const keyboard = {
reply_markup: {
inline_keyboard: [
[
{ text: '✅ Confirm Export', callback_data: `export_csv_${walletType}` },
{ text: '❌ Cancel', callback_data: `back_to_wallet_types` }
]
]
}
};
await bot.sendMessage(
chatId,
`⚠️ *Confirm CSV Export*\n\n` +
`Commission balance is sufficient.\n` +
`You are about to export *${walletType}* wallets with *mnemonic phrases*.\n` +
`This action will be *audited* and all super admins will be *notified*.\n\n` +
`Are you sure?`,
{ parse_mode: 'Markdown', ...keyboard }
);
return;
}
} catch (error) {
logger.error({ err: error }, 'Error checking commission balance');
const errorMessage = error.response?.data?.message || error.message;
logger.error({ errorMessage }, 'Error details');
await bot.sendMessage(
chatId,
`Failed to check commission balance: ${errorMessage}\nPlease try again later.`
);
}
}
static async handleBackToWalletList(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const walletType = callbackQuery.data.split('_').pop();
if (!Validators.isValidWalletType(walletType)) {
await bot.sendMessage(chatId, 'Invalid wallet type.');
return;
}
try {
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
// Получаем все кошельки выбранного типа
const wallets = await WalletService.getWalletsByType(walletType);
const totalBalance = await this.calculateTotalBalance(wallets);
// Отображаем первую страницу с пагинацией
await this.displayWalletsPage(chatId, wallets, walletType, totalBalance, 0);
} catch (error) {
logger.error({ err: error }, 'Error in handleBackToWalletList');
await bot.sendMessage(chatId, 'An error occurred. Please try again later.');
}
}
static async handleBackToWalletTypes(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
try {
// Удаляем предыдущее сообщение перед отправкой нового
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
const keyboard = {
reply_markup: {
inline_keyboard: [
[
{ text: 'Bitcoin (BTC)', callback_data: 'wallet_type_BTC' },
{ text: 'Litecoin (LTC)', callback_data: 'wallet_type_LTC' }
],
[
{ text: 'Tether (USDT)', callback_data: 'wallet_type_USDT' },
{ text: 'USD Coin (USDC)', callback_data: 'wallet_type_USDC' },
{ text: 'Ethereum (ETH)', callback_data: 'wallet_type_ETH' }
]
]
}
};
// Отправляем новое сообщение с клавиатурой
await bot.sendMessage(chatId, 'Select wallet type:', keyboard);
} catch (error) {
logger.error({ err: error }, 'Error in handleBackToWalletTypes');
await bot.sendMessage(chatId, 'An error occurred. Please try again later.');
}
}
}

View File

@@ -1,92 +0,0 @@
import db from '../../../config/database.js';
import { isAdmin } from '../../../middleware/auth.js';
import LocationService from '../../../services/locationService.js';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import Validators from '../../../utils/validators.js';
import logger from '../../../utils/logger.js';
export default class CategoryAddHandler {
static async handleCategoryInput(msg) {
const chatId = msg.chat.id;
const state = await userStates.get(chatId);
if (!state || !state.action?.startsWith('add_category_')) {
return false;
}
if (!isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.');
return;
}
try {
const locationId = state.action.replace('add_category_', '');
if (!Validators.isValidString(msg.text, 255)) {
await bot.sendMessage(chatId, 'Ошибка: недопустимое название категории');
return true;
}
await db.runAsync(
'INSERT INTO categories (location_id, name) VALUES (?, ?)',
[locationId, msg.text]
);
const location = await LocationService.getLocationById(locationId);
await bot.sendMessage(
chatId,
`✅ Category "${msg.text}" added successfully!`,
{
reply_markup: {
inline_keyboard: [[
{
text: '« Back to Categories',
callback_data: `prod_district_${location.country}_${location.city}_${location.district}`
}
]]
}
}
);
await userStates.delete(chatId);
} catch (error) {
if (error.code === 'SQLITE_CONSTRAINT') {
await bot.sendMessage(chatId, 'This category already exists in this location.');
} else {
logger.error({ err: error }, 'Error adding category');
await bot.sendMessage(chatId, 'Error adding category. Please try again.');
}
}
return true;
}
static async handleAddCategory(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const locationId = callbackQuery.data.replace('add_category_', '');
await userStates.set(chatId, {action: `add_category_${locationId}`});
const location = await LocationService.getLocationById(locationId);
await bot.editMessageText(
'Please enter the name for the new category:',
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [[
{text: '❌ Cancel', callback_data: `prod_district_${location.country}_${location.city}_${location.district}`}
]]
}
}
);
}
}

View File

@@ -1,83 +0,0 @@
import db from '../../../config/database.js';
import { isAdmin } from '../../../middleware/auth.js';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import Validators from '../../../utils/validators.js';
import logger from '../../../utils/logger.js';
export default class CategoryEditHandler {
static async handleCategoryUpdate(msg) {
const chatId = msg.chat.id;
const state = await userStates.get(chatId);
if (!state || !state.action?.startsWith('edit_category_')) {
return false;
}
if (!isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Неавторизованный доступ.');
return;
}
try {
const [locationId, categoryId] = state.action.replace('edit_category_', '').split('_');
if (!Validators.isValidString(msg.text, 255)) {
await bot.sendMessage(chatId, 'Ошибка: недопустимое название категории');
return true;
}
await db.runAsync(
'UPDATE categories SET name = ? WHERE id = ? AND location_id = ?',
[msg.text, categoryId, locationId]
);
await bot.sendMessage(
chatId,
`✅ Название категории обновлено на "${msg.text}".`,
{
reply_markup: {
inline_keyboard: [[
{
text: '« Назад к категориям',
callback_data: `prod_category_${locationId}_${categoryId}`
}
]]
}
}
);
await userStates.delete(chatId);
} catch (error) {
logger.error({ err: error }, 'Error updating category');
await bot.sendMessage(chatId, 'Ошибка обновления категории. Пожалуйста, попробуйте снова.');
}
return true;
}
static async handleEditCategory(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const [locationId, categoryId] = callbackQuery.data.replace('edit_category_', '').split('_');
await userStates.set(chatId, { action: `edit_category_${locationId}_${categoryId}` });
await bot.editMessageText(
'Пожалуйста, введите новое название категории:',
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [[
{ text: '❌ Отмена', callback_data: `prod_category_${locationId}_${categoryId}` }
]]
}
}
);
}
}

View File

@@ -1,50 +0,0 @@
import { isAdmin } from '../../../middleware/auth.js';
import LocationService from '../../../services/locationService.js';
import bot from '../../../context/bot.js';
import CategoryService from '../../../services/categoryService.js';
import ProductService from '../../../services/productService.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
export default class CategorySelectionHandler {
static async handleCategorySelection(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const [locationId, categoryId] = callbackQuery.data.replace('prod_category_', '').split('_');
try {
const category = await CategoryService.getCategoryById(categoryId);
const location = await LocationService.getLocationById(locationId);
const products = await ProductService.getProductsByCategoryId(categoryId);
const keyboard = {
inline_keyboard: [
...products.map(prod => [{
text: `${prod.name} - $${prod.price} (${prod.quantity_in_stock} left)`,
callback_data: `view_product_${prod.id}`
}]),
[{ text: ' Add Product', callback_data: `add_product_${locationId}_${categoryId}` }],
[{ text: '« Back', callback_data: `prod_loc_${locationId}` }]
]
};
await bot.editMessageText(
`📦 Category: ${category.name}\nSelect or add product:`,
{
chat_id: chatId,
message_id: messageId,
reply_markup: keyboard
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleCategorySelection');
await editOrSendCallback(callbackQuery, 'Error loading products. Please try again.');
}
}
}

View File

@@ -1,57 +0,0 @@
import { isAdmin } from '../../../middleware/auth.js';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import logger from '../../../utils/logger.js';
export default class CreateHandler {
static async handleAddProduct(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const [locationId, categoryId] = callbackQuery.data.replace('add_product_', '').split('_');
try {
const sampleProducts = [{
name: "Sample Product 1",
price: 100,
description: "Product description",
private_data: "Hidden details about the product",
quantity_in_stock: 10,
photo_url: "https://example.com/photo.jpg",
hidden_photo_url: "https://example.com/hidden.jpg",
hidden_coordinates: "40.7128,-74.0060",
hidden_description: "Secret location details"
}];
const jsonExample = JSON.stringify(sampleProducts, null, 2);
const message = `To add product, send a JSON file with product in the following format:\n\n<pre>${jsonExample}</pre>\n\nProduct must have all the fields shown above.\n\nYou can either:\n1. Send the JSON as text\n2. Upload a .json file`;
await userStates.set(chatId, {
action: 'import_products',
locationId,
categoryId
});
await bot.editMessageText(message, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{
text: '❌ Cancel',
callback_data: `prod_category_${locationId}_${categoryId}`
}
]]
}
});
} catch (error) {
logger.error({ err: error }, 'Error in handleAddProduct');
await bot.sendMessage(chatId, 'Error preparing product import. Please try again.');
}
}
}

View File

@@ -1,98 +0,0 @@
import db from '../../../config/database.js';
import { isAdmin } from '../../../middleware/auth.js';
import bot from '../../../context/bot.js';
import ProductService from '../../../services/productService.js';
import logger from '../../../utils/logger.js';
export default class DeleteHandler {
static async handleProductDelete(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const productId = callbackQuery.data.replace('delete_product_', '');
const chatId = callbackQuery.message.chat.id;
try {
const product = await ProductService.getDetailedProductById(productId);
if (!product) {
throw new Error('Product not found');
}
const keyboard = {
inline_keyboard: [
[
{text: '✅ Confirm Delete', callback_data: `confirm_delete_product_${productId}`},
{
text: '❌ Cancel',
callback_data: `prod_category_${product.location_id}_${product.category_id}`
}
]
]
};
await bot.editMessageText(
`⚠️ Are you sure you want to delete product\n\nThis action cannot be undone!`,
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: keyboard,
parse_mode: 'HTML'
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleDeleteUser');
await bot.sendMessage(chatId, 'Error processing delete request. Please try again.');
}
}
static async handleConfirmDelete(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const productId = callbackQuery.data.replace('confirm_delete_product_', '');
const chatId = callbackQuery.message.chat.id;
try {
const product = await ProductService.getDetailedProductById(productId);
if (!product) {
throw new Error('Product not found');
}
const locationId = product.location_id;
const categoryId = product.category_id;
try {
await db.runAsync('BEGIN TRANSACTION');
await db.runAsync('DELETE FROM products WHERE id=?', [productId.toString()]);
await db.runAsync('COMMIT');
} catch (e) {
await db.runAsync('ROLLBACK');
logger.error({ err: e }, 'Error deleting product');
throw e;
}
const keyboard = {
inline_keyboard: [
[{ text: '« Back', callback_data: `prod_category_${locationId}_${categoryId}` }]
]
};
await bot.editMessageText(
`✅ Product has been successfully deleted.`,
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: keyboard
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleConfirmDelete');
await bot.sendMessage(chatId, 'Error deleting product. Please try again.');
}
}
}

View File

@@ -1,92 +0,0 @@
import { isAdmin } from '../../../middleware/auth.js';
import LocationService from '../../../services/locationService.js';
import CategoryService from '../../../services/categoryService.js';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
export default class DistrictHandler {
static async handleCitySelection(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const payload = callbackQuery.data.replace('prod_city_', '');
const [country, city] = payload.split('|').map(decodeURIComponent);
try {
const locations = await LocationService.getLocationsByCountryAndCity(country, city);
const keyboard = {
inline_keyboard: [
...locations.map(loc => [{
text: loc.district || loc.city,
callback_data: `prod_loc_${loc.id}`
}]),
[{text: '« Back', callback_data: `prod_country_${encodeURIComponent(country)}`}]
]
};
await bot.editMessageText(
`📍 Select district in ${city}:`,
{
chat_id: chatId,
message_id: messageId,
reply_markup: keyboard
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleCitySelection');
await editOrSendCallback(callbackQuery, 'Error loading districts. Please try again.');
}
}
static async handleDistrictSelection(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const locationId = parseInt(callbackQuery.data.replace('prod_loc_', ''), 10);
await userStates.delete(chatId);
try {
const location = await LocationService.getLocationById(locationId);
if (!location) {
throw new Error('Location not found');
}
const categories = await CategoryService.getCategoriesByLocationId(location.id);
const keyboard = {
inline_keyboard: [
...categories.map(cat => [{
text: cat.name,
callback_data: `prod_category_${location.id}_${cat.id}`
}]),
[{text: ' Add Category', callback_data: `add_category_${location.id}`}],
[{text: '« Back', callback_data: `prod_city_${encodeURIComponent(location.country)}|${encodeURIComponent(location.city)}`}]
]
};
await bot.editMessageText(
'📦 Select or add category:',
{
chat_id: chatId,
message_id: messageId,
reply_markup: keyboard
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleDistrictSelection');
await editOrSendCallback(callbackQuery, 'Error loading categories. Please try again.');
}
}
}

View File

@@ -1,96 +0,0 @@
import db from '../../../config/database.js';
import { isAdmin } from '../../../middleware/auth.js';
import fs from 'fs/promises';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import { validateProductName, validateProductPrice } from './productValidator.js';
import logger from '../../../utils/logger.js';
export default class EditImportHandler {
static async handleProductEditImport(msg) {
const chatId = msg.chat.id;
const state = await userStates.get(chatId);
if (!state || state.action !== 'edit_product') {
return false;
}
if (!isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.');
return;
}
try {
let product;
let jsonContent;
if (msg.document) {
if (!msg.document.file_name.endsWith('.json')) {
await bot.sendMessage(chatId, 'Please upload a .json file.');
return true;
}
const file = await bot.getFile(msg.document.file_id);
const fileContent = await bot.downloadFile(file.file_id, '.');
jsonContent = await fs.readFile(fileContent, 'utf8');
await fs.rm(fileContent);
} else if (msg.text) {
jsonContent = msg.text;
} else {
await bot.sendMessage(chatId, 'Please send either a JSON file or JSON text.');
return true;
}
try {
product = JSON.parse(jsonContent);
} catch (e) {
await bot.sendMessage(chatId, 'Invalid JSON format. Please check the format and try again.');
return true;
}
await db.runAsync('BEGIN TRANSACTION');
if (!validateProductName(product.name, chatId)) {
await db.runAsync('ROLLBACK');
return true;
}
if (!validateProductPrice(product.price, chatId)) {
await db.runAsync('ROLLBACK');
return true;
}
await db.runAsync(
`UPDATE products SET
location_id = ?, category_id = ?,
name = ?, price = ?, description = ?, private_data = ?,
quantity_in_stock = ?, photo_url = ?, hidden_photo_url = ?,
hidden_coordinates = ?, hidden_description = ?
WHERE id = ?`,
[
state.locationId, state.categoryId,
product.name, product.price, product.description, product.private_data,
product.quantity_in_stock, product.photo_url, product.hidden_photo_url,
product.hidden_coordinates, product.hidden_description, state.productId
]
);
await db.runAsync('COMMIT');
await bot.sendMessage(chatId, '✅ Successfully edited!', {
reply_markup: {
inline_keyboard: [[
{ text: '« Back to Products', callback_data: `prod_category_${state.locationId}_${state.categoryId}` }
]]
}
});
await userStates.delete(chatId);
} catch (error) {
logger.error({ err: error }, 'Error importing products');
await bot.sendMessage(chatId, 'Error importing products. Please check the data and try again.');
await db.runAsync('ROLLBACK');
}
return true;
}
}

View File

@@ -1,64 +0,0 @@
import { isAdmin } from '../../../middleware/auth.js';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import ProductService from '../../../services/productService.js';
import logger from '../../../utils/logger.js';
export default class EditStartHandler {
static async handleProductEdit(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const productId = callbackQuery.data.replace('edit_product_', '');
try {
const product = await ProductService.getDetailedProductById(productId);
if (!product) {
throw new Error('Product not found');
}
const locationId = product.location_id;
const categoryId = product.category_id;
const sampleProduct = {
name: product.name,
price: product.price,
description: product.description,
private_data: product.private_data,
quantity_in_stock: product.quantity_in_stock,
photo_url: product.photo_url,
hidden_photo_url: product.hidden_photo_url,
hidden_coordinates: product.hidden_coordinates,
hidden_description: product.hidden_description
};
const jsonExample = JSON.stringify(sampleProduct, null, 2);
const message = `To edit product, send a JSON file with product data:\n\n<pre>${jsonExample}</pre>\n\nYou can either:\n1. Send the JSON as text\n2. Upload a .json file`;
await userStates.set(chatId, {
action: 'edit_product',
locationId,
categoryId,
productId
});
await bot.editMessageText(message, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'HTML',
reply_markup: {
inline_keyboard: [[
{ text: '❌ Cancel', callback_data: `prod_category_${locationId}_${categoryId}` }
]]
}
});
} catch (error) {
logger.error({ err: error }, 'Error in handleProductEdit');
await bot.sendMessage(chatId, 'Error loading product details. Please try again.');
}
}
}

View File

@@ -1,76 +0,0 @@
import db from '../../../config/database.js';
import { isAdmin } from '../../../middleware/auth.js';
import fs from 'fs/promises';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import ProductValidator from './productValidator.js';
import logger from '../../../utils/logger.js';
export default class ImportHandler {
static async handleProductImport(msg) {
const chatId = msg.chat.id;
const state = await userStates.get(chatId);
if (!state || state.action !== 'import_products') return false;
if (!isAdmin(msg.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.');
return;
}
try {
const jsonContent = await this._extractJsonContent(msg, chatId);
if (!jsonContent) return true;
let products;
try {
products = JSON.parse(jsonContent);
if (!Array.isArray(products)) throw new Error('Input must be an array of products');
} catch (e) {
await bot.sendMessage(chatId, 'Invalid JSON format. Please check the format and try again.');
return true;
}
await db.runAsync('BEGIN TRANSACTION');
for (const product of products) {
const error = ProductValidator.validateProduct(product);
if (error) {
await bot.sendMessage(chatId, error);
await db.runAsync('ROLLBACK');
return true;
}
await db.runAsync(
`INSERT INTO products (location_id, category_id, name, price, description, private_data, quantity_in_stock, photo_url, hidden_photo_url, hidden_coordinates, hidden_description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[state.locationId, state.categoryId, product.name, product.price, product.description, product.private_data, product.quantity_in_stock, product.photo_url, product.hidden_photo_url, product.hidden_coordinates, product.hidden_description]
);
}
await db.runAsync('COMMIT');
await bot.sendMessage(chatId, `✅ Successfully imported ${products.length} products!`, {
reply_markup: {
inline_keyboard: [[{ text: '« Back to Products', callback_data: `prod_category_${state.locationId}_${state.categoryId}` }]]
}
});
await userStates.delete(chatId);
} catch (error) {
logger.error({ err: error }, 'Error importing products');
await bot.sendMessage(chatId, 'Error importing products. Please check the data and try again.');
await db.runAsync('ROLLBACK');
}
return true;
}
static async _extractJsonContent(msg, chatId) {
if (msg.document) {
if (!msg.document.file_name.endsWith('.json')) {
await bot.sendMessage(chatId, 'Please upload a .json file.');
return null;
}
const file = await bot.getFile(msg.document.file_id);
const fileContent = await bot.downloadFile(file.file_id, '.');
const jsonContent = await fs.readFile(fileContent, 'utf8');
await fs.rm(fileContent);
return jsonContent;
}
if (msg.text) {
return msg.text;
}
await bot.sendMessage(chatId, 'Please send either a JSON file or JSON text.');
return null;
}
}

View File

@@ -1,32 +0,0 @@
import CreateHandler from './createHandler.js';
import ImportHandler from './importHandler.js';
import EditStartHandler from './editStartHandler.js';
import EditImportHandler from './editImportHandler.js';
import DeleteHandler from './deleteHandler.js';
import NavigationHandler from './navigationHandler.js';
import DistrictHandler from './districtHandler.js';
import CategoryAddHandler from './categoryAddHandler.js';
import CategoryEditHandler from './categoryEditHandler.js';
import CategorySelectionHandler from './categorySelectionHandler.js';
import ViewHandler from './viewHandler.js';
import ListHandler from './listHandler.js';
export default {
handleProductManagement: NavigationHandler.handleProductManagement,
handleCountrySelection: NavigationHandler.handleCountrySelection,
handleCitySelection: DistrictHandler.handleCitySelection,
handleDistrictSelection: DistrictHandler.handleDistrictSelection,
handleCategoryInput: CategoryAddHandler.handleCategoryInput,
handleAddCategory: CategoryAddHandler.handleAddCategory,
handleCategoryUpdate: CategoryEditHandler.handleCategoryUpdate,
handleEditCategory: CategoryEditHandler.handleEditCategory,
handleCategorySelection: CategorySelectionHandler.handleCategorySelection,
handleAddProduct: CreateHandler.handleAddProduct,
handleProductImport: ImportHandler.handleProductImport,
handleProductEdit: EditStartHandler.handleProductEdit,
handleProductEditImport: EditImportHandler.handleProductEditImport,
handleViewProduct: ViewHandler.handleViewProduct,
handleProductListPage: ListHandler.handleProductListPage,
handleProductDelete: DeleteHandler.handleProductDelete,
handleConfirmDelete: DeleteHandler.handleConfirmDelete,
};

View File

@@ -1,91 +0,0 @@
import db from '../../../config/database.js';
import { isAdmin } from '../../../middleware/auth.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
export default class ListHandler {
static async viewProductsPage(locationId, categoryId, page) {
try {
const limit = 10;
const offset = (page || 0) * limit;
const previousPage = page > 0 ? page - 1 : 0;
const nextPage = page + 1;
const products = await db.allAsync(
`SELECT id, name, price, quantity_in_stock
FROM products
WHERE location_id = ? AND category_id = ?
ORDER BY name
LIMIT ? OFFSET ?`,
[locationId, categoryId, limit, offset]
);
if (products.length === 0 && page === 0) {
return {
text: 'No products for this location',
markup: {
inline_keyboard: [
[{ text: '📥 Import Products', callback_data: `add_product_${locationId}_${categoryId}` }],
[{ text: '« Back', callback_data: `prod_category_${locationId}_${categoryId}` }]
]
}
};
}
if (products.length === 0 && page > 0) {
return await this.viewProductsPage(locationId, categoryId, previousPage);
}
const category = await db.getAsync('SELECT name FROM categories WHERE id = ?', [categoryId]);
const keyboard = {
inline_keyboard: [
...products.map(prod => [{
text: `${prod.name} - $${prod.price} (${prod.quantity_in_stock} left)`,
callback_data: `view_product_${prod.id}`
}]),
[{ text: '📥 Import Products', callback_data: `add_product_${locationId}_${categoryId}` }]
]
};
keyboard.inline_keyboard.push([
{ text: '«', callback_data: `list_products_${locationId}_${categoryId}_${previousPage}` },
{ text: '»', callback_data: `list_products_${locationId}_${categoryId}_${nextPage}` }
]);
keyboard.inline_keyboard.push([
{ text: '« Back', callback_data: `prod_category_${locationId}_${categoryId}` }
]);
return {
text: `📦 ${category.name}\nSelect product or import new ones:`,
markup: keyboard
};
} catch (error) {
logger.error({ err: error }, 'Error in viewProductsPage');
return { text: 'Error loading products. Please try again.' };
}
}
static async handleProductListPage(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const [locationId, categoryId, page] = callbackQuery.data.replace('list_products_', '').split('_');
try {
const { text, markup } = await this.viewProductsPage(locationId, categoryId, parseInt(page));
await bot.editMessageText(text, {
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: markup,
parse_mode: 'HTML'
});
} catch (e) {
return;
}
}
}

View File

@@ -1,88 +0,0 @@
import { isAdmin } from '../../../middleware/auth.js';
import LocationService from '../../../services/locationService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
export default class NavigationHandler {
static async handleProductManagement(msg) {
const chatId = msg.chat?.id || msg.message?.chat.id;
if (!isAdmin(msg.from?.id || msg.message?.from.id)) {
await bot.sendMessage(chatId, 'Unauthorized access.');
return;
}
try {
const countries = await LocationService.getCountries();
if (countries.length === 0) {
await bot.sendMessage(
chatId,
'No locations available. Please add locations first.',
{
reply_markup: {
inline_keyboard: [[
{text: '📍 Manage Locations', callback_data: 'view_locations'}
]]
}
}
);
return;
}
const keyboard = {
inline_keyboard: countries.map(loc => [{
text: loc.country,
callback_data: `prod_country_${encodeURIComponent(loc.country)}`
}])
};
await bot.sendMessage(
chatId,
'🌍 Select country to manage products:',
{reply_markup: keyboard}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleProductManagement');
await bot.sendMessage(chatId, 'Error loading locations. Please try again.');
}
}
static async handleCountrySelection(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const country = decodeURIComponent(callbackQuery.data.replace('prod_country_', ''));
try {
const cities = await LocationService.getCitiesByCountry(country);
const keyboard = {
inline_keyboard: [
...cities.map(loc => [{
text: loc.city,
callback_data: `prod_city_${encodeURIComponent(country)}|${encodeURIComponent(loc.city)}`
}]),
[{text: '« Back', callback_data: 'manage_products'}]
]
};
await bot.editMessageText(
`🏙 Select city in ${country}:`,
{
chat_id: chatId,
message_id: messageId,
reply_markup: keyboard
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleCountrySelection');
await editOrSendCallback(callbackQuery, 'Error loading cities. Please try again.');
}
}
}

View File

@@ -1,33 +0,0 @@
import Validators from '../../../utils/validators.js';
import logger from '../../../utils/logger.js';
export function validateProductName(name, chatId) {
if (!Validators.isValidString(name, 255)) {
logger.warn({ chatId, name }, 'Invalid product name');
return false;
}
return true;
}
export function validateProductPrice(price, chatId) {
if (!Validators.isValidPrice(price)) {
logger.warn({ chatId, price }, 'Invalid product price');
return false;
}
return true;
}
export default class ProductValidator {
static validateProduct(product) {
if (!Validators.isValidString(product.name, 255)) {
return `Ошибка: недопустимое название товара "${product.name}"`;
}
if (!Validators.isValidPrice(product.price)) {
return `Ошибка: недопустимая цена "${product.price}"`;
}
if (!Number.isFinite(product.quantity_in_stock) || product.quantity_in_stock < 0) {
return `Ошибка: недопустимое количество "${product.quantity_in_stock}"`;
}
return null;
}
}

View File

@@ -1,108 +0,0 @@
import { isAdmin } from '../../../middleware/auth.js';
import bot from '../../../context/bot.js';
import userStates from '../../../context/userStates.js';
import LocationService from '../../../services/locationService.js';
import ProductService from '../../../services/productService.js';
import logger from '../../../utils/logger.js';
import fs from 'fs';
import path from 'path';
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
const FALLBACK_PHOTO = path.join(process.cwd(), 'corrupt-photo.jpg');
function resolvePhotoSource(photoUrl) {
if (!photoUrl) return null;
if (photoUrl.startsWith('http')) return photoUrl;
const filePath = path.join(UPLOADS_DIR, photoUrl.replace(/^\/uploads\//, ''));
if (fs.existsSync(filePath)) return filePath;
return null;
}
async function sendProductPhoto(chatId, photoUrl, caption) {
const source = resolvePhotoSource(photoUrl);
if (!source) return null;
try {
return await bot.sendPhoto(chatId, source, { caption });
} catch (e) {
if (fs.existsSync(FALLBACK_PHOTO)) {
return await bot.sendPhoto(chatId, FALLBACK_PHOTO, { caption });
}
return null;
}
}
export default class ViewHandler {
static async handleViewProduct(callbackQuery) {
if (!isAdmin(callbackQuery.from.id)) {
return;
}
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const productId = callbackQuery.data.replace('view_product_', '');
try {
const product = await ProductService.getDetailedProductById(productId);
if (!product) {
throw new Error('Product not found');
}
const location = await LocationService.getLocationById(product.location_id);
if (!location) {
throw new Error('Location not found');
}
const message = `
📦 Product Details:
Name: ${product.name}
Price: $${product.price}
Description: ${product.description}
Stock: ${product.quantity_in_stock}
Location: ${location.country}, ${location.city}, ${location.district}
Category: ${product.category_name}
🔒 Private Information:
${product.private_data}
Hidden Location: ${product.hidden_description}
Coordinates: ${product.hidden_coordinates}
`;
const keyboard = {
inline_keyboard: [
[
{text: '✏️ Edit', callback_data: `edit_product_${productId}`},
{text: '❌ Delete', callback_data: `delete_product_${productId}`}
],
[{
text: '« Back',
callback_data: `prod_category_${product.location_id}_${product.category_id}`
}]
]
};
let photoMessage;
let hiddenPhotoMessage;
if (product.photo_url) {
photoMessage = await sendProductPhoto(chatId, product.photo_url, 'Public photo');
}
if (product.hidden_photo_url) {
hiddenPhotoMessage = await sendProductPhoto(chatId, product.hidden_photo_url, 'Hidden photo');
}
await userStates.set(chatId, {
msgToDelete: [photoMessage?.message_id, hiddenPhotoMessage?.message_id].filter(Boolean)
})
await bot.deleteMessage(chatId, messageId);
await bot.sendMessage(chatId, message, {reply_markup: keyboard});
} catch (error) {
logger.error({ err: error }, 'Error in handleViewProduct');
await bot.sendMessage(chatId, 'Error loading product details. Please try again.');
}
}
}

View File

@@ -1,64 +0,0 @@
import config from '../../config/config.js';
import db from '../../config/database.js';
import bot from "../../context/bot.js";
import UserService from "../../services/userService.js";
import userStates from "../../context/userStates.js";
import logger from '../../utils/logger.js';
import { editOrSendCallback } from '../../utils/messageUtils.js';
import { tForUser } from '../../i18n/index.js';
export default class UserDeletionHandler {
static async handleDeleteAccount(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
const text = `${t('deletion.confirm_title')}\n\n${t('deletion.confirm_body')}`;
const keyboard = {
inline_keyboard: [
[
{text: t('deletion.confirm_button'), callback_data: `confirm_delete_account`},
{text: t('deletion.cancel_button'), callback_data: `back_to_profile`}
]
]
};
await bot.editMessageText(
text,
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: keyboard,
parse_mode: 'HTML'
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleDeleteUser');
await editOrSendCallback(callbackQuery, t('deletion.error_processing'));
}
}
static async handleConfirmDelete(callbackQuery) {
const telegramId = callbackQuery.from.id;
const chatId = callbackQuery.message.chat.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
await UserService.updateUserStatus(telegramId, 1);
await bot.editMessageText(
t('deletion.deleted'),
{ chat_id: chatId, message_id: callbackQuery.message.message_id, }
);
} catch (error) {
logger.error({ err: error }, 'Error in handleConfirmDelete');
await editOrSendCallback(callbackQuery, t('deletion.error_deleting'));
}
}
}

View File

@@ -1,23 +1,15 @@
// userHandler.js
import config from "../../config/config.js"; import config from "../../config/config.js";
import bot from "../../context/bot.js"; import bot from "../../context/bot.js";
import UserService from "../../services/userService.js"; import UserService from "../../services/userService.js";
import WalletService from "../../services/walletService.js";
import logger from "../../utils/logger.js";
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
export default class UserHandler { export default class UserHandler {
static async canUseBot(msg) { static async canUseBot(msg) {
const telegramId = msg.from.id; const telegramId = msg.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
msg.__user = user; // Cache user for downstream handlers
const lang = user?.language || 'en';
const t = tForUser(lang);
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
[{text: t('bot.contact_support'), url: config.SUPPORT_LINK}] [{text: "Contact support", url: config.SUPPORT_LINK}]
] ]
}; };
@@ -25,10 +17,10 @@ export default class UserHandler {
case 0: case 0:
return true; return true;
case 1: case 1:
await bot.sendMessage(telegramId, t('bot.account_deleted'), {reply_markup: keyboard}); await bot.sendMessage(telegramId, '⚠Your account has been deleted by administrator', {reply_markup: keyboard});
return false; return false;
case 2: case 2:
await bot.sendMessage(telegramId, t('bot.account_blocked'), {reply_markup: keyboard}); await bot.sendMessage(telegramId, '⚠Your account has been blocked by administrator', {reply_markup: keyboard});
return false; return false;
default: default:
return true; return true;
@@ -38,49 +30,40 @@ export default class UserHandler {
static async showProfile(msg) { static async showProfile(msg) {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const telegramId = msg.from.id; const telegramId = msg.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try { try {
await UserService.recalculateUserBalanceByTelegramId(telegramId); await UserService.recalculateUserBalanceByTelegramId(telegramId);
const userStats = await UserService.getDetailedUserByTelegramId(telegramId); const userStats = await UserService.getDetailedUserByTelegramId(telegramId);
if (!userStats) { if (!userStats) {
await bot.sendMessage(chatId, t('profile.not_found')); await bot.sendMessage(chatId, 'Profile not found. Please use /start to create one.');
return; return;
} }
const activeWalletsBalance = await WalletService.getActiveWalletsBalance(userStats.id);
const archivedWalletsBalance = await WalletService.getArchivedWalletsBalance(userStats.id);
const availableBalance = userStats.bonus_balance + (userStats.total_balance || 0);
const locationText = userStats.country && userStats.city && userStats.district const locationText = userStats.country && userStats.city && userStats.district
? `${userStats.country}, ${userStats.city}, ${userStats.district}` ? `${userStats.country}, ${userStats.city}, ${userStats.district}`
: t('profile.location_not_set'); : 'Not set';
const text = ` const text = `
${t('profile.title')} 👤 *Your Profile*
${t('profile.telegram_id')}: \`${telegramId}\` 📱 Telegram ID: \`${telegramId}\`
${t('profile.location')}: ${locationText} 📍 Location: ${locationText}
${t('profile.stats')} 📊 Statistics:
${t('profile.total_purchases')}: ${userStats.purchase_count || 0} Total Purchases: ${userStats.purchase_count || 0}
${t('profile.total_spent')}: $${userStats.total_spent || 0} Total Spent: $${userStats.total_spent || 0}
${t('profile.active_wallets')}: ${userStats.crypto_wallet_count || 0} ($${activeWalletsBalance.toFixed(2)}) Active Wallets: ${userStats.crypto_wallet_count || 0}
${t('profile.archived_wallets')}: ${userStats.archived_wallet_count || 0} ($${archivedWalletsBalance.toFixed(2)}) Bonus Balance: $${userStats.bonus_balance || 0}
${t('profile.bonus_balance')}: $${userStats.bonus_balance || 0} └ Total Balance: $${(userStats.total_balance || 0) + (userStats.bonus_balance || 0)}
${t('profile.available_balance')}: $${availableBalance.toFixed(2)}
${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateString()} 📅 Member since: ${new Date(userStats.created_at).toLocaleDateString()}
`; `;
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
[{text: t('profile.set_location'), callback_data: 'set_location'}], [{text: '📍 Set Location', callback_data: 'set_location'}],
[{text: t('profile.change_language'), callback_data: 'change_language'}], [{text: '❌ Delete Account', callback_data: 'delete_account'}]
[{text: t('profile.delete_account'), callback_data: 'delete_account'}]
] ]
}; };
@@ -89,8 +72,8 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
reply_markup: keyboard reply_markup: keyboard
}); });
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in showProfile'); console.error('Error in showProfile:', error);
await bot.sendMessage(chatId, t('profile.error_loading')); await bot.sendMessage(chatId, 'Error loading profile. Please try again.');
} }
} }
@@ -100,91 +83,33 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
const username = msg.chat.username; const username = msg.chat.username;
try { try {
// Create user profile
await UserService.createUser({ await UserService.createUser({
telegram_id: telegramId, telegram_id: telegramId,
username: username username: username
}); });
const keyboard = {
inline_keyboard: AVAILABLE_LANGUAGES.map(code => [{
text: LANGUAGE_NAMES[code],
callback_data: `set_language_${code}`
}])
};
await bot.sendMessage(chatId, tForUser('en')('bot.language_select'), { reply_markup: keyboard });
} catch (error) {
logger.error({ err: error }, 'Error in handleStart');
const fallbackT = tForUser('en');
await bot.sendMessage(chatId, fallbackT('bot.error_generic'));
}
}
static async handleSetLanguage(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const lang = callbackQuery.data.replace('set_language_', '');
if (!AVAILABLE_LANGUAGES.includes(lang)) {
await bot.answerCallbackQuery(callbackQuery.id);
return;
}
try {
await UserService.setUserLanguage(telegramId, lang);
const t = tForUser(lang);
await bot.answerCallbackQuery(callbackQuery.id);
const keyboard = { const keyboard = {
reply_markup: { reply_markup: {
keyboard: [ keyboard: [
[t('keyboard.products'), t('keyboard.profile')], ['📦 Products', '👤 Profile'],
[t('keyboard.purchases'), t('keyboard.wallets')] ['🛍 Purchases', '💰 Wallets']
], ],
resize_keyboard: true resize_keyboard: true
} }
}; };
await bot.deleteMessage(chatId, callbackQuery.message.message_id); await bot.sendMessage(
await bot.sendMessage(chatId, t('bot.language_changed', { language: LANGUAGE_NAMES[lang] }), keyboard); chatId,
'Welcome to the shop! Choose an option:',
keyboard
);
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSetLanguage'); console.error('Error in handleStart:', error);
await bot.answerCallbackQuery(callbackQuery.id); await bot.sendMessage(chatId, 'Error creating user profile. Please try again.');
} }
} }
static async handleChangeLanguage(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const user = callbackQuery.message.__user || await UserService.getUserByTelegramId(callbackQuery.from.id);
const currentLang = user?.language || 'en';
const keyboard = {
inline_keyboard: AVAILABLE_LANGUAGES.map(code => [{
text: LANGUAGE_NAMES[code],
callback_data: `set_language_${code}`
}])
};
try {
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
} catch {}
await bot.sendMessage(chatId, tForUser(currentLang)('bot.language_select'), { reply_markup: keyboard });
}
static async handleLanguageCommand(msg) {
const chatId = msg.chat.id;
const keyboard = {
inline_keyboard: AVAILABLE_LANGUAGES.map(code => [{
text: LANGUAGE_NAMES[code],
callback_data: `set_language_${code}`
}])
};
await bot.sendMessage(chatId, tForUser('en')('bot.language_select'), { reply_markup: keyboard });
}
static async handleBackToProfile(callbackQuery) { static async handleBackToProfile(callbackQuery) {
await this.showProfile({ await this.showProfile({
chat: {id: callbackQuery.message.chat.id}, chat: {id: callbackQuery.message.chat.id},

View File

@@ -2,31 +2,24 @@ import db from '../../config/database.js';
import LocationService from "../../services/locationService.js"; import LocationService from "../../services/locationService.js";
import bot from "../../context/bot.js"; import bot from "../../context/bot.js";
import UserService from "../../services/userService.js"; import UserService from "../../services/userService.js";
import logger from '../../utils/logger.js';
import { editOrSendCallback } from '../../utils/messageUtils.js';
import { tForUser } from '../../i18n/index.js';
export default class UserLocationHandler { export default class UserLocationHandler {
static async handleSetLocation(callbackQuery) { static async handleSetLocation(callbackQuery) {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try { try {
const countries = await LocationService.getCountries(); const countries = await LocationService.getCountries();
if (countries.length === 0) { if (countries.length === 0) {
await bot.editMessageText( await bot.editMessageText(
t('location.no_locations'), 'No locations available yet.',
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
reply_markup: { reply_markup: {
inline_keyboard: [[ inline_keyboard: [[
{text: t('location.back_to_profile'), callback_data: 'back_to_profile'} {text: '« Back to Profile', callback_data: 'back_to_profile'}
]] ]]
} }
} }
@@ -40,12 +33,12 @@ export default class UserLocationHandler {
text: loc.country, text: loc.country,
callback_data: `set_country_${loc.country}` callback_data: `set_country_${loc.country}`
}]), }]),
[{text: t('location.back_to_profile'), callback_data: 'back_to_profile'}] [{text: '« Back to Profile', callback_data: 'back_to_profile'}]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
t('location.select_country'), '🌍 Select your country:',
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
@@ -53,8 +46,8 @@ export default class UserLocationHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSetLocation'); console.error('Error in handleSetLocation:', error);
await editOrSendCallback(callbackQuery, t('location.error_loading_countries')); await bot.sendMessage(chatId, 'Error loading countries. Please try again.');
} }
} }
@@ -62,10 +55,6 @@ export default class UserLocationHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const country = callbackQuery.data.replace('set_country_', ''); const country = callbackQuery.data.replace('set_country_', '');
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try { try {
const cities = await LocationService.getCitiesByCountry(country); const cities = await LocationService.getCitiesByCountry(country);
@@ -76,12 +65,12 @@ export default class UserLocationHandler {
text: loc.city, text: loc.city,
callback_data: `set_city_${country}_${loc.city}` callback_data: `set_city_${country}_${loc.city}`
}]), }]),
[{text: t('location.back_to_countries'), callback_data: 'set_location'}] [{text: '« Back to Countries', callback_data: 'set_location'}]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
t('location.select_city', { country }), `🏙 Select city in ${country}:`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
@@ -89,8 +78,8 @@ export default class UserLocationHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSetCountry'); console.error('Error in handleSetCountry:', error);
await editOrSendCallback(callbackQuery, t('location.error_loading_cities')); await bot.sendMessage(chatId, 'Error loading cities. Please try again.');
} }
} }
@@ -98,10 +87,6 @@ export default class UserLocationHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const [country, city] = callbackQuery.data.replace('set_city_', '').split('_'); const [country, city] = callbackQuery.data.replace('set_city_', '').split('_');
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try { try {
const districts = await LocationService.getDistrictsByCountryAndCity(country, city); const districts = await LocationService.getDistrictsByCountryAndCity(country, city);
@@ -112,12 +97,12 @@ export default class UserLocationHandler {
text: loc.district, text: loc.district,
callback_data: `set_district_${country}_${city}_${loc.district}` callback_data: `set_district_${country}_${city}_${loc.district}`
}]), }]),
[{text: t('location.back_to_countries'), callback_data: `set_country_${country}`}] [{text: '« Back to Cities', callback_data: `set_country_${country}`}]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
t('location.select_district', { city }), `📍 Select district in ${city}:`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
@@ -125,8 +110,8 @@ export default class UserLocationHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSetCity'); console.error('Error in handleSetCity:', error);
await editOrSendCallback(callbackQuery, t('location.error_loading_districts')); await bot.sendMessage(chatId, 'Error loading districts. Please try again.');
} }
} }
@@ -135,9 +120,6 @@ export default class UserLocationHandler {
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const [country, city, district] = callbackQuery.data.replace('set_district_', '').split('_'); const [country, city, district] = callbackQuery.data.replace('set_district_', '').split('_');
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try { try {
await db.runAsync('BEGIN TRANSACTION'); await db.runAsync('BEGIN TRANSACTION');
@@ -145,21 +127,21 @@ export default class UserLocationHandler {
await db.runAsync('COMMIT'); await db.runAsync('COMMIT');
await bot.editMessageText( await bot.editMessageText(
`${t('location.location_updated')}\n\n${t('location.country')}: ${country}\n${t('location.city')}: ${city}\n${t('location.district')}: ${district}`, `✅ Location updated successfully!\n\nCountry: ${country}\nCity: ${city}\nDistrict: ${district}`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
reply_markup: { reply_markup: {
inline_keyboard: [[ inline_keyboard: [[
{text: t('location.back_to_profile'), callback_data: 'back_to_profile'} {text: '« Back to Profile', callback_data: 'back_to_profile'}
]] ]]
} }
} }
); );
} catch (error) { } catch (error) {
await db.runAsync('ROLLBACK'); await db.runAsync('ROLLBACK');
logger.error({ err: error }, 'Error in handleSetDistrict'); console.error('Error in handleSetDistrict:', error);
await editOrSendCallback(callbackQuery, t('location.error_updating')); await bot.sendMessage(chatId, 'Error updating location. Please try again.');
} }
} }
} }

View File

@@ -6,38 +6,7 @@ import userStates from "../../context/userStates.js";
import ProductService from "../../services/productService.js"; import ProductService from "../../services/productService.js";
import CategoryService from "../../services/categoryService.js"; import CategoryService from "../../services/categoryService.js";
import UserService from "../../services/userService.js"; import UserService from "../../services/userService.js";
import PurchaseService from '../../services/purchaseService.js'; import PurchaseService from "../../services/purchaseService.js";
import Validators from '../../utils/validators.js';
import { editOrSendCallback, deleteAndSend } from '../../utils/messageUtils.js';
import { tForUser } from '../../i18n/index.js';
import fs from 'fs';
import path from 'path';
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
const FALLBACK_PHOTO = path.join(process.cwd(), 'corrupt-photo.jpg');
function resolvePhotoSource(photoUrl) {
if (!photoUrl) return null;
if (photoUrl.startsWith('http')) return photoUrl;
const filePath = path.join(UPLOADS_DIR, photoUrl.replace(/^\/uploads\//, ''));
if (fs.existsSync(filePath)) return filePath;
return null;
}
async function sendProductPhoto(chatId, photoUrl, caption) {
const source = resolvePhotoSource(photoUrl);
if (!source) return null;
try {
return await bot.sendPhoto(chatId, source, { caption });
} catch (e) {
logger.warn({ err: e, photoUrl }, 'Failed to send product photo');
if (fs.existsSync(FALLBACK_PHOTO)) {
return await bot.sendPhoto(chatId, FALLBACK_PHOTO, { caption });
}
return null;
}
}
import logger from '../../utils/logger.js';
export default class UserProductHandler { export default class UserProductHandler {
static async showProducts(msg) { static async showProducts(msg) {
@@ -45,14 +14,10 @@ export default class UserProductHandler {
const messageId = msg?.message_id; const messageId = msg?.message_id;
try { try {
const user = await UserService.getUserByTelegramId(msg.from.id);
const lang = user?.language || 'en';
const t = tForUser(lang);
const countries = await LocationService.getCountries() const countries = await LocationService.getCountries()
if (countries.length === 0) { if (countries.length === 0) {
const message = t('products.no_products'); const message = 'No products available at the moment.';
if (messageId) { if (messageId) {
await bot.editMessageText(message, { await bot.editMessageText(message, {
chat_id: chatId, chat_id: chatId,
@@ -67,11 +32,11 @@ export default class UserProductHandler {
const keyboard = { const keyboard = {
inline_keyboard: countries.map(loc => [{ inline_keyboard: countries.map(loc => [{
text: loc.country, text: loc.country,
callback_data: `shop_country_${encodeURIComponent(loc.country)}` callback_data: `shop_country_${loc.country}`
}]) }])
}; };
const message = t('products.select_country'); const message = '🌍 Select your country:';
try { try {
if (messageId) { if (messageId) {
@@ -85,39 +50,31 @@ export default class UserProductHandler {
await bot.sendMessage(chatId, message, {reply_markup: keyboard}); await bot.sendMessage(chatId, message, {reply_markup: keyboard});
} }
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in showProducts'); console.error('Error in showProducts:', error);
const user = await UserService.getUserByTelegramId(msg.from.id).catch(() => null); await bot.sendMessage(chatId, 'Error loading products. Please try again.');
const lang = user?.language || 'en';
const t = tForUser(lang);
await bot.sendMessage(chatId, t('products.error_loading'));
} }
} }
static async handleCountrySelection(callbackQuery) { static async handleCountrySelection(callbackQuery) {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const country = decodeURIComponent(callbackQuery.data.replace('shop_country_', '')); const country = callbackQuery.data.replace('shop_country_', '');
try { try {
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const cities = await LocationService.getCitiesByCountry(country); const cities = await LocationService.getCitiesByCountry(country);
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
...cities.map(loc => [{ ...cities.map(loc => [{
text: loc.city, text: loc.city,
callback_data: `shop_city_${encodeURIComponent(country)}|${encodeURIComponent(loc.city)}` callback_data: `shop_city_${country}_${loc.city}`
}]), }]),
[{text: t('products.back_to_countries'), callback_data: 'shop_start'}] [{text: '« Back to Countries', callback_data: 'shop_start'}]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
t('products.select_city', { country }), `🏙 Select city in ${country}:`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
@@ -125,41 +82,31 @@ export default class UserProductHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleCountrySelection'); console.error('Error in handleCountrySelection:', error);
const telegramId = callbackQuery.from.id; await bot.sendMessage(chatId, 'Error loading cities. Please try again.');
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null);
const lang = user?.language || 'en';
const t = tForUser(lang);
await editOrSendCallback(callbackQuery, t('products.error_loading_cities'));
} }
} }
static async handleCitySelection(callbackQuery) { static async handleCitySelection(callbackQuery) {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const payload = callbackQuery.data.replace('shop_city_', ''); const [country, city] = callbackQuery.data.replace('shop_city_', '').split('_');
const [country, city] = payload.split('|').map(decodeURIComponent);
try { try {
const telegramId = callbackQuery.from.id; const districts = await LocationService.getDistrictsByCountryAndCity(country, city)
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const locations = await LocationService.getLocationsByCountryAndCity(country, city);
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
...locations.map(loc => [{ ...districts.map(loc => [{
text: loc.district || loc.city, text: loc.district,
callback_data: `shop_loc_${loc.id}` callback_data: `shop_district_${country}_${city}_${loc.district}`
}]), }]),
[{text: t('products.back_to_cities'), callback_data: `shop_country_${encodeURIComponent(country)}`}] [{text: '« Back to Cities', callback_data: `shop_country_${country}`}]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
t('products.select_district', { city }), `📍 Select district in ${city}:`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
@@ -167,37 +114,34 @@ export default class UserProductHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleCitySelection'); console.error('Error in handleCitySelection:', error);
const telegramId = callbackQuery.from.id; await bot.sendMessage(chatId, 'Error loading districts. Please try again.');
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null);
const lang = user?.language || 'en';
const t = tForUser(lang);
await editOrSendCallback(callbackQuery, t('products.error_loading_districts'));
} }
} }
static async handleDistrictSelection(callbackQuery) { static async handleDistrictSelection(callbackQuery) {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const locationId = parseInt(callbackQuery.data.replace('shop_loc_', ''), 10); const [country, city, district] = callbackQuery.data.replace('shop_district_', '').split('_');
try { try {
const telegramId = callbackQuery.from.id; const location = await LocationService.getLocation(country, city, district);
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const location = await LocationService.getLocationById(locationId);
if (!location) { if (!location) {
throw new Error('Location not found');
}
const categories = await CategoryService.getCategoriesByLocationId(location.id);
if (categories.length === 0) {
await bot.editMessageText( await bot.editMessageText(
t('products.not_found'), 'No products available in this location yet.',
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
reply_markup: { reply_markup: {
inline_keyboard: [[ inline_keyboard: [[
{ text: t('products.back'), callback_data: `shop_city_${encodeURIComponent(location?.country || '')}|${encodeURIComponent(location?.city || '')}` } {text: '« Back to Districts', callback_data: `shop_city_${country}_${city}`}
]] ]]
} }
} }
@@ -205,25 +149,18 @@ export default class UserProductHandler {
return; return;
} }
await userStates.set(chatId, {
location: `${location.country}_${location.city}_${location.district}`,
locationId: location.id
});
const categories = await CategoryService.getCategoriesByLocationId(location.id);
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
...categories.map(cat => [{ ...categories.map(cat => [{
text: cat.name, text: cat.name,
callback_data: `shop_category_${location.id}_${cat.id}` callback_data: `shop_category_${location.id}_${cat.id}`
}]), }]),
[{ text: t('products.back'), callback_data: `shop_city_${encodeURIComponent(location.country)}|${encodeURIComponent(location.city)}` }] [{text: '« Back to Districts', callback_data: `shop_city_${country}_${city}`}]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
t('products.select_category'), '📦 Select category:',
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
@@ -231,12 +168,8 @@ export default class UserProductHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleDistrictSelection'); console.error('Error in handleDistrictSelection:', error);
const telegramId = callbackQuery.from.id; await bot.sendMessage(chatId, 'Error loading categories. Please try again.');
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null);
const lang = user?.language || 'en';
const t = tForUser(lang);
await bot.sendMessage(chatId, t('products.error_loading_categories'));
} }
} }
@@ -246,37 +179,21 @@ export default class UserProductHandler {
const [locationId, categoryId] = callbackQuery.data.replace('shop_category_', '').split('_'); const [locationId, categoryId] = callbackQuery.data.replace('shop_category_', '').split('_');
try { try {
const telegramId = callbackQuery.from.id; const subcategories = await CategoryService.getSubcategoriesByCategoryId(categoryId);
const user = await UserService.getUserByTelegramId(telegramId); const location = await LocationService.getLocationById(locationId);
const lang = user?.language || 'en';
const t = tForUser(lang);
// Удаляем текущее сообщение if (subcategories.length === 0) {
await bot.deleteMessage(chatId, messageId); await bot.editMessageText(
'No products available in this category yet.',
// Получаем состояние пользователя
const state = await userStates.get(chatId);
// Удаляем сообщение с фотографией, если оно существует
if (state && state.photoMessageId) {
try {
await bot.deleteMessage(chatId, state.photoMessageId);
} catch (error) {
logger.error({ err: error }, 'Error deleting photo message');
}
}
// Получаем товары для выбранной категории
const products = await ProductService.getProductsByCategoryId(categoryId);
if (products.length === 0) {
await bot.sendMessage(
chatId,
t('products.no_products_category'),
{ {
chat_id: chatId,
message_id: messageId,
reply_markup: { reply_markup: {
inline_keyboard: [[ inline_keyboard: [[
{ text: t('products.back'), callback_data: `shop_district_${state.location}` } {
text: '« Back to Categories',
callback_data: `shop_district_${location.country}_${location.city}_${location.district}`
}
]] ]]
} }
} }
@@ -284,44 +201,30 @@ export default class UserProductHandler {
return; return;
} }
// Создаем клавиатуру с товарами
const keyboard = { const keyboard = {
inline_keyboard: products.map(product => [ inline_keyboard: [
{ ...subcategories.map(sub => [{
text: `${product.name} - $${product.price}`, text: sub.name,
callback_data: `shop_product_${product.id}` callback_data: `shop_subcategory_${locationId}_${categoryId}_${sub.id}`
} }]),
]) [{
text: '« Back to Categories',
callback_data: `shop_district_${location.country}_${location.city}_${location.district}`
}]
]
}; };
// Добавляем кнопку "Назад" await bot.editMessageText(
keyboard.inline_keyboard.push([ '📦 Select subcategory:',
{ text: t('products.back'), callback_data: `shop_district_${state.location}` }
]);
// Отправляем сообщение с товарами
await bot.sendMessage(
chatId,
t('products.select_product'),
{ {
chat_id: chatId,
message_id: messageId,
reply_markup: keyboard reply_markup: keyboard
} }
); );
// Сохраняем состояние пользователя
await userStates.set(chatId, {
...state,
action: 'viewing_category',
categoryId,
location: state?.location // Сохраняем информацию о локации
});
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleCategorySelection'); console.error('Error in handleCategorySelection:', error);
const telegramId = callbackQuery.from.id; await bot.sendMessage(chatId, 'Error loading subcategories. Please try again.');
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null);
const lang = user?.language || 'en';
const t = tForUser(lang);
await bot.sendMessage(chatId, t('products.error_loading'));
} }
} }
@@ -331,17 +234,12 @@ export default class UserProductHandler {
const [locationId, categoryId, subcategoryId, photoMessageId] = callbackQuery.data.replace('shop_subcategory_', '').split('_'); const [locationId, categoryId, subcategoryId, photoMessageId] = callbackQuery.data.replace('shop_subcategory_', '').split('_');
try { try {
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
// Delete the photo message if it exists // Delete the photo message if it exists
if (photoMessageId) { if (photoMessageId) {
try { try {
await bot.deleteMessage(chatId, photoMessageId); await bot.deleteMessage(chatId, photoMessageId);
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error deleting photo message'); console.error('Error deleting photo message:', error);
} }
} }
@@ -350,14 +248,14 @@ export default class UserProductHandler {
if (products.length === 0) { if (products.length === 0) {
await bot.editMessageText( await bot.editMessageText(
t('products.no_products_subcategory'), 'No products available in this subcategory.',
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
reply_markup: { reply_markup: {
inline_keyboard: [[ inline_keyboard: [[
{ {
text: t('products.back_to_subcategories'), text: '« Back to Subcategories',
callback_data: `shop_category_${locationId}_${categoryId}` callback_data: `shop_category_${locationId}_${categoryId}`
} }
]] ]]
@@ -373,12 +271,12 @@ export default class UserProductHandler {
text: `${prod.name} - $${prod.price}`, text: `${prod.name} - $${prod.price}`,
callback_data: `shop_product_${prod.id}` callback_data: `shop_product_${prod.id}`
}]), }]),
[{text: t('products.back_to_subcategories'), callback_data: `shop_category_${locationId}_${categoryId}`}] [{text: '« Back to Subcategories', callback_data: `shop_category_${locationId}_${categoryId}`}]
] ]
}; };
await bot.editMessageText( await bot.editMessageText(
t('products.products_in', { name: subcategory.name }), `📦 Products in ${subcategory.name}:`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: messageId, message_id: messageId,
@@ -386,12 +284,8 @@ export default class UserProductHandler {
} }
); );
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleSubcategorySelection'); console.error('Error in handleSubcategorySelection:', error);
const telegramId = callbackQuery.from.id; await bot.sendMessage(chatId, 'Error loading products. Please try again.');
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null);
const lang = user?.language || 'en';
const t = tForUser(lang);
await bot.sendMessage(chatId, t('products.error_loading'));
} }
} }
@@ -401,90 +295,77 @@ export default class UserProductHandler {
const productId = callbackQuery.data.replace('shop_product_', ''); const productId = callbackQuery.data.replace('shop_product_', '');
try { try {
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const product = await ProductService.getDetailedProductById(productId); const product = await ProductService.getDetailedProductById(productId);
if (!product) { if (!product) {
throw new Error('Product not found'); throw new Error('Product not found');
} }
// Удаляем предыдущее сообщение // Delete the previous message
await bot.deleteMessage(chatId, messageId); await bot.deleteMessage(chatId, messageId);
// Получаем состояние пользователя
const state = await userStates.get(chatId);
// Удаляем сообщение с фотографией, если оно существует
if (state?.photoMessageId) {
try {
await bot.deleteMessage(chatId, state.photoMessageId);
} catch (error) {
logger.error({ err: error }, 'Error deleting photo message');
}
}
const message = ` const message = `
📦 ${product.name} 📦 ${product.name}
${t('products.product_price')}: $${product.price} 💰 Price: $${product.price}
${t('products.product_description')}: ${product.description} 📝 Description: ${product.description}
${t('products.product_available')}: ${product.quantity_in_stock} pcs 📦 Available: ${product.quantity_in_stock} pcs
${t('products.product_category')}: ${product.category_name} Category: ${product.category_name}
Subcategory: ${product.subcategory_name}
`; `;
// Отправляем фото, если оно существует let photoMessageId = null;
// First send the photo if it exists
let photoMessage; let photoMessage;
if (product.photo_url) { if (product.photo_url) {
photoMessage = await sendProductPhoto(chatId, product.photo_url, 'Public photo'); try {
photoMessage = await bot.sendPhoto(chatId, product.photo_url, {caption: 'Public photo'});
} catch (e) {
photoMessage = await bot.sendPhoto(chatId, "./corrupt-photo.jpg", {caption: 'Public photo'})
}
} }
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
[{ text: t('products.buy_now'), callback_data: `buy_product_${productId}` }], [{text: '🛒 Buy Now', callback_data: `buy_product_${productId}`}],
[ [
{ {
text: '', text: '',
callback_data: `decrease_quantity_${productId}`, callback_data: `decrease_quantity_${productId}`,
callback_game: {} // Изначально отключено, так как количество начинается с 1 callback_game: {} // Initially disabled as quantity starts at 1
}, },
{text: '1', callback_data: 'current_quantity'}, {text: '1', callback_data: 'current_quantity'},
{ {
text: '', text: '',
callback_data: `increase_quantity_${productId}`, callback_data: `increase_quantity_${productId}`,
callback_game: product.quantity_in_stock <= 1 ? {} : null // Отключено, если остаток 1 или меньше callback_game: product.quantity_in_stock <= 1 ? {} : null // Disabled if stock is 1 or less
} }
], ],
[{ text: `${t('products.back')} ${product.category_name}`, callback_data: `shop_category_${product.location_id}_${product.category_id}` }] // Возврат к категории [{
text: `« Back to ${product.subcategory_name}`,
callback_data: `shop_subcategory_${product.location_id}_${product.category_id}_${product.subcategory_id}_${photoMessageId}`
}]
] ]
}; };
// Отправляем сообщение с кнопками // Then send the message with controls
const productMessage = await bot.sendMessage(chatId, message, { await bot.sendMessage(chatId, message, {
reply_markup: keyboard, reply_markup: keyboard,
parse_mode: 'HTML' parse_mode: 'HTML'
}); });
// Сохраняем ID сообщения с фотографией и ID сообщения с товаром в состояние пользователя // Store the current quantity and photo message ID in user state
await userStates.set(chatId, { userStates.set(chatId, {
action: 'buying_product', action: 'buying_product',
productId, productId,
quantity: 1, quantity: 1,
photoMessageId: photoMessage ? photoMessage.message_id : null, photoMessageId
productMessageId: productMessage.message_id,
location: state?.location // Сохраняем информацию о локации
}); });
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleProductSelection'); console.error('Error in handleProductSelection:', error);
const telegramId = callbackQuery.from.id; await bot.sendMessage(chatId, 'Error loading product details. Please try again.');
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null);
const lang = user?.language || 'en';
const t = tForUser(lang);
await bot.sendMessage(chatId, t('products.error_loading_product'));
} }
} }
@@ -492,7 +373,7 @@ export default class UserProductHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const productId = callbackQuery.data.replace('increase_quantity_', ''); const productId = callbackQuery.data.replace('increase_quantity_', '');
const state = await userStates.get(chatId); const state = userStates.get(chatId);
try { try {
const product = await ProductService.getProductById(productId); const product = await ProductService.getProductById(productId);
@@ -512,7 +393,7 @@ export default class UserProductHandler {
const newQuantity = Math.min(currentQuantity + 1, product.quantity_in_stock); const newQuantity = Math.min(currentQuantity + 1, product.quantity_in_stock);
// Update state // Update state
await userStates.set(chatId, { userStates.set(chatId, {
...state, ...state,
quantity: newQuantity quantity: newQuantity
}); });
@@ -543,7 +424,7 @@ export default class UserProductHandler {
await bot.answerCallbackQuery(callbackQuery.id); await bot.answerCallbackQuery(callbackQuery.id);
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleIncreaseQuantity'); console.error('Error in handleIncreaseQuantity:', error);
await bot.answerCallbackQuery(callbackQuery.id); await bot.answerCallbackQuery(callbackQuery.id);
} }
} }
@@ -552,7 +433,7 @@ export default class UserProductHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const productId = callbackQuery.data.replace('decrease_quantity_', ''); const productId = callbackQuery.data.replace('decrease_quantity_', '');
const state = await userStates.get(chatId); const state = userStates.get(chatId);
try { try {
const product = await ProductService.getProductById(productId) const product = await ProductService.getProductById(productId)
@@ -572,7 +453,7 @@ export default class UserProductHandler {
const newQuantity = Math.max(currentQuantity - 1, 1); const newQuantity = Math.max(currentQuantity - 1, 1);
// Update state // Update state
await userStates.set(chatId, { userStates.set(chatId, {
...state, ...state,
quantity: newQuantity quantity: newQuantity
}); });
@@ -603,7 +484,7 @@ export default class UserProductHandler {
await bot.answerCallbackQuery(callbackQuery.id); await bot.answerCallbackQuery(callbackQuery.id);
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleDecreaseQuantity'); console.error('Error in handleDecreaseQuantity:', error);
await bot.answerCallbackQuery(callbackQuery.id); await bot.answerCallbackQuery(callbackQuery.id);
} }
} }
@@ -612,18 +493,16 @@ export default class UserProductHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const productId = callbackQuery.data.replace('buy_product_', ''); const productId = callbackQuery.data.replace('buy_product_', '');
const state = await userStates.get(chatId); const state = userStates.get(chatId);
try { try {
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId)
if (!user) { if (!user) {
throw new Error('User not found'); throw new Error('User not found');
} }
const lang = user?.language || 'en';
const t = tForUser(lang);
const product = await ProductService.getProductById(productId); const product = await ProductService.getProductById(productId);
if (!product) { if (!product) {
throw new Error('Product not found'); throw new Error('Product not found');
} }
@@ -631,39 +510,7 @@ export default class UserProductHandler {
const quantity = state?.quantity || 1; const quantity = state?.quantity || 1;
const totalPrice = product.price * quantity; const totalPrice = product.price * quantity;
// Получение баланса пользователя // Get user's crypto wallets with balances
const userBalance = await UserService.getUserBalance(user.id);
// Проверка баланса пользователя
if (userBalance <= 0) {
await editOrSendCallback(callbackQuery,
t('purchase.insufficient_balance', { balance: userBalance, total: totalPrice }),
{
reply_markup: {
inline_keyboard: [[
{ text: t('purchase.top_up_balance'), callback_data: 'top_up_wallet' }
]]
}
}
);
return;
}
if (userBalance < totalPrice) {
await editOrSendCallback(callbackQuery,
t('purchase.insufficient_balance', { balance: userBalance, total: totalPrice }),
{
reply_markup: {
inline_keyboard: [[
{ text: t('purchase.top_up_balance'), callback_data: 'top_up_wallet' }
]]
}
}
);
return;
}
// Получение криптокошельков пользователя
const cryptoWallets = await db.allAsync(` const cryptoWallets = await db.allAsync(`
SELECT wallet_type, address SELECT wallet_type, address
FROM crypto_wallets FROM crypto_wallets
@@ -672,12 +519,13 @@ export default class UserProductHandler {
`, [user.id]); `, [user.id]);
if (cryptoWallets.length === 0) { if (cryptoWallets.length === 0) {
await editOrSendCallback(callbackQuery, await bot.sendMessage(
t('purchase.need_wallet'), chatId,
'You need to add a crypto wallet first to make purchases.',
{ {
reply_markup: { reply_markup: {
inline_keyboard: [[ inline_keyboard: [[
{ text: t('purchase.add_wallet'), callback_data: 'add_wallet' } {text: ' Add Wallet', callback_data: 'add_wallet'}
]] ]]
} }
} }
@@ -687,36 +535,29 @@ export default class UserProductHandler {
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
[{ text: t('purchase.pay'), callback_data: `pay_with_main_${productId}_${quantity}` }], ...cryptoWallets.map(wallet => [{
[{ text: t('purchase.cancel'), callback_data: `shop_product_${productId}` }] // Кнопка "Back" text: `Pay with ${wallet.wallet_type}`,
callback_data: `pay_with_${wallet.wallet_type}_${productId}_${quantity}`
}]),
[{text: '« Cancel', callback_data: `shop_product_${productId}`}]
] ]
}; };
// Отправка сообщения с кнопками await bot.editMessageText(
const purchaseMessage = await bot.editMessageText( `🛒 Purchase Summary:\n\n` +
`${t('purchase.summary')}\n\n` + `Product: ${product.name}\n` +
`${t('purchase.product')}: ${product.name}\n` + `Quantity: ${quantity}\n` +
`${t('purchase.quantity')}: ${quantity}\n` + `Total: $${totalPrice}\n\n` +
`${t('purchase.total')}: $${totalPrice}\n`, `Select payment method:`,
{ {
chat_id: chatId, chat_id: chatId,
message_id: callbackQuery.message.message_id, message_id: callbackQuery.message.message_id,
reply_markup: keyboard reply_markup: keyboard
} }
); );
// Сохранение ID сообщения с фотографией в состояние пользователя
await userStates.set(chatId, {
...state,
photoMessageId: state?.photoMessageId || null,
purchaseMessageId: purchaseMessage.message_id
});
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handleBuyProduct'); console.error('Error in handleBuyProduct:', error);
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null); await bot.sendMessage(chatId, 'Error processing purchase. Please try again.');
const lang = user?.language || 'en';
const t = tForUser(lang);
await editOrSendCallback(callbackQuery, t('purchase.error_processing'));
} }
} }
@@ -724,28 +565,11 @@ export default class UserProductHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_'); const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_');
const state = await userStates.get(chatId); const state = userStates.get(chatId);
try { try {
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
if (!Validators.isValidWalletType(walletType)) {
await editOrSendCallback(callbackQuery, t('purchase.invalid_wallet'));
return;
}
if (!Validators.isValidNumericId(Number(productId))) {
await editOrSendCallback(callbackQuery, t('purchase.invalid_product'));
return;
}
const qty = Number(quantity);
if (!Number.isFinite(qty) || qty <= 0) {
await editOrSendCallback(callbackQuery, t('purchase.invalid_quantity'));
return;
}
await UserService.recalculateUserBalanceByTelegramId(telegramId); await UserService.recalculateUserBalanceByTelegramId(telegramId);
const user = await UserService.getUserByTelegramId(telegramId)
if (!user) { if (!user) {
throw new Error('User not found'); throw new Error('User not found');
@@ -760,81 +584,54 @@ export default class UserProductHandler {
const balance = user.total_balance + user.bonus_balance; const balance = user.total_balance + user.bonus_balance;
if (totalPrice > balance) { if (totalPrice > balance) {
await userStates.delete(chatId); userStates.delete(chatId);
await bot.editMessageText(t('purchase.not_enough_money'), { await bot.editMessageText(`Not enough money`, {
chat_id: chatId, chat_id: chatId,
message_id: callbackQuery.message.message_id, message_id: callbackQuery.message.message_id,
}); });
return; return;
} }
// Проверка наличия товара await PurchaseService.createPurchase(user.id, product.id, walletType, quantity, totalPrice)
if (product.quantity_in_stock < quantity) {
await editOrSendCallback(callbackQuery, t('purchase.not_enough_stock', { count: product.quantity_in_stock }));
return;
}
// Создаем покупку и получаем её ID
const purchaseId = await PurchaseService.createPurchase(user.id, productId, walletType, quantity, totalPrice);
// Уменьшаем количество товара в базе данных
await ProductService.decreaseProductQuantity(productId, quantity);
// Извлекаем данные о локации
const location = await LocationService.getLocationById(product.location_id);
const category = await CategoryService.getCategoryById(product.category_id);
// Удаляем сообщение с Public Photo, если оно существует
if (state?.photoMessageId) {
try {
await bot.deleteMessage(chatId, state.photoMessageId);
} catch (error) {
logger.error({ err: error }, 'Error deleting Public Photo message');
}
}
// Отправляем Hidden Photo
let hiddenPhotoMessage; let hiddenPhotoMessage;
if (product.hidden_photo_url) { if (product.hidden_photo_url) {
hiddenPhotoMessage = await sendProductPhoto(chatId, product.hidden_photo_url, 'Hidden photo'); try {
hiddenPhotoMessage = await bot.sendPhoto(chatId, product.hidden_photo_url, {caption: 'Hidden photo'});
} catch (e) {
hiddenPhotoMessage = await bot.sendPhoto(chatId, "./corrupt-photo.jpg", {caption: 'Hidden photo'})
}
} }
const message = ` const message = `
${t('purchase.details')} 📦 Product Details:
${t('purchase.product')}: ${product.name}
${t('purchase.quantity')}: ${quantity}
${t('purchase.total')}: $${totalPrice}
${t('purchase.location')}: ${location?.country || 'N/A'}, ${location?.city || 'N/A'}, ${location?.district || 'N/A'}
${t('purchase.category')}: ${category?.name || 'N/A'}
${t('purchase.private_info')} Name: ${product.name}
${product.private_data || 'N/A'} Price: $${product.price}
${t('purchase.hidden_location')}: ${product.hidden_description || 'N/A'} Description: ${product.description}
${t('purchase.coordinates')}: ${product.hidden_coordinates || 'N/A'} Stock: ${product.quantity_in_stock}
Location: ${product.country}, ${product.city}, ${product.district}
Category: ${product.category_name}
Subcategory: ${product.subcategory_name}
🔒 Private Information:
${product.private_data}
Hidden Location: ${product.hidden_description}
Coordinates: ${product.hidden_coordinates}
`; `;
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
[{ text: t('purchase.view_purchase'), callback_data: `view_purchase_${purchaseId}` }], // Переход к покупке [{text: "I've got it!", callback_data: "Asdasdasd"}],
[{ text: t('bot.contact_support'), url: config.SUPPORT_LINK }] // Сохранение кнопки "Contact support" [{text: "Contact support", url: config.SUPPORT_LINK}]
] ]
}; };
await bot.sendMessage(chatId, message, {reply_markup: keyboard}); await bot.sendMessage(chatId, message, {reply_markup: keyboard});
await bot.deleteMessage(chatId, callbackQuery.message.message_id); await bot.deleteMessage(chatId, callbackQuery.message.message_id);
// Сохраняем ID сообщения с Hidden Photo в состояние пользователя
await userStates.set(chatId, {
action: 'viewing_purchase',
purchaseId,
hiddenPhotoMessageId: hiddenPhotoMessage ? hiddenPhotoMessage.message_id : null
});
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in handlePay'); console.error('Error in handleBuyProduct:', error);
const user = await UserService.getUserByTelegramId(telegramId).catch(() => null); await bot.sendMessage(chatId, 'Error processing purchase. Please try again.');
const lang = user?.language || 'en';
const t = tForUser(lang);
await editOrSendCallback(callbackQuery, t('purchase.error_processing'));
} }
} }
} }

View File

@@ -1,145 +1,85 @@
// userPurchaseHandler.js
import config from "../../config/config.js"; import config from "../../config/config.js";
import db from '../../config/database.js';
import fs from 'fs';
import path from 'path';
import bot from "../../context/bot.js";
import logger from "../../utils/logger.js";
import PurchaseService from "../../services/purchaseService.js"; import PurchaseService from "../../services/purchaseService.js";
import ProductService from "../../services/productService.js";
import UserService from "../../services/userService.js"; import UserService from "../../services/userService.js";
import LocationService from "../../services/locationService.js"; import bot from "../../context/bot.js";
import CategoryService from "../../services/categoryService.js"; import ProductService from "../../services/productService.js";
import WalletService from "../../services/walletService.js";
import userStates from "../../context/userStates.js";
import Validators from '../../utils/validators.js';
import { editOrSendCallback, deleteAndSend } from '../../utils/messageUtils.js';
import { tForUser } from '../../i18n/index.js';
const UPLOADS_DIR = path.join(process.cwd(), 'uploads');
const FALLBACK_PHOTO = path.join(process.cwd(), 'corrupt-photo.jpg');
function resolvePhotoSource(photoUrl) {
if (!photoUrl) return null;
if (photoUrl.startsWith('http')) return photoUrl;
const filePath = path.join(UPLOADS_DIR, photoUrl.replace(/^\/uploads\//, ''));
if (fs.existsSync(filePath)) return filePath;
return null;
}
async function sendProductPhoto(chatId, photoUrl, caption) {
const source = resolvePhotoSource(photoUrl);
if (!source) return null;
try {
return await bot.sendPhoto(chatId, source, { caption });
} catch (e) {
if (fs.existsSync(FALLBACK_PHOTO)) {
return await bot.sendPhoto(chatId, FALLBACK_PHOTO, { caption });
}
return null;
}
}
export default class UserPurchaseHandler { export default class UserPurchaseHandler {
static async viewPurchasePage(userId, page, t) { static async viewPurchasePage(userId, page) {
try { try {
const limit = 10; const limit = 10;
const offset = page * limit; const offset = (page || 0) * limit;
const previousPage = page > 0 ? page - 1 : 0;
const nextPage = page + 1;
const purchases = await PurchaseService.getPurchasesByUserId(userId, limit, offset); const purchases = await PurchaseService.getPurchasesByUserId(userId, limit, offset);
const totalPurchases = await PurchaseService.getTotalPurchasesByUserId(userId);
const totalPages = Math.ceil(totalPurchases / limit);
if (totalPurchases === 0) { if ((purchases.length === 0) && (page == 0)) {
return { return {
text: t('purchase.history_empty'), text: 'You haven\'t made any purchases yet.',
markup: { markup: [[
inline_keyboard: [ {text: '🛍 Browse Products', callback_data: 'shop_start'}
[{ text: t('purchase.browse_products'), callback_data: 'shop_start' }] ]]
]
} }
};
} }
if (purchases.length === 0 && page > 0) { if ((purchases.length === 0) && (page > 0)) {
return await this.viewPurchasePage(userId, page - 1, t); return await this.viewPurchasePage(userId, previousPage);
} }
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
...purchases.map(item => [{ ...purchases.map(item => [{
text: `${item.status === 'received' ? '✅' : '❌'} ${item.product_name} [${new Date(item.purchase_date).toLocaleString()}]`, text: `${item.product_name} [${new Date(item.purchase_date).toLocaleString()}]`,
callback_data: `view_purchase_${item.id}` callback_data: `view_purchase_${item.id}`
}]), }]),
[
{
text: page > 0 ? t('purchase.page_back', { page }) : '« Back',
callback_data: page > 0 ? `list_purchases_${page - 1}` : 'no_action',
hide: page === 0
},
{
text: t('purchase.page_info', { current: page + 1, total: totalPages }),
callback_data: 'current_page'
},
{
text: page < totalPages - 1 ? t('purchase.page_next', { page: page + 2 }) : 'Next »',
callback_data: page < totalPages - 1 ? `list_purchases_${page + 1}` : 'no_action',
hide: page === totalPages - 1
}
]
] ]
}; };
keyboard.inline_keyboard.push([
{text: `«`, callback_data: `list_purchases_${previousPage}`},
{text: `»`, callback_data: `list_purchases_${nextPage}`},
]);
keyboard.inline_keyboard.push([
{text: '🛍 Browse Products', callback_data: 'shop_start'}
]);
return { return {
text: t('purchase.select_purchase', { page: page + 1, total: totalPages }), text: `📦 Select purchase to view detailed information:`,
markup: keyboard markup: keyboard
}; }
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in viewPurchasePage'); console.error('Error in showPurchases:', error);
return { text: t('purchase.error_loading') }; return {text: 'Error loading purchase history. Please try again.'};
} }
} }
static async handlePurchaseListPage(callbackQuery) { static async handlePurchaseListPage(callbackQuery) {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const page = parseInt(callbackQuery.data.replace('list_purchases_', ''));
const page = callbackQuery.data.replace('list_purchases_', '');
try { try {
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
if (!user) { if (!user) {
await bot.sendMessage(chatId, t('profile.not_found')); await bot.sendMessage(chatId, 'User not found.');
return; return;
} }
const state = await userStates.get(chatId); const {text, markup} = await this.viewPurchasePage(user.id, parseInt(page));
if (state?.hiddenPhotoMessageId) {
try {
await bot.deleteMessage(chatId, state.hiddenPhotoMessageId);
} catch (error) {
logger.error({ err: error }, 'Error deleting Hidden Photo message');
}
}
const { text, markup } = await this.viewPurchasePage(user.id, page, t);
await bot.editMessageText(text, { await bot.editMessageText(text, {
chat_id: chatId, chat_id: chatId,
message_id: callbackQuery.message.message_id, message_id: callbackQuery.message.message_id,
reply_markup: markup, reply_markup: markup,
parse_mode: 'Markdown' parse_mode: 'Markdown',
}); });
await userStates.delete(chatId);
} catch (e) { } catch (e) {
logger.error({ err: e }, 'Error in handlePurchaseListPage'); return;
const t = tForUser('en');
await editOrSendCallback(callbackQuery, t('purchase.error_loading'));
} }
} }
@@ -148,22 +88,20 @@ export default class UserPurchaseHandler {
const telegramId = msg.from.id; const telegramId = msg.from.id;
try { try {
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
if (!user) { if (!user) {
await bot.sendMessage(chatId, t('profile.not_found')); await bot.sendMessage(chatId, 'User not found.');
return; return;
} }
const { text, markup } = await this.viewPurchasePage(user.id, 0, t); const {text, markup} = await this.viewPurchasePage(user.id, 0);
await bot.sendMessage(chatId, text, {reply_markup: markup, parse_mode: 'Markdown'}); await bot.sendMessage(chatId, text, {reply_markup: markup, parse_mode: 'Markdown'});
} catch (error) { } catch (error) {
logger.error({ err: error }, 'Error in showPurchases'); console.error('Error in handleSubcategorySelection:', error);
const t = tForUser('en'); await bot.sendMessage(chatId, 'Error loading products. Please try again.');
await bot.sendMessage(chatId, t('purchase.error_loading'));
} }
} }
@@ -171,138 +109,52 @@ export default class UserPurchaseHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const purchaseId = callbackQuery.data.replace('view_purchase_', ''); const purchaseId = callbackQuery.data.replace('view_purchase_', '');
try {
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const purchase = await PurchaseService.getPurchaseById(purchaseId); const purchase = await PurchaseService.getPurchaseById(purchaseId);
if (!purchase) { if (!purchase) {
await editOrSendCallback(callbackQuery, t('purchase.no_such_purchase')); await bot.sendMessage(chatId, "No such purchase");
return; return;
} }
const product = await ProductService.getProductById(purchase.product_id); const product = await ProductService.getProductById(purchase.product_id)
if (!product) { if (!product) {
await editOrSendCallback(callbackQuery, t('purchase.no_such_product')); await bot.sendMessage(chatId, "No such product");
return; return;
} }
const location = await LocationService.getLocationById(product.location_id);
const category = await CategoryService.getCategoryById(product.category_id);
const state = await userStates.get(chatId);
if (state?.hiddenPhotoMessageId) {
try {
await bot.deleteMessage(chatId, state.hiddenPhotoMessageId);
} catch (error) {
logger.error({ err: error }, 'Error deleting Hidden Photo message');
}
}
let hiddenPhotoMessage; let hiddenPhotoMessage;
if (product.hidden_photo_url) { if (product.hidden_photo_url) {
hiddenPhotoMessage = await sendProductPhoto(chatId, product.hidden_photo_url, 'Hidden photo'); try {
hiddenPhotoMessage = await bot.sendPhoto(chatId, product.hidden_photo_url, {caption: 'Hidden photo'});
} catch (e) {
hiddenPhotoMessage = await bot.sendPhoto(chatId, "./corrupt-photo.jpg", {caption: 'Hidden photo'})
}
} }
const message = ` const message = `
${t('purchase.details')} 📦 Purchase Details:
${t('purchase.product')}: ${product.name || 'N/A'} Name: ${purchase.product_name}
${t('purchase.quantity')}: ${purchase.quantity} Quantity: ${purchase.quantity}
${t('purchase.total')}: $${purchase.total_price} Total: $${purchase.total_price}
${t('purchase.location')}: ${location?.country || 'N/A'}, ${location?.city || 'N/A'}, ${location?.district || 'N/A'} Location: ${purchase.country}, ${purchase.city}
${t('purchase.category')}: ${category?.name || 'N/A'} Payment: ${purchase.wallet_type}
Date: ${new Date(purchase.purchase_date).toLocaleString()}
${t('purchase.private_info')} 🔒 Private Information:
${product.private_data || 'N/A'} ${product.private_data}
${t('purchase.hidden_location')}: ${product.hidden_description || 'N/A'} Hidden Location: ${product.hidden_description}
${t('purchase.coordinates')}: ${product.hidden_coordinates || 'N/A'} Coordinates: ${product.hidden_coordinates}
`; `;
const keyboard = { const keyboard = {
inline_keyboard: [ inline_keyboard: [
...(purchase.status !== 'received' ? [[{ text: t('purchase.confirm_received'), callback_data: `confirm_received_${purchaseId}` }]] : []), [{text: "I've got it!", callback_data: "Asdasdasd"}],
[{ text: t('purchase.back_to_list'), callback_data: `list_purchases_0` }], [{text: "Contact support", url: config.SUPPORT_LINK}]
[{ text: t('bot.contact_support'), url: config.SUPPORT_LINK }]
] ]
}; };
await bot.sendMessage(chatId, message, {reply_markup: keyboard}); await bot.sendMessage(chatId, message, {reply_markup: keyboard});
await bot.deleteMessage(chatId, callbackQuery.message.message_id); await bot.deleteMessage(chatId, callbackQuery.message.message_id);
await userStates.set(chatId, {
action: 'viewing_purchase',
purchaseId,
hiddenPhotoMessageId: hiddenPhotoMessage ? hiddenPhotoMessage.message_id : null
});
} catch (error) {
logger.error({ err: error }, 'Error in viewPurchase');
const t = tForUser('en');
await editOrSendCallback(callbackQuery, t('purchase.error_loading_details'));
}
}
static async handleConfirmReceived(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const purchaseId = callbackQuery.data.replace('confirm_received_', '');
try {
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const purchase = await PurchaseService.getPurchaseById(purchaseId);
if (!purchase) {
await editOrSendCallback(callbackQuery, t('purchase.no_such_purchase'));
return;
}
const purchaseUser = await UserService.getUserByUserId(purchase.user_id);
if (!purchaseUser) {
await editOrSendCallback(callbackQuery, t('profile.not_found'));
return;
}
await PurchaseService.updatePurchaseStatus(purchaseId, 'received');
await db.runAsync(
`INSERT INTO transactions (user_id, wallet_type, tx_hash, amount, created_at)
VALUES (?, ?, ?, ?, ?)`,
[
purchaseUser.id,
purchase.wallet_type,
purchase.tx_hash || 'no_hash',
purchase.total_price,
new Date().toISOString()
]
);
const adminIds = config.ADMIN_IDS;
for (const adminId of adminIds) {
await bot.sendMessage(adminId, t('purchase.admin_notification', { username: callbackQuery.from.username, purchaseId }));
}
await bot.sendMessage(chatId, t('purchase.purchase_received'));
await bot.deleteMessage(chatId, messageId);
const state = await userStates.get(chatId);
if (state?.hiddenPhotoMessageId) {
try {
await bot.deleteMessage(chatId, state.hiddenPhotoMessageId);
} catch (error) {
logger.error({ err: error }, 'Error deleting Hidden Photo message');
}
}
await userStates.delete(chatId);
await this.showPurchases({ chat: { id: chatId }, from: { id: callbackQuery.from.id } });
} catch (error) {
logger.error({ err: error }, 'Error in handleConfirmReceived');
const t = tForUser('en');
await editOrSendCallback(callbackQuery, t('purchase.error_confirming'));
}
} }
} }

View File

@@ -0,0 +1,573 @@
import db from '../../config/database.js';
import WalletGenerator from '../../utils/walletGenerator.js';
import WalletService from '../../utils/walletService.js';
import UserService from "../../services/userService.js";
import bot from "../../context/bot.js";
export default class UserWalletsHandler {
static async showBalance(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
try {
const user = await UserService.getUserByTelegramId(telegramId.toString());
if (!user) {
await bot.sendMessage(chatId, 'Profile not found. Please use /start to create one.');
return;
}
// Get active crypto wallets only
const cryptoWallets = await db.allAsync(`
SELECT wallet_type, address
FROM crypto_wallets
WHERE user_id = ?
ORDER BY wallet_type
`, [user.id]);
let message = '💰 *Your Active Wallets:*\n\n';
if (cryptoWallets.length > 0) {
const walletService = new WalletService(
cryptoWallets.find(w => w.wallet_type === 'BTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'LTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'TRON')?.address,
cryptoWallets.find(w => w.wallet_type === 'ETH')?.address,
user.id,
Date.now() - 30 * 24 * 60 * 60 * 1000
);
const balances = await walletService.getAllBalances();
let totalUsdValue = 0;
// Show active wallets
for (const [type, balance] of Object.entries(balances)) {
const baseType = this.getBaseWalletType(type);
const wallet = cryptoWallets.find(w =>
w.wallet_type === baseType ||
(type.includes('TRC-20') && w.wallet_type === 'TRON') ||
(type.includes('ERC-20') && w.wallet_type === 'ETH')
);
if (wallet) {
message += `🔐 *${type}*\n`;
message += `├ Balance: ${balance.amount.toFixed(8)} ${type.split(' ')[0]}\n`;
message += `├ Value: $${balance.usdValue.toFixed(2)}\n`;
message += `└ Address: \`${wallet.address}\`\n\n`;
totalUsdValue += balance.usdValue;
}
}
message += `📊 *Total Balance:* $${totalUsdValue.toFixed(2)}\n`;
} else {
message = 'You don\'t have any active wallets yet.';
}
// Check if user has archived wallets
const archivedCount = await db.getAsync(`
SELECT COUNT(*) as count
FROM crypto_wallets
WHERE user_id = ? AND wallet_type LIKE '%_%'
`, [user.id]);
const keyboard = {
inline_keyboard: [
[
{ text: ' Add Crypto Wallet', callback_data: 'add_wallet' },
{ text: '💸 Top Up', callback_data: 'top_up_wallet' }
],
[{ text: '🔄 Refresh Balance', callback_data: 'refresh_balance' }],
[{ text: '📊 Transaction History', callback_data: 'wallet_history' }]
]
};
// Add archived wallets button if any exist
if (archivedCount.count > 0) {
keyboard.inline_keyboard.splice(2, 0, [
{ text: `📁 Archived Wallets (${archivedCount.count})`, callback_data: 'view_archived_wallets' }
]);
}
await bot.sendMessage(chatId, message, {
reply_markup: keyboard,
parse_mode: 'Markdown'
});
} catch (error) {
console.error('Error in showBalance:', error);
await bot.sendMessage(chatId, 'Error loading balance. Please try again.');
}
}
static async handleAddWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const cryptoOptions = [
['BTC', 'ETH', 'LTC'],
['USDT TRC-20', 'USDD TRC-20'],
['USDT ERC-20', 'USDC ERC-20']
];
const keyboard = {
inline_keyboard: [
...cryptoOptions.map(row =>
row.map(coin => ({
text: coin,
callback_data: `generate_wallet_${coin.replace(' ', '_')}`
}))
),
[{ text: '« Back', callback_data: 'back_to_balance' }]
]
};
await bot.editMessageText(
'🔐 Select cryptocurrency to generate wallet:',
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: keyboard
}
);
}
static async handleGenerateWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('generate_wallet_', '').replace('_', ' ');
try {
const user = await UserService.getUserByTelegramId(telegramId);
if (!user) {
throw new Error('User not found');
}
await db.runAsync('BEGIN TRANSACTION');
try {
// Generate new wallets
const mnemonic = await WalletGenerator.generateMnemonic();
const wallets = await WalletGenerator.generateWallets(mnemonic);
const encryptedMnemonic = await WalletGenerator.encryptMnemonic(mnemonic, telegramId);
// Get the base wallet type (ETH for ERC-20, TRON for TRC-20)
const baseType = this.getBaseWalletType(walletType);
// Get existing wallet of this type
const existingWallet = await db.getAsync(
'SELECT id, address FROM crypto_wallets WHERE user_id = ? AND wallet_type = ?',
[user.id, baseType]
);
if (existingWallet) {
// Archive the old wallet by adding a suffix to its type
const timestamp = Date.now();
await db.runAsync(
'UPDATE crypto_wallets SET wallet_type = ? WHERE id = ?',
[`${baseType}_${timestamp}`, existingWallet.id]
);
}
// Store the new wallet
await db.runAsync(
`INSERT INTO crypto_wallets (
user_id, wallet_type, address, derivation_path, encrypted_mnemonic
) VALUES (?, ?, ?, ?, ?)`,
[
user.id,
baseType,
wallets[baseType].address,
wallets[baseType].path,
encryptedMnemonic
]
);
// Get the appropriate address for the requested wallet type
const displayAddress = this.getWalletAddress(wallets, walletType);
const network = this.getNetworkName(walletType);
let message = `✅ New wallet generated successfully!\n\n`;
message += `Type: ${walletType}\n`;
message += `Network: ${network}\n`;
message += `Address: \`${displayAddress}\`\n\n`;
if (existingWallet) {
message += ` Your previous wallet has been archived and will remain accessible for existing funds.\n`;
}
message += `\n⚠️ Important: Your recovery phrase has been securely stored. Keep your wallet address safe!`;
await bot.editMessageText(message, {
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [[
{ text: '« Back to Balance', callback_data: 'back_to_balance' }
]]
}
});
await db.runAsync('COMMIT');
} catch (error) {
await db.runAsync('ROLLBACK');
throw error;
}
} catch (error) {
console.error('Error generating wallet:', error);
await bot.editMessageText(
'❌ Error generating wallet. Please try again.',
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [[
{ text: '« Back to Balance', callback_data: 'back_to_balance' }
]]
}
}
);
}
}
static async handleTopUpWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
try {
const user = await UserService.getUserByTelegramId(telegramId);
// Get crypto wallets
const cryptoWallets = await db.allAsync(`
SELECT wallet_type, address
FROM crypto_wallets
WHERE user_id = ?
ORDER BY wallet_type
`, [user.id]);
if (cryptoWallets.length === 0) {
await bot.editMessageText(
'You don\'t have any wallets yet.',
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [[
{ text: ' Add Wallet', callback_data: 'add_wallet' }
]]
}
}
);
return;
}
let message = '💰 *Available Wallets:*\n\n';
const walletService = new WalletService(
cryptoWallets.find(w => w.wallet_type === 'BTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'LTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'TRON')?.address,
cryptoWallets.find(w => w.wallet_type === 'ETH')?.address,
user.id,
Date.now() - 30 * 24 * 60 * 60 * 1000
);
const balances = await walletService.getAllBalances();
for (const [type, balance] of Object.entries(balances)) {
if (cryptoWallets.some(w => w.wallet_type === type.split(' ')[0] ||
(type.includes('TRC-20') && w.wallet_type === 'TRON') ||
(type.includes('ERC-20') && w.wallet_type === 'ETH'))) {
const wallet = cryptoWallets.find(w =>
w.wallet_type === type.split(' ')[0] ||
(type.includes('TRC-20') && w.wallet_type === 'TRON') ||
(type.includes('ERC-20') && w.wallet_type === 'ETH')
);
message += `🔐 *${type}*\n`;
message += `├ Balance: ${balance.amount.toFixed(8)} ${type.split(' ')[0]}\n`;
message += `├ Value: $${balance.usdValue.toFixed(2)}\n`;
message += `└ Address: \`${wallet.address}\`\n\n`;
}
}
const keyboard = {
inline_keyboard: [
[{ text: '« Back', callback_data: 'back_to_balance' }]
]
};
await bot.editMessageText(message, {
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: keyboard
});
} catch (error) {
console.error('Error in handleTopUpWallet:', error);
await bot.sendMessage(chatId, 'Error loading wallets. Please try again.');
}
}
static async handleWalletHistory(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
try {
const user = UserService.getUserByTelegramId(telegramId);
const transactions = await db.allAsync(`
SELECT type, amount, tx_hash, created_at, wallet_type
FROM transactions
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 10
`, [user.id]);
if (transactions.length === 0) {
await bot.editMessageText(
'No transactions found.',
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [[
{ text: '« Back', callback_data: 'back_to_balance' }
]]
}
}
);
return;
}
let message = '📊 *Recent Transactions:*\n\n';
transactions.forEach(tx => {
const date = new Date(tx.created_at).toLocaleString();
const symbol = tx.type === 'deposit' ? '' : '';
message += `${symbol} ${tx.amount} ${tx.wallet_type}\n`;
message += `🔗 TX: \`${tx.tx_hash}\`\n`;
message += `🕒 ${date}\n\n`;
});
await bot.editMessageText(message, {
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [[
{ text: '« Back', callback_data: 'back_to_balance' }
]]
}
});
} catch (error) {
console.error('Error in handleWalletHistory:', error);
await bot.sendMessage(chatId, 'Error loading transaction history. Please try again.');
}
}
static async handleViewArchivedWallets(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
try {
const user = await UserService.getUserByTelegramId(telegramId.toString());
// Get archived wallets and validate timestamps
const archivedWallets = await db.allAsync(`
SELECT wallet_type, address
FROM crypto_wallets
WHERE user_id = ? AND wallet_type LIKE '%_%'
ORDER BY wallet_type
`, [user.id]);
// Filter out wallets with invalid timestamps
const validArchivedWallets = archivedWallets.filter(wallet => {
const [, timestamp] = wallet.wallet_type.split('_');
const date = new Date(parseInt(timestamp));
return !isNaN(date.getTime()); // Check if date is valid
});
if (validArchivedWallets.length === 0) {
await bot.editMessageText(
'No archived wallets found.',
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [[
{ text: '« Back', callback_data: 'back_to_balance' }
]]
}
}
);
return;
}
// Group wallets by base type
const groupedWallets = {};
let totalUsdValue = 0;
for (const wallet of validArchivedWallets) {
const [baseType, timestamp] = wallet.wallet_type.split('_');
if (!groupedWallets[baseType]) {
groupedWallets[baseType] = [];
}
groupedWallets[baseType].push({
address: wallet.address,
timestamp: parseInt(timestamp)
});
}
// Create wallet service instance
const walletService = new WalletService(
groupedWallets['BTC']?.[0]?.address,
groupedWallets['LTC']?.[0]?.address,
groupedWallets['TRON']?.[0]?.address,
groupedWallets['ETH']?.[0]?.address,
user.id,
Date.now() - 30 * 24 * 60 * 60 * 1000
);
// Get all balances
const balances = await walletService.getAllBalances();
let message = '📁 *Archived Wallets:*\n\n';
// Process each cryptocurrency type
for (const baseType of Object.keys(groupedWallets).sort()) {
let typeTotal = 0;
let typeUsdTotal = 0;
message += `🔒 *${baseType}*\n`;
// Sort wallets by timestamp (newest first)
const sortedWallets = groupedWallets[baseType].sort((a, b) => b.timestamp - a.timestamp);
for (const wallet of sortedWallets) {
const date = new Date(wallet.timestamp);
let balance = 0;
let usdValue = 0;
// Get balance based on wallet type
switch (baseType) {
case 'BTC':
balance = balances.BTC.amount;
usdValue = balances.BTC.usdValue;
break;
case 'LTC':
balance = balances.LTC.amount;
usdValue = balances.LTC.usdValue;
break;
case 'ETH':
balance = balances.ETH.amount;
usdValue = balances.ETH.usdValue;
break;
case 'TRON':
balance = balances['USDT TRC-20'].amount + balances['USDD TRC-20'].amount;
usdValue = balances['USDT TRC-20'].usdValue + balances['USDD TRC-20'].usdValue;
break;
}
typeTotal += balance;
typeUsdTotal += usdValue;
message += `├ Balance: ${balance.toFixed(8)} ${baseType}\n`;
message += `├ Value: $${usdValue.toFixed(2)}\n`;
message += `├ Address: \`${wallet.address}\`\n`;
message += `└ Archived: ${date.toLocaleDateString()}\n\n`;
}
message += `📊 *Total ${baseType}*:\n`;
message += `├ Amount: ${typeTotal.toFixed(8)} ${baseType}\n`;
message += `└ Value: $${typeUsdTotal.toFixed(2)}\n\n`;
totalUsdValue += typeUsdTotal;
}
message += `💰 *Total Value of Archived Wallets:* $${totalUsdValue.toFixed(2)}`;
await bot.editMessageText(message, {
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [[
{ text: '« Back', callback_data: 'back_to_balance' }
]]
}
});
} catch (error) {
console.error('Error in handleViewArchivedWallets:', error);
await bot.sendMessage(chatId, 'Error loading archived wallets. Please try again.');
}
}
static async handleRefreshBalance(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
try {
await bot.editMessageText(
'🔄 Refreshing balances...',
{
chat_id: chatId,
message_id: messageId
}
);
// Re-fetch and display updated balances
await this.showBalance({
chat: { id: chatId },
from: { id: callbackQuery.from.id }
});
// Delete the "refreshing" message
await bot.deleteMessage(chatId, messageId);
} catch (error) {
console.error('Error in handleRefreshBalance:', error);
await bot.editMessageText(
'❌ Error refreshing balances. Please try again.',
{
chat_id: chatId,
message_id: messageId,
reply_markup: {
inline_keyboard: [[
{ text: '« Back', callback_data: 'back_to_balance' }
]]
}
}
);
}
}
static async handleBackToBalance(callbackQuery) {
await this.showBalance({
chat: { id: callbackQuery.message.chat.id },
from: { id: callbackQuery.from.id }
});
await bot.deleteMessage(callbackQuery.message.chat.id, callbackQuery.message.message_id);
}
// Helper methods
static getBaseWalletType(walletType) {
if (walletType.includes('TRC-20')) return 'TRON';
if (walletType.includes('ERC-20')) return 'ETH';
return walletType;
}
static getWalletAddress(wallets, walletType) {
if (walletType.includes('TRC-20')) return wallets.TRON.address;
if (walletType.includes('ERC-20')) return wallets.ETH.address;
if (walletType === 'BTC') return wallets.BTC.address;
if (walletType === 'LTC') return wallets.LTC.address;
if (walletType === 'ETH') return wallets.ETH.address;
throw new Error('Invalid wallet type');
}
static getNetworkName(walletType) {
if (walletType.includes('TRC-20')) return 'Tron Network (TRC-20)';
if (walletType.includes('ERC-20')) return 'Ethereum Network (ERC-20)';
if (walletType === 'BTC') return 'Bitcoin Network';
if (walletType === 'LTC') return 'Litecoin Network';
if (walletType === 'ETH') return 'Ethereum Network';
return 'Unknown Network';
}
}

View File

@@ -1,92 +0,0 @@
import db from '../../../config/database.js';
import WalletUtils from '../../../utils/walletUtils.js';
import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
export default class ArchiveHandler {
static async handleViewArchivedWallets(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId.toString());
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
const archivedWallets = await db.allAsync(`
SELECT wallet_type, address FROM crypto_wallets
WHERE user_id = ? AND wallet_type LIKE '%_%' ORDER BY wallet_type
`, [user.id]);
const validArchivedWallets = archivedWallets.filter(wallet => {
const [, timestamp] = wallet.wallet_type.split('_');
return !isNaN(new Date(parseInt(timestamp)).getTime());
});
if (validArchivedWallets.length === 0) {
await bot.editMessageText(t('wallet.no_archived_wallets'), {
chat_id: chatId, message_id: callbackQuery.message.message_id,
reply_markup: { inline_keyboard: [[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]] }
});
return;
}
const groupedWallets = {};
for (const wallet of validArchivedWallets) {
const [baseType, timestamp] = wallet.wallet_type.split('_');
if (!groupedWallets[baseType]) groupedWallets[baseType] = [];
groupedWallets[baseType].push({ address: wallet.address, timestamp: parseInt(timestamp) });
}
const walletUtilsInstance = new WalletUtils(
groupedWallets['BTC']?.[0]?.address,
groupedWallets['LTC']?.[0]?.address,
groupedWallets['ETH']?.[0]?.address || groupedWallets['USDT']?.[0]?.address || groupedWallets['USDC']?.[0]?.address,
user.id, Date.now() - 30 * 24 * 60 * 60 * 1000
);
const balances = await walletUtilsInstance.getAllBalances();
let message = `${t('wallet.archived_wallets_title')}\n\n`;
let totalUsdValue = 0;
for (const baseType of Object.keys(groupedWallets).sort()) {
let typeTotal = 0;
let typeUsdTotal = 0;
message += `🔒 *${baseType}*\n`;
const sortedWallets = groupedWallets[baseType].sort((a, b) => b.timestamp - a.timestamp);
for (const wallet of sortedWallets) {
const balance = balances[baseType]?.amount || 0;
const usdValue = balances[baseType]?.usdValue || 0;
typeTotal += balance;
typeUsdTotal += usdValue;
const date = new Date(wallet.timestamp);
message += `${t('wallet.balance')}: ${balance.toFixed(8)} ${baseType}\n`;
message += `${t('wallet.value')}: $${usdValue.toFixed(2)}\n`;
message += `${t('wallet.address')}: \`${wallet.address}\`\n`;
message += `${t('wallet.archived_date')}: ${date.toLocaleDateString()}\n\n`;
}
message += `${t('wallet.total_type', { type: baseType })}:\n`;
message += `${t('wallet.amount')}: ${typeTotal.toFixed(8)} ${baseType}\n`;
message += `${t('wallet.value')}: $${typeUsdTotal.toFixed(2)}\n\n`;
totalUsdValue += typeUsdTotal;
}
message += `${t('wallet.total_archived_value')} $${totalUsdValue.toFixed(2)}`;
await bot.editMessageText(message, {
chat_id: chatId, message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]] }
});
} catch (error) {
logger.error({ err: error }, 'Error in handleViewArchivedWallets');
await editOrSendCallback(callbackQuery, t('wallet.error_loading_archived'));
}
}
}

View File

@@ -1,103 +0,0 @@
import db from '../../../config/database.js';
import WalletUtils from '../../../utils/walletUtils.js';
import UserService from '../../../services/userService.js';
import WalletService from '../../../services/walletService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
export default class BalanceHandler {
static async showBalance(msg) {
const chatId = msg.chat.id;
const telegramId = msg.from.id;
const user = await UserService.getUserByTelegramId(telegramId.toString());
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
if (!user) {
await bot.sendMessage(chatId, t('wallet.profile_not_found'));
return;
}
await UserService.recalculateUserBalanceByTelegramId(telegramId);
const updatedUser = await UserService.getUserByTelegramId(telegramId.toString());
const cryptoWallets = await db.allAsync(`
SELECT wallet_type, address, balance
FROM crypto_wallets WHERE user_id = ?
ORDER BY wallet_type
`, [updatedUser.id]);
let message = `${t('wallet.your_active_wallets')}\n\n`;
if (cryptoWallets.length > 0) {
const walletUtilsInstance = new WalletUtils(
cryptoWallets.find(w => w.wallet_type === 'BTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'LTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'ETH')?.address,
cryptoWallets.find(w => w.wallet_type === 'USDT')?.address,
cryptoWallets.find(w => w.wallet_type === 'USDC')?.address,
updatedUser.id
);
const balances = await walletUtilsInstance.getAllBalancesFromDB();
let totalUsdValue = 0;
for (const [type, balance] of Object.entries(balances)) {
const wallet = cryptoWallets.find(w => w.wallet_type === type.split(' ')[0]);
if (wallet) {
message += `🔐 *${type}*\n`;
message += `${t('wallet.balance')}: ${balance.amount.toFixed(8)} ${type.split(' ')[0]}\n`;
message += `${t('wallet.value')}: $${balance.usdValue.toFixed(2)}\n`;
message += `${t('wallet.address')}: \`${wallet.address}\`\n\n`;
totalUsdValue += balance.usdValue;
}
}
message += `${t('wallet.total_crypto_balance')} $${totalUsdValue.toFixed(2)}\n`;
message += `${t('wallet.bonus_balance_label')} $${updatedUser.bonus_balance.toFixed(2)}\n`;
const availableBalance = updatedUser.bonus_balance + (updatedUser.total_balance || 0);
message += `${t('wallet.available_balance_label')} $${availableBalance.toFixed(2)}\n`;
} else {
message = t('wallet.no_active_wallets');
}
const archivedCount = await WalletService.getArchivedWalletsCount(updatedUser);
const keyboard = {
inline_keyboard: [
[
{ text: t('wallet.add_crypto_wallet'), callback_data: 'add_wallet' },
{ text: t('wallet.top_up'), callback_data: 'top_up_wallet' }
],
[{ text: t('wallet.refresh_balance'), callback_data: 'refresh_balance' }]
]
};
if (archivedCount > 0) {
keyboard.inline_keyboard.splice(2, 0, [
{ text: t('wallet.archived_wallets_count', { count: archivedCount }), callback_data: 'view_archived_wallets' }
]);
}
keyboard.inline_keyboard.splice(3, 0, [
{ text: t('wallet.transaction_history'), callback_data: 'view_transaction_history_0' }
]);
await bot.sendMessage(chatId, message, { reply_markup: keyboard, parse_mode: 'Markdown' });
} catch (error) {
logger.error({ err: error }, 'Error in showBalance');
await bot.sendMessage(chatId, t('wallet.error_loading_balance'));
}
}
static async handleBackToBalance(callbackQuery) {
await this.showBalance({
chat: { id: callbackQuery.message.chat.id },
from: { id: callbackQuery.from.id }
});
await bot.deleteMessage(callbackQuery.message.chat.id, callbackQuery.message.message_id);
}
}

View File

@@ -1,105 +0,0 @@
import db from '../../../config/database.js';
import WalletService from '../../../services/walletService.js';
import Validators from '../../../utils/validators.js';
import bot from '../../../context/bot.js';
import UserService from '../../../services/userService.js';
import logger from '../../../utils/logger.js';
import WalletHelpers from './helpers.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
export default class CreateHandler {
static async handleAddWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const cryptoOptions = [['BTC', 'ETH', 'LTC'], ['USDT', 'USDC']];
const keyboard = {
inline_keyboard: [
...cryptoOptions.map(row =>
row.map(coin => ({
text: coin,
callback_data: `generate_wallet_${coin.replace(' ', '_')}`
}))
),
[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]
]
};
await bot.editMessageText(t('wallet.select_crypto'), {
chat_id: chatId, message_id: callbackQuery.message.message_id,
reply_markup: keyboard
});
}
static async handleGenerateWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('generate_wallet_', '').replace('_', ' ');
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
if (!Validators.isValidWalletType(walletType)) {
await editOrSendCallback(callbackQuery, t('wallet.invalid_wallet_type'));
return;
}
try {
if (!user) throw new Error(t('wallet.user_not_found'));
await db.runAsync('BEGIN TRANSACTION');
try {
const existingWallet = await db.getAsync(
'SELECT id, address FROM crypto_wallets WHERE user_id = ? AND wallet_type = ?',
[user.id, walletType]
);
if (existingWallet) {
const timestamp = Date.now();
await db.runAsync(
'UPDATE crypto_wallets SET wallet_type = ? WHERE id = ?',
[`${walletType}_${timestamp}`, existingWallet.id]
);
}
const walletResult = await WalletService.createWallet(user.id, walletType);
if (!walletResult?.address) throw new Error('Failed to generate wallet address');
const network = WalletHelpers.getNetworkName(walletType, t);
let message = `${t('wallet.wallet_generated')}\n\n`;
message += `${t('wallet.wallet_type')}: ${walletType}\n${t('wallet.network')}: ${network}\n`;
message += `${t('wallet.address')}: \`${walletResult.address}\`\n\n`;
if (existingWallet) {
message += `${t('wallet.previous_archived')}\n`;
}
message += `\n${t('wallet.recovery_stored')}`;
await bot.editMessageText(message, {
chat_id: chatId, message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: t('wallet.back_to_balance'), callback_data: 'back_to_balance' }]] }
});
await db.runAsync('COMMIT');
} catch (error) {
await db.runAsync('ROLLBACK');
throw error;
}
} catch (error) {
logger.error({ err: error }, 'Error generating wallet');
await bot.editMessageText(t('wallet.error_generating'), {
chat_id: chatId, message_id: callbackQuery.message.message_id,
reply_markup: { inline_keyboard: [[{ text: t('wallet.back_to_balance'), callback_data: 'back_to_balance' }]] }
});
}
}
}

View File

@@ -1,230 +0,0 @@
import db from '../../../config/database.js';
import config from '../../../config/config.js';
import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
const DEPOSIT_AMOUNTS = [25, 50, 100, 250, 500];
const CHANGENOW_CRYPTO_MAP = {
BTC: 'btc',
ETH: 'eth',
LTC: 'ltc',
USDT: 'usdterc20',
USDC: 'usdcerc20'
};
const CRYPTO_SYMBOLS = {
BTC: '₿',
ETH: 'Ξ',
LTC: 'Ł',
USDT: '💲',
USDC: '💲'
};
export default class DepositHandler {
static async handleDepositSelectWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
if (!user) {
await editOrSendCallback(callbackQuery, t('wallet.profile_not_found'));
return;
}
const cryptoWallets = await db.allAsync(
"SELECT wallet_type, address FROM crypto_wallets WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#' ORDER BY wallet_type",
[user.id]
);
if (cryptoWallets.length === 0) {
await bot.editMessageText(
t('wallet.no_wallets_prefix'),
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
reply_markup: {
inline_keyboard: [
[{ text: t('purchase.add_wallet'), callback_data: 'add_wallet' }],
[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]
]
}
}
);
return;
}
const walletButtons = cryptoWallets.map(w => {
const symbol = CRYPTO_SYMBOLS[w.wallet_type] || '';
return [{
text: `${symbol} ${w.wallet_type}`,
callback_data: `deposit_wallet_${w.wallet_type}`
}];
});
walletButtons.push([{ text: t('wallet.back'), callback_data: 'back_to_balance' }]);
await bot.editMessageText(
t('wallet.deposit_changenow_select'),
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: walletButtons }
}
);
} catch (error) {
logger.error({ err: error }, 'Error in handleDepositSelectWallet');
await editOrSendCallback(callbackQuery, t('wallet.error_loading'));
}
}
static async handleDepositSelectAmount(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('deposit_wallet_', '');
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
const amountButtons = DEPOSIT_AMOUNTS.map(amount => ([{
text: `$${amount}`,
callback_data: `deposit_amount_${walletType}_${amount}`
}]));
amountButtons.push([{ text: t('wallet.back'), callback_data: 'top_up_wallet' }]);
await bot.editMessageText(
t('wallet.deposit_select_amount', { type: walletType }),
{
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: amountButtons }
}
);
}
static async handleDepositInstruction(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const parts = callbackQuery.data.replace('deposit_amount_', '').split('_');
const walletType = parts[0];
const amount = parts[1];
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
if (!user) {
await editOrSendCallback(callbackQuery, t('wallet.profile_not_found'));
return;
}
const wallet = await db.getAsync(
'SELECT address FROM crypto_wallets WHERE user_id = ? AND wallet_type = ?',
[user.id, walletType]
);
if (!wallet) {
await editOrSendCallback(callbackQuery, t('wallet.wallet_not_found'));
return;
}
const changenowTo = CHANGENOW_CRYPTO_MAP[walletType] || walletType.toLowerCase();
const refId = config.CHANGENOW_REF;
const changenowUrl = `https://changenow.io/exchange?from=eur&to=${changenowTo}&fiatMode=true&amount=${amount}${refId ? `&ref_id=${refId}` : ''}`;
let message = `${t('wallet.deposit_title', { type: walletType, amount })}\n\n`;
message += `${t('wallet.deposit_instructions_title')}\n\n`;
message += `${t('wallet.deposit_step1')}\n`;
message += `${t('wallet.deposit_step2', { amount, type: walletType })}\n`;
message += `${t('wallet.deposit_step3')}\n`;
message += `${t('wallet.deposit_step4')}\n`;
message += `${t('wallet.deposit_step5')}\n`;
message += `${t('wallet.deposit_step6')}\n\n`;
message += `${t('wallet.deposit_your_address', { type: walletType })}\n`;
message += `\`${wallet.address}\`\n\n`;
message += `${t('wallet.deposit_important_title')}\n`;
message += `${t('wallet.deposit_important1')}\n`;
message += `${t('wallet.deposit_important2')}\n`;
message += `${t('wallet.deposit_important3')}`;
const keyboard = {
inline_keyboard: [
[{ text: t('wallet.deposit_open_changenow', { amount, type: walletType }), url: changenowUrl }],
[
{ text: t('wallet.deposit_copy_address'), callback_data: `deposit_copy_${walletType}` },
{ text: t('wallet.deposit_change_amount'), callback_data: `deposit_wallet_${walletType}` }
],
[
{ text: t('wallet.deposit_choose_different'), callback_data: 'top_up_wallet' },
{ text: t('wallet.back_to_balance'), callback_data: 'back_to_balance' }
]
]
};
await bot.editMessageText(message, {
chat_id: chatId,
message_id: callbackQuery.message.message_id,
parse_mode: 'MarkdownV2',
reply_markup: keyboard
});
} catch (error) {
logger.error({ err: error }, 'Error in handleDepositInstruction');
await editOrSendCallback(callbackQuery, t('wallet.error_deposit_instructions'));
}
}
static async handleDepositCopyAddress(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('deposit_copy_', '');
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
if (!user) {
await bot.answerCallbackQuery(callbackQuery.id, { text: t('wallet.profile_not_found_short') });
return;
}
const wallet = await db.getAsync(
'SELECT address FROM crypto_wallets WHERE user_id = ? AND wallet_type = ?',
[user.id, walletType]
);
if (!wallet) {
await bot.answerCallbackQuery(callbackQuery.id, { text: t('wallet.wallet_not_found_short') });
return;
}
await bot.sendMessage(chatId, `${t('wallet.deposit_wallet_address', { type: walletType })}\n\n\`${wallet.address}\``, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: t('wallet.back_to_deposit'), callback_data: `deposit_wallet_${walletType}` }]
]
}
});
await bot.answerCallbackQuery(callbackQuery.id, {
text: t('wallet.deposit_address_sent', { type: walletType })
});
} catch (error) {
logger.error({ err: error }, 'Error in handleDepositCopyAddress');
await bot.answerCallbackQuery(callbackQuery.id, { text: t('wallet.error_copying_address') });
}
}
}

View File

@@ -1,20 +0,0 @@
import WalletUtils from '../../../utils/walletUtils.js';
export default class WalletHelpers {
static getNetworkName(walletType, t) {
if (walletType.includes('USDT')) return t ? t('wallet.network_erc20') : 'Ethereum Network (ERC-20)';
if (walletType.includes('USDC')) return t ? t('wallet.network_erc20') : 'Ethereum Network (ERC-20)';
if (walletType === 'BTC') return t ? t('wallet.network_btc') : 'Bitcoin Network';
if (walletType === 'LTC') return t ? t('wallet.network_ltc') : 'Litecoin Network';
if (walletType === 'ETH') return t ? t('wallet.network_eth') : 'Ethereum Network';
return t ? t('wallet.network_unknown') : 'Unknown Network';
}
static getWalletAddress(wallets, walletType) {
if (walletType.includes('ERC-20')) return wallets.ETH.address;
if (walletType === 'BTC') return wallets.BTC.address;
if (walletType === 'LTC') return wallets.LTC.address;
if (walletType === 'ETH') return wallets.ETH.address;
throw new Error('Invalid wallet type');
}
}

View File

@@ -1,117 +0,0 @@
import db from '../../../config/database.js';
import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
export default class HistoryHandler {
static async handleTransactionHistory(callbackQuery, page = 0) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId.toString());
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
if (!user) {
await editOrSendCallback(callbackQuery, t('wallet.profile_not_found'));
return;
}
const limit = 10;
const offset = page * limit;
const transactions = await db.allAsync(`
SELECT amount, tx_hash, created_at, wallet_type
FROM transactions WHERE user_id = ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`, [user.id, limit, offset]);
let message = '';
if (transactions.length > 0) {
message = `${t('wallet.transaction_history_title')}\n\n`;
transactions.forEach(tx => {
const date = new Date(tx.created_at).toLocaleString();
message += `${t('wallet.tx_amount')}: ${tx.amount}\n`;
message += `${t('wallet.tx_hash')}: \`${tx.tx_hash}\`\n`;
message += `${t('wallet.tx_date')}: ${date}\n`;
message += `${t('wallet.tx_wallet_type')}: ${tx.wallet_type}\n\n`;
});
} else {
message = `${t('wallet.transaction_history_title')}\n\n${t('wallet.no_transactions')}`;
}
const keyboard = { inline_keyboard: [[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]] };
if (page > 0) {
keyboard.inline_keyboard.unshift([
{ text: t('wallet.previous_page'), callback_data: `view_transaction_history_${page - 1}` }
]);
}
const nextTransactions = await db.allAsync(`
SELECT amount FROM transactions WHERE user_id = ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`, [user.id, limit, offset + limit]);
if (nextTransactions.length > 0) {
keyboard.inline_keyboard.push([
{ text: t('wallet.next_page'), callback_data: `view_transaction_history_${page + 1}` }
]);
}
await bot.editMessageText(message, {
chat_id: chatId, message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown', reply_markup: keyboard
});
} catch (error) {
logger.error({ err: error }, 'Error in handleTransactionHistory');
await editOrSendCallback(callbackQuery, t('wallet.error_loading_history'));
}
}
static async handleWalletHistory(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
const transactions = await db.allAsync(`
SELECT type, amount, tx_hash, created_at, wallet_type
FROM transactions WHERE user_id = ?
ORDER BY created_at DESC LIMIT 10
`, [user.id]);
if (transactions.length === 0) {
await bot.editMessageText(t('wallet.no_transactions'), {
chat_id: chatId, message_id: callbackQuery.message.message_id,
reply_markup: { inline_keyboard: [[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]] }
});
return;
}
let message = `${t('wallet.recent_transactions')}\n\n`;
transactions.forEach(tx => {
const date = new Date(tx.created_at).toLocaleString();
const symbol = tx.type === 'deposit' ? '' : '';
message += `${symbol} ${tx.amount} ${tx.wallet_type}\n`;
message += `${t('wallet.tx_hash')}: \`${tx.tx_hash}\`\n`;
message += `🕒 ${date}\n\n`;
});
await bot.editMessageText(message, {
chat_id: chatId, message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: [[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]] }
});
} catch (error) {
logger.error({ err: error }, 'Error in handleWalletHistory');
await editOrSendCallback(callbackQuery, t('wallet.error_loading_history'));
}
}
}

View File

@@ -1,26 +0,0 @@
import BalanceHandler from './balanceHandler.js';
import HistoryHandler from './historyHandler.js';
import RefreshHandler from './refreshHandler.js';
import CreateHandler from './createHandler.js';
import TopUpHandler from './topUpHandler.js';
import ArchiveHandler from './archiveHandler.js';
import DepositHandler from './depositHandler.js';
import WalletHelpers from './helpers.js';
export default {
showBalance: BalanceHandler.showBalance,
handleTransactionHistory: HistoryHandler.handleTransactionHistory,
handleWalletHistory: HistoryHandler.handleWalletHistory,
handleRefreshBalance: RefreshHandler.handleRefreshBalance,
handleAddWallet: CreateHandler.handleAddWallet,
handleGenerateWallet: CreateHandler.handleGenerateWallet,
handleTopUpWallet: TopUpHandler.handleTopUpWallet,
handleViewArchivedWallets: ArchiveHandler.handleViewArchivedWallets,
handleBackToBalance: BalanceHandler.handleBackToBalance,
handleDepositSelectWallet: DepositHandler.handleDepositSelectWallet,
handleDepositSelectAmount: DepositHandler.handleDepositSelectAmount,
handleDepositInstruction: DepositHandler.handleDepositInstruction,
handleDepositCopyAddress: DepositHandler.handleDepositCopyAddress,
getNetworkName: WalletHelpers.getNetworkName,
getWalletAddress: WalletHelpers.getWalletAddress,
};

View File

@@ -1,82 +0,0 @@
import db from '../../../config/database.js';
import WalletUtils from '../../../utils/walletUtils.js';
import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
export default class RefreshHandler {
static async handleRefreshBalance(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_id;
const user = await UserService.getUserByTelegramId(callbackQuery.from.id.toString());
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
await bot.answerCallbackQuery(callbackQuery.id, { text: t('wallet.refreshing_balances') });
if (!user) {
await editOrSendCallback(callbackQuery, t('wallet.profile_not_found'));
return;
}
const activeWallets = await db.allAsync(`
SELECT wallet_type, address FROM crypto_wallets
WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#'
`, [user.id]);
const walletAddresses = {
btc: activeWallets.find(w => w.wallet_type === 'BTC')?.address || null,
ltc: activeWallets.find(w => w.wallet_type === 'LTC')?.address || null,
eth: activeWallets.find(w => w.wallet_type === 'ETH')?.address || null,
usdt: activeWallets.find(w => w.wallet_type === 'USDT')?.address || null,
usdc: activeWallets.find(w => w.wallet_type === 'USDC')?.address || null,
};
const walletUtilsInstance = new WalletUtils(
walletAddresses.btc, walletAddresses.ltc, walletAddresses.eth,
walletAddresses.usdt, walletAddresses.usdc,
user.id, Date.now() - 30 * 24 * 60 * 60 * 1000
);
const balances = await walletUtilsInstance.getAllBalancesExt();
const walletTypeMap = { BTC: 'btc', LTC: 'ltc', ETH: 'eth', USDT: 'usdt', USDC: 'usdc' };
for (const [type, balance] of Object.entries(balances)) {
const address = walletAddresses[walletTypeMap[type]];
if (!address) continue;
const currentBalance = await db.getAsync(
'SELECT balance FROM crypto_wallets WHERE user_id = ? AND address = ?',
[user.id, address]
);
if (currentBalance?.balance !== balance.amount) {
await db.runAsync(
'UPDATE crypto_wallets SET balance = ? WHERE user_id = ? AND address = ?',
[balance.amount, user.id, address]
);
}
}
await UserService.recalculateUserBalanceByTelegramId(callbackQuery.from.id);
const BalanceHandler = (await import('./balanceHandler.js')).default;
await BalanceHandler.showBalance({
chat: { id: chatId }, from: { id: callbackQuery.from.id }
});
await bot.deleteMessage(chatId, messageId);
} catch (error) {
logger.error({ err: error }, 'Error in handleRefreshBalance');
await bot.answerCallbackQuery(callbackQuery.id, { text: t('wallet.error_refreshing_balances') });
await editOrSendCallback(callbackQuery, t('wallet.error_refreshing_balances_retry'), {
reply_markup: { inline_keyboard: [[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]] }
});
}
}
}

View File

@@ -1,85 +0,0 @@
import db from '../../../config/database.js';
import WalletUtils from '../../../utils/walletUtils.js';
import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { tForUser } from '../../../i18n/index.js';
export default class TopUpHandler {
static async handleTopUpWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en';
const t = tForUser(lang);
try {
if (!user) {
await editOrSendCallback(callbackQuery, t('wallet.profile_not_found'));
return;
}
const cryptoWallets = await db.allAsync(`
SELECT wallet_type, address, balance FROM crypto_wallets
WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#'
ORDER BY wallet_type
`, [user.id]);
if (cryptoWallets.length === 0) {
await bot.editMessageText(t('wallet.no_wallets'), {
chat_id: chatId, message_id: callbackQuery.message.message_id,
reply_markup: { inline_keyboard: [[{ text: t('purchase.add_wallet'), callback_data: 'add_wallet' }], [{ text: t('wallet.back'), callback_data: 'back_to_balance' }]] }
});
return;
}
const walletUtilsInstance = new WalletUtils(
cryptoWallets.find(w => w.wallet_type === 'BTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'LTC')?.address,
cryptoWallets.find(w => w.wallet_type === 'ETH')?.address,
cryptoWallets.find(w => w.wallet_type === 'USDT')?.address,
cryptoWallets.find(w => w.wallet_type === 'USDC')?.address,
user.id,
Date.now() - 30 * 24 * 60 * 60 * 1000
);
const balances = await walletUtilsInstance.getAllBalancesFromDB();
let message = `${t('wallet.your_wallets')}\n\n`;
for (const wallet of cryptoWallets) {
const balanceData = balances[wallet.wallet_type];
const amount = balanceData ? balanceData.amount.toFixed(8) : '0.00000000';
const usdValue = balanceData ? balanceData.usdValue.toFixed(2) : '0.00';
message += `🔐 *${wallet.wallet_type}*\n`;
message += `${t('wallet.balance')}: ${amount} ${wallet.wallet_type}\n`;
message += `${t('wallet.value')}: $${usdValue}\n`;
message += `${t('wallet.address')}: \`${wallet.address}\`\n\n`;
}
const walletButtons = cryptoWallets.map(w => ([{
text: t('wallet.deposit', { type: w.wallet_type }),
callback_data: `deposit_wallet_${w.wallet_type}`
}]));
const keyboard = {
inline_keyboard: [
[{ text: t('wallet.deposit_via_changenow'), callback_data: 'deposit_select_wallet' }],
...walletButtons,
[{ text: t('wallet.back'), callback_data: 'back_to_balance' }]
]
};
await bot.editMessageText(message, {
chat_id: chatId, message_id: callbackQuery.message.message_id,
parse_mode: 'Markdown',
reply_markup: keyboard
});
} catch (error) {
logger.error({ err: error }, 'Error in handleTopUpWallet');
await editOrSendCallback(callbackQuery, t('wallet.error_loading'));
}
}
}

View File

@@ -1,66 +0,0 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AVAILABLE_LANGUAGES = ['en', 'es', 'de'];
const LANGUAGE_NAMES = {
en: '🇬🇧 English',
es: '🇪🇸 Español',
de: '🇩🇪 Deutsch'
};
const DEFAULT_LOCALE = 'en';
let currentLocale = DEFAULT_LOCALE;
const locales = {};
for (const lang of AVAILABLE_LANGUAGES) {
const filePath = path.join(__dirname, 'locales', `${lang}.json`);
try {
const content = fs.readFileSync(filePath, 'utf-8');
locales[lang] = JSON.parse(content);
} catch (err) {
console.error(`[i18n] Failed to load locale "${lang}" from ${filePath}: ${err.message}. Using empty object as fallback.`);
locales[lang] = {};
}
}
function getNestedValue(obj, keyPath) {
return keyPath.split('.').reduce((o, k) => o?.[k], obj);
}
function t(key, params = {}) {
return tForLang(currentLocale, key, params);
}
function tForLang(lang, key, params = {}) {
let value = getNestedValue(locales[lang], key)
|| getNestedValue(locales[DEFAULT_LOCALE], key)
|| key;
if (typeof value === 'string') {
for (const [paramKey, paramValue] of Object.entries(params)) {
value = value.replace(new RegExp(`\\{\\{${paramKey}\\}\\}`, 'g'), paramValue);
}
}
return value;
}
function tForUser(lang) {
return (key, params = {}) => tForLang(lang || currentLocale, key, params);
}
function setLocale(lang) {
if (AVAILABLE_LANGUAGES.includes(lang)) {
currentLocale = lang;
}
}
function getLocale() {
return currentLocale;
}
export { t, tForLang, tForUser, setLocale, getLocale, AVAILABLE_LANGUAGES, LANGUAGE_NAMES };

View File

@@ -1,220 +0,0 @@
{
"bot": {
"welcome": "Willkommen im Shop! Wähle eine Option:",
"language_select": "🌍 Bitte wähle deine Sprache:",
"language_changed": "✅ Sprache geändert zu {{language}}!",
"error_generic": "Fehler bei der Verarbeitung. Bitte versuche es erneut.",
"account_blocked": "⚠️ Dein Konto wurde vom Administrator gesperrt",
"account_deleted": "⚠️ Dein Konto wurde vom Administrator gelöscht",
"contact_support": "Support kontaktieren"
},
"profile": {
"title": "👤 *Dein Profil*",
"not_found": "Profil nicht gefunden. Bitte verwende /start um eines zu erstellen.",
"telegram_id": "📱 Telegram ID",
"location": "📍 Standort",
"location_not_set": "Nicht festgelegt",
"stats": "📊 Statistiken:",
"total_purchases": "Gesammte Käufe",
"total_spent": "Gesamtausgaben",
"active_wallets": "Aktive Wallets",
"archived_wallets": "Archivierte Wallets",
"bonus_balance": "Bonus-Guthaben",
"available_balance": "Verfügbares Guthaben",
"member_since": "📅 Mitglied seit",
"set_location": "📍 Standort festlegen",
"change_language": "🌐 Sprache ändern",
"delete_account": "❌ Konto löschen",
"error_loading": "Fehler beim Laden des Profils. Bitte versuche es erneut."
},
"products": {
"select_country": "🌍 Wähle dein Land:",
"select_city": "🏙 Wähle eine Stadt in {{country}}:",
"select_district": "📍 Wähle einen Bezirk in {{city}}:",
"select_category": "📦 Wähle eine Kategorie:",
"select_product": "Wähle ein Produkt:",
"no_products": "Aktuell keine Produkte verfügbar.",
"no_products_category": "Keine Produkte in dieser Kategorie.",
"no_products_subcategory": "Keine Produkte in dieser Unterkategorie.",
"back_to_countries": "« Zurück zu den Ländern",
"back_to_cities": "« Zurück zu den Städten",
"back_to_subcategories": "« Zurück zu den Unterkategorien",
"back": "« Zurück",
"product_price": "💰 Preis",
"product_description": "📝 Beschreibung",
"product_available": "📦 Verfügbar",
"product_category": "Kategorie",
"buy_now": "🛒 Jetzt kaufen",
"increase": "",
"decrease": "",
"products_in": "📦 Produkte in {{name}}:",
"error_loading": "Fehler beim Laden der Produkte. Bitte versuche es erneut.",
"error_loading_cities": "Fehler beim Laden der Städte. Bitte versuche es erneut.",
"error_loading_districts": "Fehler beim Laden der Bezirke. Bitte versuche es erneut.",
"error_loading_categories": "Fehler beim Laden der Kategorien. Bitte versuche es erneut.",
"error_loading_product": "Fehler beim Laden der Produktdetails. Bitte versuche es erneut.",
"not_found": "Standort nicht gefunden. Zurück zum vorherigen Menü."
},
"purchase": {
"summary": "🛒 Kaufübersicht:",
"product": "Produkt",
"quantity": "Menge",
"total": "Gesamt",
"pay": "Bezahlen",
"cancel": "« Abbrechen",
"insufficient_balance": "❌ Nicht genug Guthaben. Dein aktuelles Guthaben: ${{balance}}. Du benötigst: ${{total}}.",
"need_wallet": "Du musst zuerst ein Krypto-Wallet hinzufügen, um Käufe zu tätigen.",
"add_wallet": " Wallet hinzufügen",
"top_up_balance": "💰 Guthaben aufladen",
"not_enough_stock": "❌ Nicht genug auf Lager. Nur {{count}} verfügbar.",
"not_enough_money": "Nicht genug Guthaben",
"details": "📦 Kaufdetails:",
"location": "Standort",
"category": "Kategorie",
"private_info": "🔒 Private Informationen:",
"hidden_location": "Versteckter Standort",
"coordinates": "Koordinaten",
"view_purchase": "Kauf ansehen",
"confirm_received": "Erhalten!",
"back_to_list": "« Zurück zur Kaufübersicht",
"history_empty": "Dein Kaufverlauf ist leer.",
"browse_products": "🛍 Produkte durchsuchen",
"select_purchase": "📦 Wähle einen Kauf für Details (Seite {{page}} von {{total}}):",
"page_back": "« Zurück (Seite {{page}})",
"page_next": "Weiter » (Seite {{page}})",
"page_info": "Seite {{current}} von {{total}}",
"no_such_purchase": "Kauf nicht gefunden",
"no_such_product": "Produkt nicht gefunden",
"purchase_received": "Danke! Dein Kauf wurde als erhalten markiert.",
"admin_notification": "Benutzer {{username}} hat den Erhalt von Kauf #{{purchaseId}} bestätigt.",
"error_loading": "Fehler beim Laden des Kaufverlaufs. Bitte versuche es erneut.",
"error_loading_details": "Fehler beim Laden der Kaufdetails. Bitte versuche es erneut.",
"error_confirming": "Fehler bei der Empfangsbestätigung. Bitte versuche es erneut.",
"error_processing": "Fehler bei der Kaufabwicklung. Bitte versuche es erneut.",
"invalid_wallet": "Ungültiger Wallet-Typ.",
"invalid_product": "Ungültiges Produkt.",
"invalid_quantity": "Ungültige Menge."
},
"wallet": {
"your_wallets": "💰 *Deine Wallets*",
"your_active_wallets": "💰 *Deine aktiven Wallets:*",
"balance": "Guthaben",
"value": "Wert",
"address": "Adresse",
"network": "Netzwerk",
"deposit": "💳 {{type}} aufladen",
"deposit_via_changenow": "💳 Über ChangeNOW aufladen",
"select_crypto": "🔐 Wähle eine Kryptowährung zum Wallet erstellen:",
"wallet_generated": "✅ Neues Wallet erfolgreich erstellt!",
"wallet_type": "Typ",
"previous_archived": " Dein vorheriges Wallet wurde archiviert.",
"recovery_stored": "⚠️ Wichtig: Deine Wiederherstellungsphrase wurde sicher gespeichert.",
"error_generating": "❌ Fehler beim Wallet erstellen. Bitte versuche es erneut.",
"no_wallets": "Du hast noch keine Wallets. Erstelle zuerst eines.",
"no_wallets_prefix": "❌ Du hast noch keine Wallets. Erstelle zuerst eines.",
"no_active_wallets": "Du hast keine aktiven Wallets.",
"back_to_balance": "« Zurück zum Guthaben",
"back": "« Zurück",
"invalid_wallet_type": "Ungültiger Wallet-Typ.",
"user_not_found": "Benutzer nicht gefunden.",
"profile_not_found": "Profil nicht gefunden. Bitte verwende /start.",
"profile_not_found_short": "Profil nicht gefunden.",
"error_loading": "Fehler beim Laden der Wallets. Bitte versuche es erneut.",
"error_loading_balance": "Fehler beim Laden des Guthabens. Bitte versuche es erneut.",
"error_loading_archived": "Fehler beim Laden der archivierten Wallets. Bitte versuche es erneut.",
"error_loading_history": "Fehler beim Laden der Transaktionshistorie. Bitte versuche es erneut.",
"error_refreshing_balances": "❌ Fehler beim Aktualisieren der Guthaben.",
"error_refreshing_balances_retry": "❌ Fehler beim Aktualisieren der Guthaben. Bitte versuche es erneut.",
"error_deposit_instructions": "Fehler beim Erstellen der Einzahlungsanleitung. Bitte versuche es erneut.",
"error_copying_address": "Fehler beim Kopieren der Adresse.",
"add_crypto_wallet": " Krypto-Wallet hinzufügen",
"top_up": "💸 Aufladen",
"refresh_balance": "🔄 Guthaben aktualisieren",
"refreshing_balances": "🔄 Guthaben werden aktualisiert...",
"archived_wallets_count": "📁 Archivierte Wallets ({{count}})",
"archived_wallets_title": "📁 *Archivierte Wallets:*",
"no_archived_wallets": "Keine archivierten Wallets gefunden.",
"archived_date": "Archiviert",
"total_crypto_balance": "📊 *Gesamtes Krypto-Guthaben:*",
"bonus_balance_label": "🎁 *Bonus-Guthaben:*",
"available_balance_label": "💰 *Verfügbares Guthaben:*",
"total_type": "📊 *Gesamt {{type}}:*",
"amount": "Betrag",
"total_archived_value": "💰 *Gesamtwert der archivierten Wallets:*",
"transaction_history": "📊 Transaktionshistorie",
"transaction_history_title": "📊 *Transaktionshistorie:*",
"recent_transactions": "📊 *Letzte Transaktionen:*",
"no_transactions": "Keine Transaktionen gefunden.",
"tx_amount": "💰 Betrag",
"tx_hash": "🔗 TX-Hash",
"tx_date": "🕒 Datum",
"tx_wallet_type": "💼 Wallet-Typ",
"previous_page": "⬅️ Zurück",
"next_page": "➡️ Weiter",
"wallet_not_found": "Wallet nicht gefunden. Bitte versuche es erneut.",
"wallet_not_found_short": "Wallet nicht gefunden.",
"deposit_changenow_select": "💳 *Einzahlung über ChangeNOW*\n\nWähle das Wallet zum Aufladen:",
"deposit_select_amount": "💳 *{{type}} aufladen*\n\nWähle den Betrag (USD) zum Aufladen:",
"deposit_title": "💳 *{{type}} aufladen — €{{amount}}*",
"deposit_instructions_title": "📋 *Schritt-für-Schritt-Anleitung:*",
"deposit_step1": "1⃣ Tippe auf *Adresse kopieren* unten, um deine Wallet-Adresse zu kopieren",
"deposit_step2": "2⃣ Tippe auf *ChangeNOW öffnen* \\— Betrag €{{amount}} und Währung {{type}} sind bereits eingestellt",
"deposit_step3": "3⃣ Füge auf ChangeNOW die kopierte Adresse als Empfangswallet ein",
"deposit_step4": "4⃣ Gib deine E-Mail ein und erstelle ein Passwort \\— dies ist *gesetzlich vorgeschrieben* für Kartenzahlungen \\(KYC-Verifizierung\\)\\. Deine Daten sind durch ChangeNOWs Sicherheit geschützt",
"deposit_step5": "5⃣ Bezahle mit deiner Bankkarte \\(Visa\\/Mastercard\\)",
"deposit_step6": "6⃣ Die Kryptowährung wird innerhalb von 5\\-30 Minuten in deinem Wallet eintreffen",
"deposit_your_address": "🔐 *Deine {{type}} Wallet-Adresse:*",
"deposit_important_title": "⚠️ *Wichtig:*",
"deposit_important1": "• Überprüfe die Wallet-Adresse doppelt vor der Bestätigung",
"deposit_important2": "• E-Mail \\+ Passwort auf ChangeNOW ist ein Standard-Verifizierungsschritt für Kartenzahlungen \\— keine Sorge\\, es ist sicher",
"deposit_important3": "• Wenn die Kryptowährung nicht innerhalb von 30 Minuten eintrifft \\— prüfe den Transaktionsstatus in deiner ChangeNOW E-Mail-Bestätigung",
"deposit_open_changenow": "🌐 ChangeNOW öffnen — €{{amount}} → {{type}}",
"deposit_copy_address": "📋 Adresse kopieren",
"deposit_change_amount": "🔄 Betrag ändern",
"deposit_choose_different": "💸 Anderes Wallet wählen",
"deposit_wallet_address": "{{type}} Wallet-Adresse:",
"back_to_deposit": "« Zurück zur Einzahlung",
"deposit_address_sent": "📋 {{type}}-Adresse gesendet! Kopiere sie aus der Nachricht unten.",
"network_erc20": "Ethereum-Netzwerk (ERC-20)",
"network_btc": "Bitcoin-Netzwerk",
"network_ltc": "Litecoin-Netzwerk",
"network_eth": "Ethereum-Netzwerk",
"network_unknown": "Unbekanntes Netzwerk"
},
"location": {
"select_country": "🌍 Wähle dein Land:",
"select_city": "🏙 Wähle eine Stadt in {{country}}:",
"select_district": "📍 Wähle einen Bezirk in {{city}}:",
"no_locations": "Noch keine Standorte verfügbar.",
"back_to_profile": "« Zurück zum Profil",
"back_to_countries": "« Zurück zu den Ländern",
"location_updated": "✅ Standort erfolgreich aktualisiert!",
"country": "Land",
"city": "Stadt",
"district": "Bezirk",
"error_loading_countries": "Fehler beim Laden der Länder. Bitte versuche es erneut.",
"error_loading_cities": "Fehler beim Laden der Städte. Bitte versuche es erneut.",
"error_loading_districts": "Fehler beim Laden der Bezirke. Bitte versuche es erneut.",
"error_updating": "Fehler beim Aktualisieren des Standorts. Bitte versuche es erneut."
},
"deletion": {
"confirm_title": "⚠️ Bist du sicher, dass du dein Konto löschen möchtest?",
"confirm_body": "Diese Aktion:\n- Löscht alle Benutzerdaten\n- Entfernt alle Wallets\n- Löscht den Kaufverlauf\n\nDiese Aktion kann nicht rückgängig gemacht werden!",
"confirm_button": "✅ Löschung bestätigen",
"cancel_button": "❌ Abbrechen",
"deleted": "⚠️ Dein Konto wurde erfolgreich gelöscht",
"error_processing": "Fehler bei der Löschanfrage. Bitte versuche es erneut.",
"error_deleting": "Fehler beim Löschen des Benutzers. Bitte versuche es erneut."
},
"keyboard": {
"products": "📦 Produkte",
"profile": "👤 Profil",
"purchases": "🛍 Käufe",
"wallets": "💰 Wallets",
"manage_products": "📦 Produkte verwalten",
"manage_users": "👥 Benutzer verwalten",
"manage_locations": "📍 Standorte verwalten",
"database_backup": "💾 Datenbank-Backup",
"manage_wallets": "💰 Wallets verwalten"
}
}

View File

@@ -1,220 +0,0 @@
{
"bot": {
"welcome": "Welcome to the shop! Choose an option:",
"language_select": "🌍 Please select your language:",
"language_changed": "✅ Language changed to {{language}}!",
"error_generic": "Error processing request. Please try again.",
"account_blocked": "⚠️ Your account has been blocked by administrator",
"account_deleted": "⚠️ Your account has been deleted by administrator",
"contact_support": "Contact support"
},
"profile": {
"title": "👤 *Your Profile*",
"not_found": "Profile not found. Please use /start to create one.",
"telegram_id": "📱 Telegram ID",
"location": "📍 Location",
"location_not_set": "Not set",
"stats": "📊 Statistics:",
"total_purchases": "Total Purchases",
"total_spent": "Total Spent",
"active_wallets": "Active Wallets",
"archived_wallets": "Archived Wallets",
"bonus_balance": "Bonus Balance",
"available_balance": "Available Balance",
"member_since": "📅 Member since",
"set_location": "📍 Set Location",
"change_language": "🌐 Change Language",
"delete_account": "❌ Delete Account",
"error_loading": "Error loading profile. Please try again."
},
"products": {
"select_country": "🌍 Select your country:",
"select_city": "🏙 Select city in {{country}}:",
"select_district": "📍 Select district in {{city}}:",
"select_category": "📦 Select category:",
"select_product": "Select a product:",
"no_products": "No products available at the moment.",
"no_products_category": "No products available in this category.",
"no_products_subcategory": "No products available in this subcategory.",
"back_to_countries": "« Back to Countries",
"back_to_cities": "« Back to Cities",
"back_to_subcategories": "« Back to Subcategories",
"back": "« Back",
"product_price": "💰 Price",
"product_description": "📝 Description",
"product_available": "📦 Available",
"product_category": "Category",
"buy_now": "🛒 Buy Now",
"increase": "",
"decrease": "",
"products_in": "📦 Products in {{name}}:",
"error_loading": "Error loading products. Please try again.",
"error_loading_cities": "Error loading cities. Please try again.",
"error_loading_districts": "Error loading districts. Please try again.",
"error_loading_categories": "Error loading categories. Please try again.",
"error_loading_product": "Error loading product details. Please try again.",
"not_found": "Location not found. Returning to previous menu."
},
"purchase": {
"summary": "🛒 Purchase Summary:",
"product": "Product",
"quantity": "Quantity",
"total": "Total",
"pay": "Pay",
"cancel": "« Cancel",
"insufficient_balance": "❌ Insufficient balance. Your current balance is ${{balance}}. You need ${{total}} to complete this purchase.",
"need_wallet": "You need to add a crypto wallet first to make purchases.",
"add_wallet": " Add Wallet",
"top_up_balance": "💰 Top Up Balance",
"not_enough_stock": "❌ Not enough items in stock. Only {{count}} available.",
"not_enough_money": "Not enough money",
"details": "📦 Purchase Details:",
"location": "Location",
"category": "Category",
"private_info": "🔒 Private Information:",
"hidden_location": "Hidden Location",
"coordinates": "Coordinates",
"view_purchase": "View new purchase",
"confirm_received": "I've got it!",
"back_to_list": "« Back to Purchase List",
"history_empty": "Your purchase history is empty.",
"browse_products": "🛍 Browse Products",
"select_purchase": "📦 Select purchase to view detailed information (Page {{page}} of {{total}}):",
"page_back": "« Back (Page {{page}})",
"page_next": "Next » (Page {{page}})",
"page_info": "Page {{current}} of {{total}}",
"no_such_purchase": "No such purchase",
"no_such_product": "No such product",
"purchase_received": "Thank you! Your purchase has been marked as received.",
"admin_notification": "User {{username}} has confirmed receiving purchase #{{purchaseId}}.",
"error_loading": "Error loading purchase history. Please try again.",
"error_loading_details": "Error loading purchase details. Please try again.",
"error_confirming": "Error confirming receipt. Please try again.",
"error_processing": "Error processing purchase. Please try again.",
"invalid_wallet": "Invalid wallet type.",
"invalid_product": "Invalid product.",
"invalid_quantity": "Invalid quantity."
},
"wallet": {
"your_wallets": "💰 *Your Wallets*",
"your_active_wallets": "💰 *Your Active Wallets:*",
"balance": "Balance",
"value": "Value",
"address": "Address",
"network": "Network",
"deposit": "💳 Deposit {{type}}",
"deposit_via_changenow": "💳 Deposit via ChangeNOW",
"select_crypto": "🔐 Select cryptocurrency to generate wallet:",
"wallet_generated": "✅ New wallet generated successfully!",
"wallet_type": "Type",
"previous_archived": " Your previous wallet has been archived.",
"recovery_stored": "⚠️ Important: Your recovery phrase has been securely stored.",
"error_generating": "❌ Error generating wallet. Please try again.",
"no_wallets": "You don't have any wallets yet. Create one first.",
"no_wallets_prefix": "❌ You don't have any wallets yet. Create one first.",
"no_active_wallets": "You don't have any active wallets yet.",
"back_to_balance": "« Back to Balance",
"back": "« Back",
"invalid_wallet_type": "Invalid wallet type.",
"user_not_found": "User not found.",
"profile_not_found": "Profile not found. Please use /start to create one.",
"profile_not_found_short": "Profile not found.",
"error_loading": "Error loading wallets. Please try again.",
"error_loading_balance": "Error loading balance. Please try again.",
"error_loading_archived": "Error loading archived wallets. Please try again.",
"error_loading_history": "Error loading transaction history. Please try again.",
"error_refreshing_balances": "❌ Error refreshing balances.",
"error_refreshing_balances_retry": "❌ Error refreshing balances. Please try again.",
"error_deposit_instructions": "Error creating deposit instructions. Please try again.",
"error_copying_address": "Error copying address.",
"add_crypto_wallet": " Add Crypto Wallet",
"top_up": "💸 Top Up",
"refresh_balance": "🔄 Refresh Balance",
"refreshing_balances": "🔄 Refreshing balances...",
"archived_wallets_count": "📁 Archived Wallets ({{count}})",
"archived_wallets_title": "📁 *Archived Wallets:*",
"no_archived_wallets": "No archived wallets found.",
"archived_date": "Archived",
"total_crypto_balance": "📊 *Total Crypto Balance:*",
"bonus_balance_label": "🎁 *Bonus Balance:*",
"available_balance_label": "💰 *Available Balance:*",
"total_type": "📊 *Total {{type}}:*",
"amount": "Amount",
"total_archived_value": "💰 *Total Value of Archived Wallets:*",
"transaction_history": "📊 Transaction History",
"transaction_history_title": "📊 *Transaction History:*",
"recent_transactions": "📊 *Recent Transactions:*",
"no_transactions": "No transactions found.",
"tx_amount": "💰 Amount",
"tx_hash": "🔗 TX Hash",
"tx_date": "🕒 Date",
"tx_wallet_type": "💼 Wallet Type",
"previous_page": "⬅️ Previous",
"next_page": "➡️ Next",
"wallet_not_found": "Wallet not found. Please try again.",
"wallet_not_found_short": "Wallet not found.",
"deposit_changenow_select": "💳 *Deposit via ChangeNOW*\n\nSelect the wallet you want to top up:",
"deposit_select_amount": "💳 *Deposit {{type}}*\n\nSelect the amount (USD) you want to deposit:",
"deposit_title": "💳 *Deposit {{type}} — €{{amount}}*",
"deposit_instructions_title": "📋 *Step\\-by\\-step instructions:*",
"deposit_step1": "1⃣ Tap *Copy Address* below to copy your wallet address",
"deposit_step2": "2⃣ Tap *Open ChangeNOW* \\— the amount €{{amount}} and currency {{type}} are already set",
"deposit_step3": "3⃣ On ChangeNOW\\, paste the copied address as the receiving wallet",
"deposit_step4": "4⃣ Enter your email and create a password when prompted \\— this is *required by law* for card payments \\(KYC verification\\)\\. Your data is protected by ChangeNOW\\'s security",
"deposit_step5": "5⃣ Pay with your bank card \\(Visa\\/Mastercard\\)",
"deposit_step6": "6⃣ Crypto will arrive in your wallet within 5\\-30 minutes",
"deposit_your_address": "🔐 *Your {{type}} wallet address:*",
"deposit_important_title": "⚠️ *Important:*",
"deposit_important1": "• Double\\-check the wallet address before confirming",
"deposit_important2": "• Email \\+ password on ChangeNOW is a standard verification step for card payments \\— don\\'t worry\\, it\\'s safe",
"deposit_important3": "• If crypto doesn\\'t arrive within 30 min \\— check the transaction status in your ChangeNOW email confirmation",
"deposit_open_changenow": "🌐 Open ChangeNOW — €{{amount}} → {{type}}",
"deposit_copy_address": "📋 Copy Address",
"deposit_change_amount": "🔄 Change Amount",
"deposit_choose_different": "💸 Choose Different Wallet",
"deposit_wallet_address": "{{type}} wallet address:",
"back_to_deposit": "« Back to Deposit",
"deposit_address_sent": "📋 {{type}} address sent! Copy it from the message below.",
"network_erc20": "Ethereum Network (ERC-20)",
"network_btc": "Bitcoin Network",
"network_ltc": "Litecoin Network",
"network_eth": "Ethereum Network",
"network_unknown": "Unknown Network"
},
"location": {
"select_country": "🌍 Select your country:",
"select_city": "🏙 Select city in {{country}}:",
"select_district": "📍 Select district in {{city}}:",
"no_locations": "No locations available yet.",
"back_to_profile": "« Back to Profile",
"back_to_countries": "« Back to Countries",
"location_updated": "✅ Location updated successfully!",
"country": "Country",
"city": "City",
"district": "District",
"error_loading_countries": "Error loading countries. Please try again.",
"error_loading_cities": "Error loading cities. Please try again.",
"error_loading_districts": "Error loading districts. Please try again.",
"error_updating": "Error updating location. Please try again."
},
"deletion": {
"confirm_title": "⚠️ Are you sure you want to delete your account?",
"confirm_body": "This action will:\n- Delete all user data\n- Remove all wallets\n- Erase purchase history\n\nThis action cannot be undone!",
"confirm_button": "✅ Confirm Delete",
"cancel_button": "❌ Cancel",
"deleted": "⚠️ Your account has been successfully deleted",
"error_processing": "Error processing delete request. Please try again.",
"error_deleting": "Error deleting user. Please try again."
},
"keyboard": {
"products": "📦 Products",
"profile": "👤 Profile",
"purchases": "🛍 Purchases",
"wallets": "💰 Wallets",
"manage_products": "📦 Manage Products",
"manage_users": "👥 Manage Users",
"manage_locations": "📍 Manage Locations",
"database_backup": "💾 Database Backup",
"manage_wallets": "💰 Manage Wallets"
}
}

View File

@@ -1,220 +0,0 @@
{
"bot": {
"welcome": "¡Bienvenido a la tienda! Elige una opción:",
"language_select": "🌍 Por favor, selecciona tu idioma:",
"language_changed": "✅ ¡Idioma cambiado a {{language}}!",
"error_generic": "Error al procesar la solicitud. Inténtalo de nuevo.",
"account_blocked": "⚠️ Tu cuenta ha sido bloqueada por el administrador",
"account_deleted": "⚠️ Tu cuenta ha sido eliminada por el administrador",
"contact_support": "Contactar soporte"
},
"profile": {
"title": "👤 *Tu Perfil*",
"not_found": "Perfil no encontrado. Usa /start para crear uno.",
"telegram_id": "📱 Telegram ID",
"location": "📍 Ubicación",
"location_not_set": "No establecida",
"stats": "📊 Estadísticas:",
"total_purchases": "Compras totales",
"total_spent": "Total gastado",
"active_wallets": "Billeteras activas",
"archived_wallets": "Billeteras archivadas",
"bonus_balance": "Saldo de bonificación",
"available_balance": "Saldo disponible",
"member_since": "📅 Miembro desde",
"set_location": "📍 Establecer ubicación",
"change_language": "🌐 Cambiar idioma",
"delete_account": "❌ Eliminar cuenta",
"error_loading": "Error al cargar perfil. Inténtalo de nuevo."
},
"products": {
"select_country": "🌍 Selecciona tu país:",
"select_city": "🏙 Selecciona ciudad en {{country}}:",
"select_district": "📍 Selecciona distrito en {{city}}:",
"select_category": "📦 Selecciona categoría:",
"select_product": "Selecciona un producto:",
"no_products": "No hay productos disponibles en este momento.",
"no_products_category": "No hay productos disponibles en esta categoría.",
"no_products_subcategory": "No hay productos disponibles en esta subcategoría.",
"back_to_countries": "« Volver a países",
"back_to_cities": "« Volver a ciudades",
"back_to_subcategories": "« Volver a subcategorías",
"back": "« Volver",
"product_price": "💰 Precio",
"product_description": "📝 Descripción",
"product_available": "📦 Disponibles",
"product_category": "Categoría",
"buy_now": "🛒 Comprar ahora",
"increase": "",
"decrease": "",
"products_in": "📦 Productos en {{name}}:",
"error_loading": "Error al cargar productos. Inténtalo de nuevo.",
"error_loading_cities": "Error al cargar ciudades. Inténtalo de nuevo.",
"error_loading_districts": "Error al cargar distritos. Inténtalo de nuevo.",
"error_loading_categories": "Error al cargar categorías. Inténtalo de nuevo.",
"error_loading_product": "Error al cargar detalles del producto. Inténtalo de nuevo.",
"not_found": "Ubicación no encontrada. Volviendo al menú anterior."
},
"purchase": {
"summary": "🛒 Resumen de compra:",
"product": "Producto",
"quantity": "Cantidad",
"total": "Total",
"pay": "Pagar",
"cancel": "« Cancelar",
"insufficient_balance": "❌ Saldo insuficiente. Tu saldo actual es ${{balance}}. Necesitas ${{total}} para completar esta compra.",
"need_wallet": "Necesitas agregar una billetera cripto primero para hacer compras.",
"add_wallet": " Agregar billetera",
"top_up_balance": "💰 Recargar saldo",
"not_enough_stock": "❌ No hay suficientes unidades en stock. Solo {{count}} disponibles.",
"not_enough_money": "Dinero insuficiente",
"details": "📦 Detalles de la compra:",
"location": "Ubicación",
"category": "Categoría",
"private_info": "🔒 Información privada:",
"hidden_location": "Ubicación oculta",
"coordinates": "Coordenadas",
"view_purchase": "Ver nueva compra",
"confirm_received": "¡Lo recibí!",
"back_to_list": "« Volver a la lista de compras",
"history_empty": "Tu historial de compras está vacío.",
"browse_products": "🛍 Ver productos",
"select_purchase": "📦 Selecciona una compra para ver detalles (Página {{page}} de {{total}}):",
"page_back": "« Anterior (Pág. {{page}})",
"page_next": "Siguiente » (Pág. {{page}})",
"page_info": "Pág. {{current}} de {{total}}",
"no_such_purchase": "No existe esa compra",
"no_such_product": "No existe ese producto",
"purchase_received": "¡Gracias! Tu compra ha sido marcada como recibida.",
"admin_notification": "El usuario {{username}} ha confirmado recibir la compra #{{purchaseId}}.",
"error_loading": "Error al cargar historial de compras. Inténtalo de nuevo.",
"error_loading_details": "Error al cargar detalles de la compra. Inténtalo de nuevo.",
"error_confirming": "Error al confirmar recepción. Inténtalo de nuevo.",
"error_processing": "Error al procesar la compra. Inténtalo de nuevo.",
"invalid_wallet": "Tipo de billetera inválido.",
"invalid_product": "Producto inválido.",
"invalid_quantity": "Cantidad inválida."
},
"wallet": {
"your_wallets": "💰 *Tus billeteras*",
"your_active_wallets": "💰 *Tus billeteras activas:*",
"balance": "Saldo",
"value": "Valor",
"address": "Dirección",
"network": "Red",
"deposit": "💳 Depositar {{type}}",
"deposit_via_changenow": "💳 Depositar vía ChangeNOW",
"select_crypto": "🔐 Selecciona criptomoneda para generar billetera:",
"wallet_generated": "✅ ¡Nueva billetera generada exitosamente!",
"wallet_type": "Tipo",
"previous_archived": " Tu billetera anterior ha sido archivada.",
"recovery_stored": "⚠️ Importante: Tu frase de recuperación ha sido almacenada de forma segura.",
"error_generating": "❌ Error al generar billetera. Inténtalo de nuevo.",
"no_wallets": "Aún no tienes billeteras. Crea una primero.",
"no_wallets_prefix": "❌ Aún no tienes billeteras. Crea una primero.",
"no_active_wallets": "Aún no tienes billeteras activas.",
"back_to_balance": "« Volver al saldo",
"back": "« Volver",
"invalid_wallet_type": "Tipo de billetera inválido.",
"user_not_found": "Usuario no encontrado.",
"profile_not_found": "Perfil no encontrado. Usa /start para crear uno.",
"profile_not_found_short": "Perfil no encontrado.",
"error_loading": "Error al cargar billeteras. Inténtalo de nuevo.",
"error_loading_balance": "Error al cargar saldo. Inténtalo de nuevo.",
"error_loading_archived": "Error al cargar billeteras archivadas. Inténtalo de nuevo.",
"error_loading_history": "Error al cargar historial de transacciones. Inténtalo de nuevo.",
"error_refreshing_balances": "❌ Error al actualizar saldos.",
"error_refreshing_balances_retry": "❌ Error al actualizar saldos. Inténtalo de nuevo.",
"error_deposit_instructions": "Error al crear instrucciones de depósito. Inténtalo de nuevo.",
"error_copying_address": "Error al copiar dirección.",
"add_crypto_wallet": " Agregar billetera cripto",
"top_up": "💸 Recargar",
"refresh_balance": "🔄 Actualizar saldo",
"refreshing_balances": "🔄 Actualizando saldos...",
"archived_wallets_count": "📁 Billeteras archivadas ({{count}})",
"archived_wallets_title": "📁 *Billeteras archivadas:*",
"no_archived_wallets": "No se encontraron billeteras archivadas.",
"archived_date": "Archivada",
"total_crypto_balance": "📊 *Saldo total cripto:*",
"bonus_balance_label": "🎁 *Saldo de bonificación:*",
"available_balance_label": "💰 *Saldo disponible:*",
"total_type": "📊 *Total {{type}}:*",
"amount": "Cantidad",
"total_archived_value": "💰 *Valor total de billeteras archivadas:*",
"transaction_history": "📊 Historial de transacciones",
"transaction_history_title": "📊 *Historial de transacciones:*",
"recent_transactions": "📊 *Transacciones recientes:*",
"no_transactions": "No se encontraron transacciones.",
"tx_amount": "💰 Cantidad",
"tx_hash": "🔗 TX Hash",
"tx_date": "🕒 Fecha",
"tx_wallet_type": "💼 Tipo de billetera",
"previous_page": "⬅️ Anterior",
"next_page": "➡️ Siguiente",
"wallet_not_found": "Billetera no encontrada. Inténtalo de nuevo.",
"wallet_not_found_short": "Billetera no encontrada.",
"deposit_changenow_select": "💳 *Depositar vía ChangeNOW*\n\nSelecciona la billetera que quieres recargar:",
"deposit_select_amount": "💳 *Depositar {{type}}*\n\nSelecciona la cantidad (USD) que quieres depositar:",
"deposit_title": "💳 *Depositar {{type}} — €{{amount}}*",
"deposit_instructions_title": "📋 *Instrucciones paso a paso:*",
"deposit_step1": "1⃣ Toca *Copiar dirección* abajo para copiar tu dirección de billetera",
"deposit_step2": "2⃣ Toca *Abrir ChangeNOW* \\— la cantidad €{{amount}} y la moneda {{type}} ya están configuradas",
"deposit_step3": "3⃣ En ChangeNOW\\, pega la dirección copiada como billetera receptora",
"deposit_step4": "4⃣ Ingresa tu correo y crea una contraseña cuando se solicite \\— esto es *requerido por ley* para pagos con tarjeta \\(verificación KYC\\)\\. Tus datos están protegidos por la seguridad de ChangeNOW",
"deposit_step5": "5⃣ Paga con tu tarjeta bancaria \\(Visa\\/Mastercard\\)",
"deposit_step6": "6⃣ Las criptomonedas llegarán a tu billetera en 5\\-30 minutos",
"deposit_your_address": "🔐 *Tu dirección de billetera {{type}}:*",
"deposit_important_title": "⚠️ *Importante:*",
"deposit_important1": "• Verifica dos veces la dirección de la billetera antes de confirmar",
"deposit_important2": "• Correo \\+ contraseña en ChangeNOW es un paso de verificación estándar para pagos con tarjeta \\— no te preocupes\\, es seguro",
"deposit_important3": "• Si las criptomonedas no llegan en 30 min \\— verifica el estado de la transacción en tu confirmación por correo de ChangeNOW",
"deposit_open_changenow": "🌐 Abrir ChangeNOW — €{{amount}} → {{type}}",
"deposit_copy_address": "📋 Copiar dirección",
"deposit_change_amount": "🔄 Cambiar cantidad",
"deposit_choose_different": "💸 Elegir otra billetera",
"deposit_wallet_address": "Dirección de billetera {{type}}:",
"back_to_deposit": "« Volver al depósito",
"deposit_address_sent": "📋 ¡Dirección {{type}} enviada! Cópiala del mensaje de abajo.",
"network_erc20": "Red Ethereum (ERC-20)",
"network_btc": "Red Bitcoin",
"network_ltc": "Red Litecoin",
"network_eth": "Red Ethereum",
"network_unknown": "Red desconocida"
},
"location": {
"select_country": "🌍 Selecciona tu país:",
"select_city": "🏙 Selecciona ciudad en {{country}}:",
"select_district": "📍 Selecciona distrito en {{city}}:",
"no_locations": "No hay ubicaciones disponibles aún.",
"back_to_profile": "« Volver al perfil",
"back_to_countries": "« Volver a países",
"location_updated": "✅ ¡Ubicación actualizada exitosamente!",
"country": "País",
"city": "Ciudad",
"district": "Distrito",
"error_loading_countries": "Error al cargar países. Inténtalo de nuevo.",
"error_loading_cities": "Error al cargar ciudades. Inténtalo de nuevo.",
"error_loading_districts": "Error al cargar distritos. Inténtalo de nuevo.",
"error_updating": "Error al actualizar ubicación. Inténtalo de nuevo."
},
"deletion": {
"confirm_title": "⚠️ ¿Estás seguro de que quieres eliminar tu cuenta?",
"confirm_body": "Esta acción:\n- Eliminará todos los datos de usuario\n- Eliminará todas las billeteras\n- Borrará el historial de compras\n\n¡Esta acción no se puede deshacer!",
"confirm_button": "✅ Confirmar eliminación",
"cancel_button": "❌ Cancelar",
"deleted": "⚠️ Tu cuenta ha sido eliminada exitosamente",
"error_processing": "Error al procesar la solicitud de eliminación. Inténtalo de nuevo.",
"error_deleting": "Error al eliminar usuario. Inténtalo de nuevo."
},
"keyboard": {
"products": "📦 Productos",
"profile": "👤 Perfil",
"purchases": "🛍 Compras",
"wallets": "💰 Billeteras",
"manage_products": "📦 Gestionar productos",
"manage_users": "👥 Gestionar usuarios",
"manage_locations": "📍 Gestionar ubicaciones",
"database_backup": "💾 Respaldo de base de datos",
"manage_wallets": "💰 Gestionar billeteras"
}
}

View File

@@ -1,25 +1,33 @@
import 'dotenv/config'; import adminUserHandler from './handlers/adminHandlers/adminUserHandler.js';
import { runMigrations, cleanUpInvalidForeignKeys } from './migrations/runner.js';
import { registerRoutes } from './router/routes.js';
import bot, { botAvailable } from './context/bot.js';
import ErrorHandler from './utils/errorHandler.js'; import ErrorHandler from './utils/errorHandler.js';
import logger from './utils/logger.js'; import bot from "./context/bot.js";
import userHandler from './handlers/userHandlers/userHandler.js'; import userHandler from "./handlers/userHandlers/userHandler.js";
import adminHandler from './handlers/adminHandlers/adminHandler.js'; import userPurchaseHandler from "./handlers/userHandlers/userPurchaseHandler.js";
import callbackRouter from './router/callbackRouter.js'; import userLocationHandler from "./handlers/userHandlers/userLocationHandler.js";
import messageRouter from './router/messageRouter.js'; import userProductHandler from "./handlers/userHandlers/userProductHandler.js";
import userWalletsHandler from "./handlers/userHandlers/userWalletsHandler.js";
import adminHandler from "./handlers/adminHandlers/adminHandler.js";
import adminUserLocationHandler from "./handlers/adminHandlers/adminUserLocationHandler.js";
import adminDumpHandler from "./handlers/adminHandlers/adminDumpHandler.js";
import adminLocationHandler from "./handlers/adminHandlers/adminLocationHandler.js";
import adminProductHandler from "./handlers/adminHandlers/adminProductHandler.js";
import { initStates } from './services/stateService.js'; // Debug logging function
const logDebug = (action, functionName) => {
console.log(`[DEBUG] Button Press: ${action}`);
console.log(`[DEBUG] Calling Function: ${functionName}`);
};
await runMigrations(); // Start command - Create user profile
await cleanUpInvalidForeignKeys();
await initStates();
registerRoutes();
if (bot && botAvailable) {
bot.onText(/\/start/, async (msg) => { bot.onText(/\/start/, async (msg) => {
logDebug('/start', 'handleStart');
const canUse = await userHandler.canUseBot(msg); const canUse = await userHandler.canUseBot(msg);
if (!canUse) return;
if (!canUse) {
return;
}
try { try {
await userHandler.handleStart(msg); await userHandler.handleStart(msg);
} catch (error) { } catch (error) {
@@ -27,15 +35,9 @@ if (bot && botAvailable) {
} }
}); });
bot.onText(/\/language/, async (msg) => { // Admin command
try {
await userHandler.handleLanguageCommand(msg);
} catch (error) {
await ErrorHandler.handleError(bot, msg.chat.id, error, 'language command');
}
});
bot.onText(/\/admin/, async (msg) => { bot.onText(/\/admin/, async (msg) => {
logDebug('/admin', 'handleAdminCommand');
try { try {
await adminHandler.handleAdminCommand(msg); await adminHandler.handleAdminCommand(msg);
} catch (error) { } catch (error) {
@@ -43,41 +45,317 @@ if (bot && botAvailable) {
} }
}); });
// Handle user menu buttons
bot.on('message', async (msg) => { bot.on('message', async (msg) => {
if (msg.text?.toLowerCase() === '/start') return; if (msg.text && msg.text.toLowerCase() === '/start') {
return;
}
const canUse = await userHandler.canUseBot(msg); const canUse = await userHandler.canUseBot(msg);
if (!canUse) return;
if (!canUse) {
return;
}
try { try {
await messageRouter.dispatch(msg); // Check for admin location input
if (await adminLocationHandler.handleLocationInput(msg)) {
return;
}
// Check for admin category input
if (await adminProductHandler.handleCategoryInput(msg)) {
return;
}
// Check for admin subcategory input
if (await adminProductHandler.handleSubcategoryInput(msg)) {
return;
}
// Check for product import
if (await adminProductHandler.handleProductImport(msg)) {
return;
}
// Check for product edition
if (await adminProductHandler.handleProductEditImport(msg)) {
return;
}
// Check for database dump import
if (await adminDumpHandler.handleDumpImport(msg)) {
return;
}
// Check for bonus balance input
if (await adminUserHandler.handleBonusBalanceInput(msg)) {
return;
}
// Check for category update input
if (await adminProductHandler.handleCategoryUpdate(msg)) {
return;
}
logDebug(msg.text, 'handleMessage');
switch (msg.text) {
case '📦 Products':
await userProductHandler.showProducts(msg);
break;
case '👤 Profile':
await userHandler.showProfile(msg);
break;
case '💰 Wallets':
await userWalletsHandler.showBalance(msg);
break;
case '🛍 Purchases':
await userPurchaseHandler.showPurchases(msg);
break;
case '📦 Manage Products':
if (adminHandler.isAdmin(msg.from.id)) {
await adminProductHandler.handleProductManagement(msg);
}
break;
case '👥 Manage Users':
if (adminHandler.isAdmin(msg.from.id)) {
await adminUserHandler.handleUserList(msg);
}
break;
case '📍 Manage Locations':
if (adminHandler.isAdmin(msg.from.id)) {
await adminLocationHandler.handleViewLocations(msg);
}
break;
case '💾 Database Backup':
if (adminHandler.isAdmin(msg.from.id)) {
await adminDumpHandler.handleDump(msg);
}
break;
}
} catch (error) { } catch (error) {
await ErrorHandler.handleError(bot, msg.chat.id, error, 'message handler'); await ErrorHandler.handleError(bot, msg.chat.id, error, 'message handler');
} }
}); });
// Handle callback queries
bot.on('callback_query', async (callbackQuery) => { bot.on('callback_query', async (callbackQuery) => {
const action = callbackQuery.data;
const msg = callbackQuery.message;
const canUse = await userHandler.canUseBot(callbackQuery); const canUse = await userHandler.canUseBot(callbackQuery);
if (!canUse) { if (!canUse) {
await bot.answerCallbackQuery(callbackQuery.id); await bot.answerCallbackQuery(callbackQuery.id);
return; return;
} }
try { try {
await callbackRouter.dispatch(callbackQuery); // Profile and location management
if (action === 'set_location') {
logDebug(action, 'handleSetLocation');
await userLocationHandler.handleSetLocation(callbackQuery);
} else if (action.startsWith('set_country_')) {
logDebug(action, 'handleSetCountry');
await userLocationHandler.handleSetCountry(callbackQuery);
} else if (action.startsWith('set_city_')) {
logDebug(action, 'handleSetCity');
await userLocationHandler.handleSetCity(callbackQuery);
} else if (action.startsWith('set_district_')) {
logDebug(action, 'handleSetDistrict');
await userLocationHandler.handleSetDistrict(callbackQuery);
} else if (action === 'back_to_profile') {
logDebug(action, 'handleBackToProfile');
await userHandler.handleBackToProfile(callbackQuery);
} else if (action === 'back_to_balance') {
logDebug(action, 'handleBackToBalance');
await userWalletsHandler.handleBackToBalance(callbackQuery);
}
// Wallet management
else if (action === 'add_wallet') {
logDebug(action, 'handleAddWallet');
await userWalletsHandler.handleAddWallet(callbackQuery);
} else if (action === 'top_up_wallet') {
logDebug(action, 'handleTopUpWallet');
await userWalletsHandler.handleTopUpWallet(callbackQuery);
} else if (action === 'wallet_history') {
logDebug(action, 'handleWalletHistory');
await userWalletsHandler.handleWalletHistory(callbackQuery);
} else if (action === 'view_archived_wallets') {
logDebug(action, 'handleViewArchivedWallets');
await userWalletsHandler.handleViewArchivedWallets(callbackQuery);
} else if (action === 'refresh_balance') {
logDebug(action, 'handleRefreshBalance');
await userWalletsHandler.handleRefreshBalance(callbackQuery);
}
// Wallet generation
else if (action.startsWith('generate_wallet_')) {
logDebug(action, 'handleGenerateWallet');
await userWalletsHandler.handleGenerateWallet(callbackQuery);
}
// Shop navigation
else if (action === 'shop_start') {
logDebug(action, 'showProducts');
await userProductHandler.showProducts(msg);
} else if (action.startsWith('shop_country_')) {
logDebug(action, 'handleCountrySelection');
await userProductHandler.handleCountrySelection(callbackQuery);
} else if (action.startsWith('shop_city_')) {
logDebug(action, 'handleCitySelection');
await userProductHandler.handleCitySelection(callbackQuery);
} else if (action.startsWith('shop_district_')) {
logDebug(action, 'handleDistrictSelection');
await userProductHandler.handleDistrictSelection(callbackQuery);
} else if (action.startsWith('shop_category_')) {
logDebug(action, 'handleCategorySelection');
await userProductHandler.handleCategorySelection(callbackQuery);
} else if (action.startsWith('shop_subcategory_')) {
logDebug(action, 'handleSubcategorySelection');
await userProductHandler.handleSubcategorySelection(callbackQuery);
} else if (action.startsWith('shop_product_')) {
logDebug(action, 'handleProductSelection');
await userProductHandler.handleProductSelection(callbackQuery);
} else if (action.startsWith('increase_quantity_')) {
logDebug(action, 'handleIncreaseQuantity');
await userProductHandler.handleIncreaseQuantity(callbackQuery);
} else if (action.startsWith('decrease_quantity_')) {
logDebug(action, 'handleDecreaseQuantity');
await userProductHandler.handleDecreaseQuantity(callbackQuery);
} else if (action.startsWith('buy_product_')) {
logDebug(action, 'handleBuyProduct');
await userProductHandler.handleBuyProduct(callbackQuery);
} else if (action.startsWith('pay_with_')) {
logDebug(action, 'handlePay');
await userProductHandler.handlePay(callbackQuery);
} else if (action.startsWith('list_purchases_')) {
logDebug(action, 'handlePurchaseListPage');
await userPurchaseHandler.handlePurchaseListPage(callbackQuery);
} else if (action.startsWith('view_purchase_')) {
logDebug(action, 'viewPurchase');
await userPurchaseHandler.viewPurchase(callbackQuery);
}
// Admin location management
else if (action === 'add_location') {
logDebug(action, 'handleAddLocation');
await adminLocationHandler.handleAddLocation(callbackQuery);
} else if (action === 'view_locations') {
logDebug(action, 'handleViewLocations');
await adminLocationHandler.handleViewLocations(callbackQuery);
} else if (action === 'delete_location') {
logDebug(action, 'handleDeleteLocation');
await adminLocationHandler.handleDeleteLocation(callbackQuery);
} else if (action.startsWith('confirm_delete_location_')) {
logDebug(action, 'handleConfirmDelete');
await adminLocationHandler.handleConfirmDelete(callbackQuery);
} else if (action === 'admin_menu') {
logDebug(action, 'backToMenu');
await adminLocationHandler.backToMenu(callbackQuery);
}
// Admin product management
else if (action === 'manage_products') {
logDebug(action, 'handleProductManagement');
await adminProductHandler.handleProductManagement(callbackQuery);
} else if (action.startsWith('prod_country_')) {
logDebug(action, 'handleCountrySelection');
await adminProductHandler.handleCountrySelection(callbackQuery);
} else if (action.startsWith('prod_city_')) {
logDebug(action, 'handleCitySelection');
await adminProductHandler.handleCitySelection(callbackQuery);
} else if (action.startsWith('prod_district_')) {
logDebug(action, 'handleDistrictSelection');
await adminProductHandler.handleDistrictSelection(callbackQuery);
} else if (action.startsWith('add_category_')) {
logDebug(action, 'handleAddCategory');
await adminProductHandler.handleAddCategory(callbackQuery);
} else if (action.startsWith('edit_category_')) {
logDebug(action, 'handleEditCategory');
await adminProductHandler.handleEditCategory(callbackQuery);
} else if (action.startsWith('prod_category_')) {
logDebug(action, 'handleCategorySelection');
await adminProductHandler.handleCategorySelection(callbackQuery);
} else if (action.startsWith('add_subcategory_')) {
logDebug(action, 'handleAddSubcategory');
await adminProductHandler.handleAddSubcategory(callbackQuery);
} else if (action.startsWith('prod_subcategory_')) {
logDebug(action, 'handleSubcategorySelection');
await adminProductHandler.handleSubcategorySelection(callbackQuery);
} else if (action.startsWith('list_products_')) {
logDebug(action, 'handleProductListPage');
await adminProductHandler.handleProductListPage(callbackQuery);
} else if (action.startsWith('add_product_')) {
logDebug(action, 'handleAddProduct');
await adminProductHandler.handleAddProduct(callbackQuery);
} else if (action.startsWith('view_product_')) {
logDebug(action, 'handleViewProduct');
await adminProductHandler.handleViewProduct(callbackQuery);
} else if (action.startsWith('edit_product_')) {
logDebug(action, 'handleProductEdit');
await adminProductHandler.handleProductEdit(callbackQuery)
} else if (action.startsWith('delete_product_')) {
logDebug(action, 'handleProductDelete');
await adminProductHandler.handleProductDelete(callbackQuery);
} else if (action.startsWith('confirm_delete_product_')) {
logDebug(action, 'handleConfirmDelete');
await adminProductHandler.handleConfirmDelete(callbackQuery);
}
// Admin user management
else if (action.startsWith('view_user_')) {
logDebug(action, 'handleViewUser');
await adminUserHandler.handleViewUser(callbackQuery);
} else if (action.startsWith('list_users_')) {
logDebug(action, 'handleUserListPage');
await adminUserHandler.handleUserListPage(callbackQuery);
} else if (action.startsWith('delete_user_')) {
logDebug(action, 'handleDeleteUser');
await adminUserHandler.handleDeleteUser(callbackQuery);
} else if (action.startsWith('block_user_')) {
logDebug(action, 'handleBlockUser');
await adminUserHandler.handleBlockUser(callbackQuery);
} else if (action.startsWith('confirm_delete_user_')) {
logDebug(action, 'handleConfirmDelete');
await adminUserHandler.handleConfirmDelete(callbackQuery);
} else if (action.startsWith('confirm_block_user_')) {
logDebug(action, 'handleConfirmBlock');
await adminUserHandler.handleConfirmBlock(callbackQuery);
} else if (action.startsWith('edit_user_balance_')) {
logDebug(action, 'handleEditUserBalance');
await adminUserHandler.handleEditUserBalance(callbackQuery);
}
// Admin users location management
else if (action.startsWith('edit_user_location_')) {
logDebug(action, 'handleEditUserLocation');
await adminUserLocationHandler.handleEditUserLocation(callbackQuery);
} else if (action.startsWith('edit_user_country_')) {
logDebug(action, 'handleEditUserCountry');
await adminUserLocationHandler.handleEditUserCountry(callbackQuery);
} else if (action.startsWith('edit_user_city_')) {
logDebug(action, 'handleEditUserCity');
await adminUserLocationHandler.handleEditUserCity(callbackQuery);
} else if (action.startsWith('edit_user_district_')) {
logDebug(action, 'handleEditUserDistrict');
await adminUserLocationHandler.handleEditUserDistrict(callbackQuery)
}
// Dump manage
else if (action === "export_database") {
await adminDumpHandler.handleExportDatabase(callbackQuery);
return;
} else if (action === "import_database") {
await adminDumpHandler.handleImportDatabase(callbackQuery);
}
await bot.answerCallbackQuery(callbackQuery.id); await bot.answerCallbackQuery(callbackQuery.id);
} catch (error) { } catch (error) {
await ErrorHandler.handleError(bot, callbackQuery.message.chat.id, error, 'callback query'); await ErrorHandler.handleError(bot, msg.chat.id, error, 'callback query');
} }
}); });
// Error handling
bot.on('polling_error', ErrorHandler.handlePollingError); bot.on('polling_error', ErrorHandler.handlePollingError);
logger.info('Bot is running...');
} else {
logger.warn('Bot is not available. Running in admin-only mode. Admin panel will continue to work.');
}
process.on('unhandledRejection', (error) => { process.on('unhandledRejection', (error) => {
logger.error({ err: error }, 'Unhandled promise rejection'); console.error('Unhandled promise rejection:', error);
}); });
import { startAdminPanel } from './admin/server.js'; console.log('Bot is running...');
startAdminPanel();

View File

@@ -1,9 +0,0 @@
import config from '../config/config.js';
export function isAdmin(userId) {
return config.ADMIN_IDS.includes(userId.toString());
}
export function isSuperAdmin(userId) {
return config.SUPER_ADMIN_IDS.includes(userId.toString());
}

View File

@@ -1,94 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration001(db) {
await db.runAsync('BEGIN TRANSACTION');
await db.runAsync(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id TEXT UNIQUE NOT NULL,
username TEXT,
country TEXT,
city TEXT,
district TEXT,
status INTEGER DEFAULT 0,
total_balance REAL DEFAULT 0,
bonus_balance REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
await db.runAsync(`CREATE TABLE IF NOT EXISTS crypto_wallets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
wallet_type TEXT NOT NULL,
address TEXT NOT NULL,
derivation_path TEXT NOT NULL,
mnemonic TEXT NOT NULL,
balance REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(user_id, wallet_type)
)`);
await db.runAsync(`CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
wallet_type TEXT NOT NULL,
tx_hash TEXT NOT NULL,
amount REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`);
await db.runAsync(`CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
private_data TEXT,
price REAL NOT NULL CHECK (price > 0),
quantity_in_stock INTEGER DEFAULT 0 CHECK (quantity_in_stock >= 0),
photo_url TEXT,
hidden_photo_url TEXT,
hidden_coordinates TEXT,
hidden_description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
)`);
await db.runAsync(`CREATE TABLE IF NOT EXISTS purchases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
wallet_type TEXT NOT NULL,
tx_hash TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
total_price REAL NOT NULL CHECK (total_price > 0),
purchase_date DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'pending',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE
)`);
await db.runAsync(`CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
country TEXT NOT NULL,
city TEXT NOT NULL,
district TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(country, city, district)
)`);
await db.runAsync(`CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
location_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE CASCADE,
UNIQUE(location_id, name)
)`);
await db.runAsync('COMMIT');
logger.info('Migration 001: Initial schema created');
}

View File

@@ -1,35 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration002(db, checkColumnExists) {
const balanceExists = await checkColumnExists('crypto_wallets', 'balance');
if (!balanceExists) {
await db.runAsync(`ALTER TABLE crypto_wallets ADD COLUMN balance REAL DEFAULT 0`);
logger.info('Migration 002: Column balance added to crypto_wallets');
}
const userIdExists = await checkColumnExists('transactions', 'user_id');
if (!userIdExists) {
await db.runAsync(`ALTER TABLE transactions ADD COLUMN user_id INTEGER NOT NULL`);
logger.info('Migration 002: Column user_id added to transactions');
}
const walletTypeExists = await checkColumnExists('transactions', 'wallet_type');
if (!walletTypeExists) {
await db.runAsync(`ALTER TABLE transactions ADD COLUMN wallet_type TEXT NOT NULL`);
logger.info('Migration 002: Column wallet_type added to transactions');
}
const txHashExists = await checkColumnExists('transactions', 'tx_hash');
if (!txHashExists) {
await db.runAsync(`ALTER TABLE transactions ADD COLUMN tx_hash TEXT NOT NULL`);
logger.info('Migration 002: Column tx_hash added to transactions');
}
const statusExists = await checkColumnExists('purchases', 'status');
if (!statusExists) {
await db.runAsync(`ALTER TABLE purchases ADD COLUMN status TEXT DEFAULT 'pending'`);
logger.info('Migration 002: Column status added to purchases');
}
logger.info('Migration 002: Column additions complete');
}

View File

@@ -1,11 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration003(db) {
await db.runAsync(`CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id)`);
await db.runAsync(`CREATE INDEX IF NOT EXISTS idx_crypto_wallets_user_type ON crypto_wallets(user_id, wallet_type)`);
await db.runAsync(`CREATE INDEX IF NOT EXISTS idx_transactions_user ON transactions(user_id)`);
await db.runAsync(`CREATE INDEX IF NOT EXISTS idx_purchases_user_product ON purchases(user_id, product_id)`);
await db.runAsync(`CREATE INDEX IF NOT EXISTS idx_purchases_status ON purchases(status)`);
await db.runAsync(`CREATE INDEX IF NOT EXISTS idx_products_location_category ON products(location_id, category_id)`);
logger.info('Migration 003: Indexes created');
}

View File

@@ -1,10 +0,0 @@
export default async function migration004(db) {
await db.runAsync(`
CREATE TABLE IF NOT EXISTS user_states (
chat_id TEXT PRIMARY KEY,
state_data TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
console.log('Migration 004: user_states table created');
}

View File

@@ -1,14 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration005(db) {
await db.runAsync(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
admin_id TEXT NOT NULL,
details TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
logger.info('Migration 005: audit_log table created');
}

View File

@@ -1,23 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration006(db) {
await db.runAsync('BEGIN TRANSACTION');
await db.runAsync(`CREATE TABLE IF NOT EXISTS subcategories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE(category_id, name)
)`);
const cols = await db.allAsync('PRAGMA table_info(products)');
const hasSubcat = cols.some(c => c.name === 'subcategory_id');
if (!hasSubcat) {
await db.runAsync(`ALTER TABLE products ADD COLUMN subcategory_id INTEGER REFERENCES subcategories(id) ON DELETE SET NULL`);
}
await db.runAsync('COMMIT');
logger.info('Migration 006: subcategories table + products.subcategory_id column');
}

View File

@@ -1,17 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration007(db) {
await db.runAsync(`
CREATE TABLE IF NOT EXISTS commission_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
total_balance_usd REAL NOT NULL,
commission_rate REAL NOT NULL,
commission_amount_usd REAL NOT NULL,
paid_amount_usd REAL NOT NULL,
wallet_count INTEGER NOT NULL,
note TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
logger.info('Migration 007: commission_payments table created');
}

View File

@@ -1,15 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration008(db, checkColumnExists) {
if (await checkColumnExists('users', 'language')) {
logger.info('Migration 008: language column already exists, skipping');
return;
}
await db.runAsync('BEGIN TRANSACTION');
await db.runAsync(`ALTER TABLE users ADD COLUMN language TEXT DEFAULT 'en'`);
await db.runAsync('COMMIT');
logger.info('Migration 008: Added language column to users table');
}

View File

@@ -1,15 +0,0 @@
import logger from '../utils/logger.js';
export default async function migration009(db, checkColumnExists) {
if (await checkColumnExists('users', 'language_set')) {
logger.info('Migration 009: language_set column already exists, skipping');
return;
}
await db.runAsync('BEGIN TRANSACTION');
await db.runAsync(`ALTER TABLE users ADD COLUMN language_set INTEGER DEFAULT 0`);
await db.runAsync('COMMIT');
logger.info('Migration 009: Added language_set column to users table');
}

View File

@@ -1,59 +0,0 @@
import db from '../config/database.js';
import logger from '../utils/logger.js';
const ALLOWED_TABLES = new Set([
'users', 'crypto_wallets', 'transactions', 'products',
'purchases', 'locations', 'categories', 'subcategories'
]);
export const checkColumnExists = async (tableName, columnName) => {
if (!ALLOWED_TABLES.has(tableName)) {
throw new Error(`Invalid table name: ${tableName}`);
}
try {
const result = await db.allAsync(`PRAGMA table_info(${tableName})`);
return result.some(column => column.name === columnName);
} catch (error) {
logger.error({ err: error, tableName, columnName }, 'Error checking column');
return false;
}
};
export const cleanUpInvalidForeignKeys = async () => {
try {
await db.runAsync(`DELETE FROM crypto_wallets WHERE user_id NOT IN (SELECT id FROM users)`);
logger.info('Cleaned up invalid foreign key references in crypto_wallets table');
} catch (error) {
logger.error({ err: error }, 'Error cleaning up invalid foreign key references');
}
};
export async function runMigrations() {
await db.runAsync(`CREATE TABLE IF NOT EXISTS _meta (key TEXT PRIMARY KEY, value TEXT)`);
const row = await db.getAsync(`SELECT value FROM _meta WHERE key = 'schema_version'`);
const currentVersion = row ? parseInt(row.value, 10) : 0;
const migrations = [
(await import('./001_initial_schema.js')).default,
(await import('./002_add_columns.js')).default,
(await import('./003_add_indexes.js')).default,
(await import('./004_user_states.js')).default,
(await import('./005_audit_log.js')).default,
(await import('./006_subcategories.js')).default,
(await import('./007_commission_payments.js')).default,
(await import('./008_user_language.js')).default,
(await import('./009_user_language_set.js')).default,
];
for (let i = currentVersion; i < migrations.length; i++) {
logger.info({ migration: i + 1, total: migrations.length }, 'Running migration');
await migrations[i](db, checkColumnExists);
await db.runAsync(
`INSERT OR REPLACE INTO _meta (key, value) VALUES ('schema_version', ?)`,
[String(i + 1)]
);
}
logger.info({ schemaVersion: migrations.length }, 'Migrations complete');
}

View File

@@ -1,9 +1,13 @@
// Wallet.js
import db from "../config/database.js"; import db from "../config/database.js";
import WalletUtils from "../utils/walletUtils.js"; import WalletService from "../utils/walletService.js";
export default class Wallet { export default class Wallet {
static getBaseWalletType(walletType) {
if (walletType.includes('TRC-20')) return 'TRON';
if (walletType.includes('ERC-20')) return 'ETH';
return walletType;
}
static async getArchivedWallets(userId) { static async getArchivedWallets(userId) {
const archivedWallets = await db.allAsync(` const archivedWallets = await db.allAsync(`
SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type LIKE '%_%' SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type LIKE '%_%'
@@ -11,53 +15,59 @@ export default class Wallet {
const btcAddress = archivedWallets.find(w => w.wallet_type.startsWith('BTC'))?.address; const btcAddress = archivedWallets.find(w => w.wallet_type.startsWith('BTC'))?.address;
const ltcAddress = archivedWallets.find(w => w.wallet_type.startsWith('LTC'))?.address; const ltcAddress = archivedWallets.find(w => w.wallet_type.startsWith('LTC'))?.address;
const tronAddress = archivedWallets.find(w => w.wallet_type.startsWith('TRON'))?.address;
const ethAddress = archivedWallets.find(w => w.wallet_type.startsWith('ETH'))?.address; const ethAddress = archivedWallets.find(w => w.wallet_type.startsWith('ETH'))?.address;
return { return {
btc: btcAddress, btc: btcAddress,
ltc: ltcAddress, ltc: ltcAddress,
tron: tronAddress,
eth: ethAddress, eth: ethAddress,
wallets: archivedWallets wallets: archivedWallets
}; }
} }
static async getActiveWallets(userId) { static async getActiveWallets(userId) {
const activeWallets = await db.allAsync( const activeWallets = await db.allAsync(
`SELECT wallet_type, address FROM crypto_wallets WHERE user_id = ? ORDER BY wallet_type`, `SELECT wallet_type, address FROM crypto_wallets WHERE user_id = ? ORDER BY wallet_type`,
[userId] [userId]
); )
const btcAddress = activeWallets.find(w => w.wallet_type === 'BTC')?.address; const btcAddress = activeWallets.find(w => w.wallet_type === 'BTC')?.address;
const ltcAddress = activeWallets.find(w => w.wallet_type === 'LTC')?.address; const ltcAddress = activeWallets.find(w => w.wallet_type === 'LTC')?.address;
const tronAddress = activeWallets.find(w => w.wallet_type === 'TRON')?.address;
const ethAddress = activeWallets.find(w => w.wallet_type === 'ETH')?.address; const ethAddress = activeWallets.find(w => w.wallet_type === 'ETH')?.address;
return { return {
btc: btcAddress, btc: btcAddress,
ltc: ltcAddress, ltc: ltcAddress,
tron: tronAddress,
eth: ethAddress, eth: ethAddress,
wallets: activeWallets wallets: activeWallets
}; }
} }
static async getActiveWalletsBalance(userId) { static async getActiveWalletsBalance(userId) {
const activeWallets = await this.getActiveWallets(userId); const activeWallets = await this.getActiveWallets(userId);
const walletUtilsInstance = new WalletUtils( const walletService = new WalletService(
activeWallets.btc, activeWallets.btc,
activeWallets.ltc, activeWallets.ltc,
activeWallets.tron,
activeWallets.eth, activeWallets.eth,
userId, userId,
Date.now() - 30 * 24 * 60 * 60 * 1000 Date.now() - 30 * 24 * 60 * 60 * 1000
); );
const balances = await walletUtilsInstance.getAllBalances(); const balances = await walletService.getAllBalances();
let totalUsdBalance = 0; let totalUsdBalance = 0;
for (const [type, balance] of Object.entries(balances)) { for (const [type, balance] of Object.entries(balances)) {
const baseType = WalletUtils.getBaseWalletType(type); const baseType = this.getBaseWalletType(type);
const wallet = activeWallets.wallets.find(w => const wallet = activeWallets.wallets.find(w =>
w.wallet_type === baseType || w.wallet_type === baseType ||
(type.includes('TRC-20') && w.wallet_type === 'TRON') ||
(type.includes('ERC-20') && w.wallet_type === 'ETH') (type.includes('ERC-20') && w.wallet_type === 'ETH')
); );
@@ -65,8 +75,10 @@ export default class Wallet {
continue; continue;
} }
if (wallet) {
totalUsdBalance += balance.usdValue; totalUsdBalance += balance.usdValue;
} }
}
return totalUsdBalance; return totalUsdBalance;
} }
@@ -74,22 +86,24 @@ export default class Wallet {
static async getArchivedWalletsBalance(userId) { static async getArchivedWalletsBalance(userId) {
const archiveWallets = await this.getArchivedWallets(userId); const archiveWallets = await this.getArchivedWallets(userId);
const walletUtilsInstance = new WalletUtils( const walletService = new WalletService(
archiveWallets.btc, archiveWallets.btc,
archiveWallets.ltc, archiveWallets.ltc,
archiveWallets.tron,
archiveWallets.eth, archiveWallets.eth,
userId, userId,
Date.now() - 30 * 24 * 60 * 60 * 1000 Date.now() - 30 * 24 * 60 * 60 * 1000
); );
const balances = await walletUtilsInstance.getAllBalances(); const balances = await walletService.getAllBalances();
let totalUsdBalance = 0; let totalUsdBalance = 0;
for (const [type, balance] of Object.entries(balances)) { for (const [type, balance] of Object.entries(balances)) {
const baseType = WalletUtils.getBaseWalletType(type); const baseType = this.getBaseWalletType(type);
const wallet = archiveWallets.wallets.find(w => const wallet = archiveWallets.wallets.find(w =>
w.wallet_type === baseType || w.wallet_type === baseType ||
(type.includes('TRC-20') && w.wallet_type.startsWith('TRON')) ||
(type.includes('ERC-20') && w.wallet_type.startsWith('ETH')) (type.includes('ERC-20') && w.wallet_type.startsWith('ETH'))
); );
@@ -97,8 +111,10 @@ export default class Wallet {
continue; continue;
} }
if (wallet) {
totalUsdBalance += balance.usdValue; totalUsdBalance += balance.usdValue;
} }
}
return totalUsdBalance; return totalUsdBalance;
} }

View File

@@ -1,38 +0,0 @@
import logger from '../utils/logger.js';
class CallbackRouter {
constructor() {
this.exactRoutes = new Map();
this.prefixRoutes = new Map();
}
registerExact(action, handler) {
this.exactRoutes.set(action, handler);
}
registerPrefix(prefix, handler) {
this.prefixRoutes.set(prefix, handler);
}
async dispatch(callbackQuery) {
const action = callbackQuery.data;
const exactHandler = this.exactRoutes.get(action);
if (exactHandler) {
await exactHandler(callbackQuery);
return;
}
const prefixes = [...this.prefixRoutes.keys()].sort((a, b) => b.length - a.length);
for (const prefix of prefixes) {
if (action.startsWith(prefix)) {
await this.prefixRoutes.get(prefix)(callbackQuery);
return;
}
}
logger.warn({ action }, 'No handler for callback');
}
}
export default new CallbackRouter();

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