Compare commits
1 Commits
main
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ff01f5f68 |
@@ -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
|
|
||||||
56
.env.example
56
.env.example
@@ -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
47
.gitignore
vendored
@@ -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
|
|
||||||
47
Dockerfile
47
Dockerfile
@@ -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
376
README.md
@@ -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
|
|
||||||
@@ -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
|
|
||||||
313
install.sh
313
install.sh
@@ -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
2606
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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"
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
||||||
@@ -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>`;
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function escapeHtml(str) {
|
|
||||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
@@ -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>` : '';
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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 & Restart</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
return layout('Settings', content, 'settings');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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 || ''
|
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1058
src/handlers/adminHandlers/adminProductHandler.js
Normal file
1058
src/handlers/adminHandlers/adminProductHandler.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`}
|
|
||||||
]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}` }
|
|
||||||
]]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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},
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
573
src/handlers/userHandlers/userWalletsHandler.js
Normal file
573
src/handlers/userHandlers/userWalletsHandler.js
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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' }]] }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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') });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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' }]] }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
354
src/index.js
354
src/index.js
@@ -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();
|
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user