Compare commits

...

2 Commits

Author SHA1 Message Date
0bbeb0767b geo detection 2025-11-06 15:09:15 +09:00
88d9ccd75d fix(database): Исправлены критические ошибки БД - job, state, looking_for
- Добавлена колонка job в profiles (устраняет ошибку column job does not exist)
- Добавлена колонка state в users (устраняет предупреждения State column does not exist)
- Исправлен триггер create_initial_profile() для включения looking_for
- Колонка looking_for сделана nullable с DEFAULT 'both'
- Добавлена колонка interested_in как современный синоним для looking_for
- Созданы индексы для производительности: idx_profiles_job, idx_users_state, idx_profiles_interested_in

Патчи:
- sql/fix_looking_for_column.sql
- sql/add_job_and_state_columns.sql

Утилиты:
- bin/apply_all_patches.sh - автоматическое применение всех патчей

Документация:
- docs/DATABASE_FIXES.md - подробное описание исправлений
- docs/HEALTH_CHECK.md - чеклист проверки здоровья бота
- docs/FIXES_SUMMARY_2025-11-06.md - краткая сводка изменений

Fixes: #job-column-error #state-column-warning #looking-for-constraint
2025-11-06 10:30:35 +09:00
54 changed files with 2076 additions and 93 deletions

View File

@@ -46,6 +46,13 @@ JWT_SECRET=your_jwt_secret_here
# Encryption key for sensitive data
ENCRYPTION_KEY=your_encryption_key_here
# === KAKAO MAPS API ===
# Kakao REST API Key for location services (Get from https://developers.kakao.com/)
# You can use either KAKAO_REST_API_KEY or KAKAO_MAP_REST_KEY
KAKAO_REST_API_KEY=your_kakao_rest_api_key_here
# KAKAO_MAP_REST_KEY=your_kakao_rest_api_key_here
# === ADVANCED SETTINGS ===
# Notification check interval in milliseconds (default: 60000 - 1 minute)

View File

@@ -16,7 +16,7 @@ COPY src/ ./src/
COPY .env.example ./
# Build the application (using Linux-compatible build command)
RUN npm run build:linux:linux
RUN npm run build:linux
# Production stage
FROM node:18-alpine AS production

View File

@@ -21,9 +21,20 @@ help:
install:
@echo "Установка зависимостей..."
@if ! command -v docker &> /dev/null || ! command -v docker-compose &> /dev/null; then \
@if ! command -v docker &> /dev/null; then \
echo "Установка Docker..."; \
sudo apt update && sudo apt install -y docker.io docker-compose; \
echo "Удаление конфликтующих пакетов..."; \
sudo apt remove -y docker.io containerd runc 2>/dev/null || true; \
sudo apt update; \
sudo apt install -y apt-transport-https ca-certificates curl software-properties-common; \
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg; \
echo "deb [arch=$$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null; \
sudo apt update; \
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin; \
sudo systemctl start docker; \
sudo systemctl enable docker; \
sudo usermod -aG docker $$USER; \
echo "Docker установлен. Перелогиньтесь для применения группы docker."; \
fi
@if [ ! -f .env ]; then \
echo "Создание .env файла..."; \
@@ -37,6 +48,14 @@ update:
@git fetch --all
@git pull origin main || git pull origin master || echo "Не удалось обновить код"
@if [ -f package.json ]; then npm ci || npm install; fi
@echo "Пересборка контейнеров..."
@docker compose down || docker-compose down || true
@docker compose build || docker-compose build
@docker compose up -d || docker-compose up -d
@echo "Применение миграций к базе данных..."
@sleep 5
@make migrate
@echo "✅ Обновление завершено! Бот перезапущен с новой версией."
run:
@echo "Запуск бота..."
@@ -47,18 +66,26 @@ run:
@echo "Бот запущен! Для просмотра логов: docker-compose logs -f"
migrate:
@echo "Применение миграций..."
@if [ -d migrations ]; then \
mkdir -p temp_migrations; \
find migrations -name "*.js" -exec cp {} temp_migrations/ \; 2>/dev/null || true; \
DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} \
npx node-pg-migrate up --migrations-dir=migrations || true; \
fi
@if [ -d sql ]; then \
for sql_file in sql/*.sql; do \
[ -f "$${sql_file}" ] && docker-compose exec -T db psql -U ${DB_USERNAME} -d ${DB_NAME} -f "/app/$${sql_file}" || true; \
done; \
@echo "Применение миграций к базе данных..."
@if [ ! -f .env ]; then \
echo "❌ Файл .env не найден! Создайте его перед применением миграций."; \
exit 1; \
fi
@. ./.env && export $$(cat .env | grep -v '^#' | xargs) && \
echo "Подключение к БД: $$DB_HOST:$$DB_PORT/$$DB_NAME ($$DB_USERNAME)" && \
echo "Создание расширения uuid-ossp..." && \
PGPASSWORD="$$DB_PASSWORD" psql -h $$DB_HOST -p $$DB_PORT -U $$DB_USERNAME -d $$DB_NAME \
-c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";" 2>/dev/null || true && \
echo "Применение consolidated.sql..." && \
PGPASSWORD="$$DB_PASSWORD" psql -h $$DB_HOST -p $$DB_PORT -U $$DB_USERNAME -d $$DB_NAME \
-f sql/consolidated.sql 2>&1 | grep -E "(ERROR|CREATE|ALTER)" || true && \
echo "Применение дополнительных миграций..." && \
for sql_file in sql/add_*.sql; do \
[ -f "$$sql_file" ] && echo " - Применение $$(basename $$sql_file)..." && \
PGPASSWORD="$$DB_PASSWORD" psql -h $$DB_HOST -p $$DB_PORT -U $$DB_USERNAME -d $$DB_NAME \
-f "$$sql_file" 2>&1 | grep -v "NOTICE" || true; \
done && \
echo "✅ Миграции применены успешно!"
fix-docker:
@echo "Исправление Docker конфигурации..."

View File

@@ -2,6 +2,24 @@
Этот документ описывает процесс автоматического обновления бота с помощью созданных скриптов.
## Доступные скрипты
### apply_all_patches.sh
Применяет все SQL патчи к базе данных в правильном порядке:
- Основная схема (consolidated.sql)
- Исправление триггера looking_for
- Добавление колонок job и state
```bash
./bin/apply_all_patches.sh
```
### apply_migrations.sh
Применяет Node.js миграции через node-pg-migrate.
### apply_direct_sql.sh
Применяет SQL файлы напрямую через psql.
## Скрипт обновления
Скрипт обновления выполняет следующие действия:

76
bin/apply_all_patches.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/bin/bash
# Применение всех патчей базы данных
# Использование: ./bin/apply_all_patches.sh
set -e # Остановка при ошибке
# Загрузка переменных окружения
if [ -f .env ]; then
source .env
else
echo "❌ Файл .env не найден!"
exit 1
fi
# Проверка обязательных переменных
if [ -z "$DB_HOST" ] || [ -z "$DB_PORT" ] || [ -z "$DB_NAME" ] || [ -z "$DB_USERNAME" ] || [ -z "$DB_PASSWORD" ]; then
echo "❌ Не все переменные DB_* заданы в .env"
exit 1
fi
echo "🔧 Применение патчей к базе данных..."
echo "📍 Сервер: $DB_HOST:$DB_PORT"
echo "📂 База данных: $DB_NAME"
echo ""
# Функция применения патча
apply_patch() {
local patch_file=$1
local description=$2
if [ ! -f "$patch_file" ]; then
echo "⚠️ Патч $patch_file не найден, пропуск..."
return
fi
echo "📝 Применение: $description"
if PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USERNAME" -d "$DB_NAME" -f "$patch_file" > /dev/null 2>&1; then
echo "✅ Патч применен: $patch_file"
else
echo "⚠️ Ошибка при применении: $patch_file (возможно уже применен)"
fi
echo ""
}
# Применение патчей в правильном порядке
apply_patch "sql/consolidated.sql" "Основная схема БД (16 таблиц)"
apply_patch "sql/fix_looking_for_column.sql" "Исправление триггера и колонки looking_for"
apply_patch "sql/add_job_and_state_columns.sql" "Добавление колонок job и state"
echo "🎉 Все патчи обработаны!"
echo ""
echo "🔍 Проверка применения патчей..."
# Проверка критичных колонок
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USERNAME" -d "$DB_NAME" << 'EOF'
SELECT
CASE
WHEN EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = 'job')
THEN '✅ profiles.job существует'
ELSE '❌ profiles.job НЕ НАЙДЕНА'
END as status_job,
CASE
WHEN EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'state')
THEN '✅ users.state существует'
ELSE '❌ users.state НЕ НАЙДЕНА'
END as status_state,
CASE
WHEN EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = 'interested_in')
THEN '✅ profiles.interested_in существует'
ELSE '❌ profiles.interested_in НЕ НАЙДЕНА'
END as status_interested_in;
EOF
echo ""
echo "✅ Готово! Теперь можно перезапустить бота:"
echo " docker compose restart bot"

0
bin/apply_direct_sql.sh Executable file → Normal file
View File

0
bin/apply_migrations.sh Executable file → Normal file
View File

0
bin/backup_db.sh Executable file → Normal file
View File

0
bin/compile_ts_migrations.sh Executable file → Normal file
View File

0
bin/create_consolidated_migration.sh Executable file → Normal file
View File

0
bin/create_release.sh Executable file → Normal file
View File

0
bin/fix_docker.sh Executable file → Normal file
View File

0
bin/fix_line_endings.sh Executable file → Normal file
View File

0
bin/fix_permissions.sh Executable file → Normal file
View File

20
bin/init_database.sh Normal file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# init_database.sh - Initialize database with schema
set -e
echo "🗄️ Initializing database..."
# Wait for database to be ready
echo "⏳ Waiting for database..."
sleep 3
# Create UUID extension
echo "📦 Creating UUID extension..."
docker compose exec -T db psql -U postgres -d telegram_tinder_bot -c "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";" || true
# Apply consolidated schema
echo "📋 Applying database schema..."
docker compose exec -T db psql -U postgres -d telegram_tinder_bot < sql/consolidated.sql
echo "✅ Database initialized successfully!"

0
bin/install_docker.sh Executable file → Normal file
View File

0
bin/install_ubuntu.sh Executable file → Normal file
View File

0
bin/run_full_migration.sh Executable file → Normal file
View File

0
bin/run_sql_migrations.sh Executable file → Normal file
View File

0
bin/setup.sh Executable file → Normal file
View File

0
bin/start_bot.sh Executable file → Normal file
View File

0
bin/update.sh Executable file → Normal file
View File

0
deploy.sh Executable file → Normal file
View File

View File

@@ -1,23 +1,19 @@
version: '3.8'
services:
bot:
build: .
container_name: telegram-tinder-bot
restart: unless-stopped
depends_on:
db:
condition: service_healthy
env_file: .env
environment:
- NODE_ENV=production
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=telegram_tinder_bot
- DB_USERNAME=postgres
- DB_HOST=${DB_HOST:-db}
- DB_PORT=${DB_PORT:-5432}
- DB_NAME=${DB_NAME:-telegram_tinder_bot}
- DB_USERNAME=${DB_USERNAME:-postgres}
- DB_PASSWORD=${DB_PASSWORD:-postgres}
volumes:
- ./uploads:/app/uploads
- ./logs:/app/logs
- ./uploads:/app/uploads:rw
- ./logs:/app/logs:rw
networks:
- bot-network
healthcheck:
@@ -32,9 +28,9 @@ services:
container_name: postgres-tinder
restart: unless-stopped
environment:
- POSTGRES_DB=telegram_tinder_bot
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD:-password123}
- POSTGRES_DB=${DB_NAME:-telegram_tinder_bot}
- POSTGRES_USER=${DB_USERNAME:-postgres}
- POSTGRES_PASSWORD=${DB_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
@@ -42,19 +38,18 @@ services:
networks:
- bot-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-telegram_tinder_bot}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
adminer:
image: adminer:latest
container_name: adminer-tinder
restart: unless-stopped
ports:
- "8080:8080"
depends_on:
- db
networks:
- bot-network

176
docs/DATABASE_FIXES.md Normal file
View File

@@ -0,0 +1,176 @@
# Database Schema Fixes
## Обзор исправлений
Этот документ описывает исправления схемы базы данных, примененные к проекту Telegram Tinder Bot для устранения критических ошибок и предупреждений.
## Дата последнего обновления: 2025-11-06
---
## Исправление 1: Колонка `looking_for` и триггер создания профиля
### Проблема
```
ERROR: null value in column "looking_for" of relation "profiles" violates not-null constraint
```
### Причина
Триггер `create_initial_profile()` не устанавливал значение для обязательного поля `looking_for` при автоматическом создании профиля.
### Решение
Применен патч: `sql/fix_looking_for_column.sql`
**Изменения:**
1. Обновлен триггер для включения `looking_for = 'both'` и `interested_in = 'both'`
2. Сделана колонка `looking_for` необязательной (nullable) с DEFAULT 'both'
3. Добавлена колонка `interested_in` как современный синоним для `looking_for`
4. Создан индекс для поиска: `idx_profiles_interested_in`
**Применение:**
```bash
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name -f sql/fix_looking_for_column.sql
```
---
## Исправление 2: Колонка `job` и `state`
### Проблема 1: Column "job" does not exist
```
ERROR: column "job" of relation "profiles" does not exist
```
**Причина:** Код использует `job`, но в БД создана колонка `occupation`.
### Проблема 2: State column does not exist
```
WARNING: State column does not exist in users table. Skipping state check.
```
**Причина:** Таблица `users` не содержит колонку `state` для отслеживания состояния диалога.
### Решение
Применен патч: `sql/add_job_and_state_columns.sql`
**Изменения:**
1. Добавлена колонка `job VARCHAR(255)` в таблицу `profiles`
2. Скопированы данные из `occupation``job` для обратной совместимости
3. Добавлена колонка `state VARCHAR(50)` в таблицу `users`
4. Созданы индексы: `idx_profiles_job`, `idx_users_state`
**Применение:**
```bash
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name -f sql/add_job_and_state_columns.sql
```
---
## Полная последовательность применения патчей
Для нового развертывания применяйте патчи в следующем порядке:
```bash
# 1. Основная схема (если еще не применена)
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name -f sql/consolidated.sql
# 2. Исправление looking_for
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name -f sql/fix_looking_for_column.sql
# 3. Добавление job и state
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name -f sql/add_job_and_state_columns.sql
```
Или используйте автоматизированную команду:
```bash
make migrate
```
---
## Проверка применения исправлений
### Проверка триггера looking_for
```sql
SELECT proname, prosrc
FROM pg_proc
WHERE proname = 'create_initial_profile';
```
Должен содержать: `looking_for = 'both', interested_in = 'both'`
### Проверка колонок
```sql
-- Проверка profiles
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'profiles'
AND column_name IN ('job', 'occupation', 'looking_for', 'interested_in')
ORDER BY column_name;
-- Проверка users
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name = 'state';
```
### Проверка индексов
```sql
SELECT indexname, tablename, indexdef
FROM pg_indexes
WHERE tablename IN ('profiles', 'users')
AND indexname IN ('idx_profiles_job', 'idx_users_state', 'idx_profiles_interested_in')
ORDER BY tablename, indexname;
```
---
## Известные предупреждения (неопасные)
### ES Module в миграциях
```
SyntaxError: Unexpected token 'export'
```
**Статус:** Не критично. Миграции применяются через psql напрямую, а не через node-pg-migrate.
**Причина:** Файлы миграций в `/migrations` используют ES6 синтаксис, несовместимый с node-pg-migrate в режиме CommonJS.
**Решение:** Используйте `make migrate` или применяйте SQL патчи напрямую через psql.
### DEEPSEEK_API_KEY not found
```
⚠️ DEEPSEEK_API_KEY not found in environment variables
```
**Статус:** Не критично. Это опциональная AI-функция.
**Решение:** Добавьте `DEEPSEEK_API_KEY=your_key` в `.env` если хотите использовать AI-фичи.
---
## Mapping колонок (для справки)
| Код (TypeScript) | База данных | Комментарий |
|------------------|-------------|-------------|
| `job` | `job` | Основная колонка (новая) |
| `job` | `occupation` | Устаревшая, оставлена для совместимости |
| `interestedIn` | `interested_in` | Основная колонка (новая) |
| `lookingFor` | `looking_for` | Устаревшая, nullable |
| - | `state` | Новая колонка для users.state |
---
## Контакты для поддержки
При возникновении проблем с миграциями:
1. Проверьте логи бота: `docker compose logs bot --tail 50`
2. Проверьте применение всех патчей (см. раздел "Проверка")
3. Убедитесь, что `.env` содержит правильные DB_* переменные
4. Попробуйте применить патчи вручную через psql
**Версия документа:** 1.0
**Автор:** GitHub Copilot
**Дата:** 2025-11-06

View File

@@ -0,0 +1,173 @@
# Сводка исправлений от 2025-11-06
## 🎯 Цель
Устранить критические ошибки базы данных, блокирующие создание профилей пользователей.
---
## 🐛 Исправленные ошибки
### 1. ❌ Column "job" does not exist
**Ошибка:**
```
ERROR: column "job" of relation "profiles" does not exist
Code: 42703
```
**Причина:** Код использует поле `job`, но в БД существует только `occupation`.
**Решение:** Добавлена колонка `job` в таблицу `profiles`.
**Файл патча:** `sql/add_job_and_state_columns.sql`
**Статус:** ✅ ИСПРАВЛЕНО
---
### 2. ⚠️ State column does not exist in users table
**Предупреждение:**
```
State column does not exist in users table. Skipping state check.
```
**Причина:** Код пытается проверить состояние диалога пользователя через колонку `state`, которой нет в таблице `users`.
**Решение:** Добавлена колонка `state VARCHAR(50)` в таблицу `users`.
**Файл патча:** `sql/add_job_and_state_columns.sql`
**Статус:** ✅ ИСПРАВЛЕНО
---
### 3. ❌ null value in column "looking_for" violates not-null constraint
**Ошибка:**
```
ERROR: null value in column "looking_for" of relation "profiles" violates not-null constraint
Code: 23502
```
**Причина:** Триггер `create_initial_profile()` не устанавливал значение для обязательного поля `looking_for`.
**Решение:**
- Обновлен триггер для включения `looking_for = 'both'`
- Колонка сделана nullable с DEFAULT 'both'
- Добавлена колонка `interested_in` как современный синоним
**Файл патча:** `sql/fix_looking_for_column.sql`
**Статус:** ✅ ИСПРАВЛЕНО
---
## 📦 Применённые патчи
| # | Файл | Описание | Дата |
|---|------|----------|------|
| 1 | `sql/fix_looking_for_column.sql` | Исправление триггера и колонки looking_for | 2025-11-06 |
| 2 | `sql/add_job_and_state_columns.sql` | Добавление колонок job и state | 2025-11-06 |
---
## 🔧 Применение патчей
### Автоматически (рекомендуется):
```bash
./bin/apply_all_patches.sh
```
### Вручную:
```bash
# Патч 1: looking_for
PGPASSWORD='password' psql -h host -p 5432 -U user -d db_name \
-f sql/fix_looking_for_column.sql
# Патч 2: job и state
PGPASSWORD='password' psql -h host -p 5432 -U user -d db_name \
-f sql/add_job_and_state_columns.sql
# Перезапуск бота
docker compose restart bot
```
---
## ✅ Результаты после исправлений
### Проверка логов (нет критичных ошибок):
```bash
docker compose logs bot --since 5m | grep -E "(State column|column.*job|does not exist)"
```
**Результат:** Пусто (0 строк) ✅
### Проверка структуры БД:
```
table_name | column_name | data_type | nullable | column_default
------------+---------------+-------------------+----------+--------------------
profiles | interested_in | character varying | NULL | 'both'
profiles | job | character varying | NULL |
profiles | looking_for | character varying | NULL | 'both'
profiles | occupation | character varying | NULL |
users | state | character varying | NULL |
```
**Результат:** Все колонки присутствуют ✅
### Статус бота:
```
🎉 Bot initialized successfully!
🤖 Bot is running and ready to match people!
📱 Bot username: @seoulmate_officialbot
```
**Результат:** Бот запущен успешно ✅
---
## 📊 Статистика изменений
- **Добавлено колонок:** 3 (`job`, `state`, `interested_in`)
- **Обновлено триггеров:** 1 (`create_initial_profile`)
- **Создано индексов:** 3 (`idx_profiles_job`, `idx_users_state`, `idx_profiles_interested_in`)
- **Файлов патчей:** 2
- **Создано документации:** 3 файла (DATABASE_FIXES.md, HEALTH_CHECK.md, этот файл)
- **Создано утилит:** 1 (`bin/apply_all_patches.sh`)
---
## 🚀 Дальнейшие действия
### Обязательно:
- ✅ Протестировать создание профиля через бота
- ✅ Проверить обновление профиля (поле job)
- ✅ Убедиться что свайпы работают
### Опционально:
- ⚠️ Рассмотреть объединение `job` и `occupation` в одну колонку
- ⚠️ Рассмотреть объединение `looking_for` и `interested_in` в одну колонку
- ⚠️ Исправить ES module warnings в миграциях (низкий приоритет)
- ⚠️ Настроить DEEPSEEK_API_KEY если нужны AI-фичи
---
## 📚 Связанная документация
- `/docs/DATABASE_FIXES.md` - Подробное описание всех исправлений БД
- `/docs/HEALTH_CHECK.md` - Чеклист проверки здоровья бота
- `/bin/README.md` - Описание утилит и скриптов
- `/bin/apply_all_patches.sh` - Скрипт автоматического применения патчей
---
## 👤 Авторство
**Дата:** 2025-11-06
**Автор:** GitHub Copilot
**Проект:** Telegram Tinder Bot
**Версия:** 2.0
---
## ✨ Заключение
Все критические ошибки устранены. Бот готов к работе в production-среде с внешним PostgreSQL сервером (192.168.0.102:5432).
**Статус проекта:** 🟢 РАБОТОСПОСОБЕН

199
docs/HEALTH_CHECK.md Normal file
View File

@@ -0,0 +1,199 @@
# Быстрая проверка здоровья бота
Используйте этот чеклист после развёртывания или обновления бота.
## ✅ Чеклист проверки
### 1. Проверка контейнера
```bash
docker compose ps
# Ожидается: telegram-tinder-bot в состоянии "running" (healthy)
```
### 2. Проверка логов (нет критичных ошибок)
```bash
docker compose logs bot --tail 50
# ✅ Должно быть: "Bot initialized successfully"
# ✅ Должно быть: "Bot username: @your_bot_name"
# ❌ НЕ должно быть: "column X does not exist" (критическая ошибка)
```
### 3. Проверка схемы БД
```bash
# Проверка критичных колонок
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name << 'EOF'
SELECT
table_name,
column_name,
data_type,
is_nullable
FROM information_schema.columns
WHERE table_name IN ('users', 'profiles')
AND column_name IN ('state', 'job', 'looking_for', 'interested_in', 'occupation')
ORDER BY table_name, column_name;
EOF
```
**Ожидаемый результат:**
```
table_name | column_name | data_type | is_nullable
------------+----------------+--------------------+-------------
profiles | interested_in | character varying | YES
profiles | job | character varying | YES
profiles | looking_for | character varying | YES
profiles | occupation | character varying | YES
users | state | character varying | YES
```
### 4. Проверка триггера
```bash
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name -c \
"SELECT proname FROM pg_proc WHERE proname = 'create_initial_profile';"
```
**Ожидается:** `create_initial_profile` (1 строка)
### 5. Проверка индексов
```bash
PGPASSWORD='your_password' psql -h host -p 5432 -U user -d db_name << 'EOF'
SELECT indexname
FROM pg_indexes
WHERE indexname IN ('idx_profiles_job', 'idx_users_state', 'idx_profiles_interested_in');
EOF
```
**Ожидается:** 3 строки с названиями индексов
---
## 🔧 Типичные проблемы и решения
### Проблема: "column job does not exist"
**Решение:**
```bash
./bin/apply_all_patches.sh
docker compose restart bot
```
### Проблема: "State column does not exist" (много раз)
**Решение:**
```bash
PGPASSWORD='password' psql -h host -p 5432 -U user -d db_name \
-c "ALTER TABLE users ADD COLUMN IF NOT EXISTS state VARCHAR(50);"
docker compose restart bot
```
### Проблема: "looking_for violates not-null constraint"
**Решение:**
```bash
PGPASSWORD='password' psql -h host -p 5432 -U user -d db_name \
-f sql/fix_looking_for_column.sql
docker compose restart bot
```
### Проблема: Бот не запускается (exit code 1)
**Диагностика:**
```bash
docker compose logs bot --tail 100
# Ищите строки с ERROR или "does not exist"
```
**Решения:**
1. Проверьте `.env` - все переменные DB_* заданы?
2. Проверьте подключение к БД: `PGPASSWORD='password' psql -h host -p 5432 -U user -d db_name -c "SELECT 1;"`
3. Примените все патчи: `./bin/apply_all_patches.sh`
4. Пересоберите контейнер: `make update`
---
## 📊 Быстрая диагностика одной командой
```bash
# Создайте alias для удобства
alias bot-health='docker compose ps && echo "=== LOGS ===" && docker compose logs bot --tail 20'
# Использование
bot-health
```
---
## 🚀 Команды для разработки
```bash
# Полное обновление (pull + rebuild + migrate + restart)
make update
# Применение миграций
make migrate
# Только перезапуск
docker compose restart bot
# Пересборка с нуля
make clean && make install
# Проверка синтаксиса TypeScript
npm run build
# Запуск в режиме разработки (локально)
npm run dev
```
---
## 📝 Переменные окружения (.env)
Обязательные:
```env
DB_HOST=192.168.0.102
DB_PORT=5432
DB_NAME=telegram_tinder_bot
DB_USERNAME=trevor
DB_PASSWORD=your_secure_password
TELEGRAM_BOT_TOKEN=your_bot_token
JWT_SECRET=your_jwt_secret
APP_SECRET=your_app_secret
NODE_ENV=production
PORT=3000
```
Опциональные:
```env
DEEPSEEK_API_KEY=your_deepseek_key # Для AI фич
LOG_LEVEL=info # debug | info | warn | error
```
---
## 🆘 Экстренное восстановление
Если бот полностью сломан:
```bash
# 1. Остановить всё
docker compose down
# 2. Сделать бэкап БД
./bin/backup_db.sh
# 3. Откатить к последнему коммиту
git reset --hard HEAD
# 4. Применить все патчи заново
./bin/apply_all_patches.sh
# 5. Пересобрать
make install
# 6. Запустить
docker compose up -d
```
---
**Версия:** 1.0
**Дата:** 2025-11-06
**Для:** Telegram Tinder Bot v2.0

BIN
new_docker-keyring.gpg Normal file

Binary file not shown.

View File

@@ -1,15 +1,16 @@
// add-hobbies-column.js
// Скрипт для добавления колонки hobbies в таблицу profiles
require('dotenv').config();
const { Pool } = require('pg');
// Настройки подключения к базе данных
// Настройки подключения к базе данных из переменных окружения
const pool = new Pool({
host: '192.168.0.102',
port: 5432,
database: 'telegram_tinder_bot',
user: 'trevor',
password: 'Cl0ud_1985!'
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
database: process.env.DB_NAME || 'telegram_tinder_bot',
user: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD
});
async function addHobbiesColumn() {

View File

@@ -1,4 +1,5 @@
// Исправленный код для создания профиля
require('dotenv').config();
const { Client } = require('pg');
const { v4: uuidv4 } = require('uuid');
@@ -18,13 +19,13 @@ if (!telegramId || !name || !age || !gender || !city || !bio || !photoFileId) {
process.exit(1);
}
// Устанавливаем соединение с базой данных
// Устанавливаем соединение с базой данных из переменных окружения
const client = new Client({
host: '192.168.0.102',
port: 5432,
user: 'trevor',
password: 'Cl0ud_1985!',
database: 'telegram_tinder_bot'
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
user: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'telegram_tinder_bot'
});
async function createProfile() {

View File

@@ -2,17 +2,18 @@
// Этот скрипт создает записи о миграциях в таблице pgmigrations без применения изменений
// Используется для синхронизации существующей базы с миграциями
require('dotenv').config();
const { Client } = require('pg');
const fs = require('fs');
const path = require('path');
// Подключение к базе данных
// Подключение к базе данных из переменных окружения
const client = new Client({
host: '192.168.0.102',
port: 5432,
database: 'telegram_tinder_bot',
user: 'trevor',
password: 'Cl0ud_1985!'
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
database: process.env.DB_NAME || 'telegram_tinder_bot',
user: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD
});
async function syncMigrations() {

View File

@@ -0,0 +1,36 @@
-- Добавление колонок job и state
-- Дата: 2025-11-06
-- Исправляет ошибки: "column job does not exist" и "State column does not exist"
-- 1. Добавляем колонку job в таблицу profiles (синоним для occupation)
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS job VARCHAR(255);
-- 2. Копируем существующие данные из occupation в job
UPDATE profiles SET job = occupation WHERE occupation IS NOT NULL AND job IS NULL;
-- 3. Добавляем колонку state в таблицу users для отслеживания состояния диалога
ALTER TABLE users ADD COLUMN IF NOT EXISTS state VARCHAR(50);
-- 4. Создаём индексы для производительности
CREATE INDEX IF NOT EXISTS idx_profiles_job ON profiles(job);
CREATE INDEX IF NOT EXISTS idx_users_state ON users(state);
-- 5. Добавляем комментарии для документации
COMMENT ON COLUMN profiles.job IS 'Профессия/работа пользователя (синоним для occupation)';
COMMENT ON COLUMN profiles.occupation IS 'Профессия/работа пользователя (устаревшее, используйте job)';
COMMENT ON COLUMN users.state IS 'Текущее состояние пользователя в диалоге с ботом';
-- Проверка результата
SELECT
'profiles.job' as column_name,
CASE WHEN EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'profiles' AND column_name = 'job'
) THEN '✅ Существует' ELSE 'Не найдена' END as status
UNION ALL
SELECT
'users.state' as column_name,
CASE WHEN EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'state'
) THEN '✅ Существует' ELSE 'Не найдена' END as status;

View File

@@ -0,0 +1,43 @@
-- Исправление триггера create_initial_profile и колонки looking_for
-- Дата: 2025-11-06
-- 1. Удаляем старую функцию триггера
DROP FUNCTION IF EXISTS create_initial_profile() CASCADE;
-- 2. Создаём исправленную функцию с полем looking_for
CREATE OR REPLACE FUNCTION create_initial_profile()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO profiles (user_id, name, age, gender, looking_for, interested_in)
VALUES (NEW.id, COALESCE(NEW.first_name, 'User'), 18, 'other', 'both', 'both');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 3. Пересоздаём триггер
DROP TRIGGER IF EXISTS create_profile_on_user_insert ON users;
DROP TRIGGER IF EXISTS create_profile_trigger ON users;
CREATE TRIGGER create_profile_on_user_insert
AFTER INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION create_initial_profile();
-- 4. Делаем looking_for необязательным с дефолтным значением
ALTER TABLE profiles ALTER COLUMN looking_for DROP NOT NULL;
ALTER TABLE profiles ALTER COLUMN looking_for SET DEFAULT 'both';
-- 5. Добавляем interested_in как синоним для looking_for
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS interested_in VARCHAR(20) DEFAULT 'both'
CHECK (interested_in IN ('male', 'female', 'both'));
-- 6. Обновляем существующие записи
UPDATE profiles SET looking_for = 'both' WHERE looking_for IS NULL;
UPDATE profiles SET interested_in = COALESCE(looking_for, 'both') WHERE interested_in IS NULL;
-- 7. Создаём индекс для поиска по interested_in
CREATE INDEX IF NOT EXISTS idx_profiles_interested_in ON profiles(interested_in);
COMMENT ON COLUMN profiles.looking_for IS 'Предпочитаемый пол для знакомства (устаревшее, используйте interested_in)';
COMMENT ON COLUMN profiles.interested_in IS 'Предпочитаемый пол для знакомства: male, female, both';

82
sql/fix_match_trigger.sql Normal file
View File

@@ -0,0 +1,82 @@
-- Исправление триггера create_match_on_mutual_like
-- Дата: 2025-11-06
-- Проблемы:
-- 1. Использовались неправильные имена полей: target_id вместо target_user_id, action вместо type
-- 2. Использовались несуществующие колонки в notifications: content и reference_id вместо data
-- Удаляем старую функцию
DROP FUNCTION IF EXISTS create_match_on_mutual_like() CASCADE;
-- Создаем исправленную функцию
CREATE OR REPLACE FUNCTION create_match_on_mutual_like()
RETURNS TRIGGER AS $$
DECLARE
reverse_like_exists BOOLEAN;
match_id_var UUID;
BEGIN
-- Проверяем только лайки и суперлайки (игнорируем pass)
IF NEW.type != 'like' AND NEW.type != 'superlike' THEN
RETURN NEW;
END IF;
-- Проверяем есть ли обратный лайк (правильные имена полей: target_user_id, type)
SELECT EXISTS (
SELECT 1
FROM swipes
WHERE user_id = NEW.target_user_id
AND target_user_id = NEW.user_id
AND type IN ('like', 'superlike')
) INTO reverse_like_exists;
-- Если есть взаимный лайк, создаем матч
IF reverse_like_exists THEN
-- Создаем матч и получаем его ID
INSERT INTO matches (user_id_1, user_id_2, created_at, is_active)
VALUES (
LEAST(NEW.user_id, NEW.target_user_id),
GREATEST(NEW.user_id, NEW.target_user_id),
NOW(),
true
)
ON CONFLICT (user_id_1, user_id_2) DO UPDATE SET is_active = true
RETURNING id INTO match_id_var;
-- Создаем уведомления для обоих пользователей
-- Используем data (jsonb) вместо content и reference_id
INSERT INTO notifications (user_id, type, data, is_read, created_at)
VALUES (
NEW.user_id,
'new_match',
jsonb_build_object('message', 'У вас новый матч!', 'match_id', match_id_var),
false,
NOW()
);
INSERT INTO notifications (user_id, type, data, is_read, created_at)
VALUES (
NEW.target_user_id,
'new_match',
jsonb_build_object('message', 'У вас новый матч!', 'match_id', match_id_var),
false,
NOW()
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Пересоздаем триггер
DROP TRIGGER IF EXISTS create_match_trigger ON swipes;
CREATE TRIGGER create_match_trigger
AFTER INSERT ON swipes
FOR EACH ROW
EXECUTE FUNCTION create_match_on_mutual_like();
-- Проверка
SELECT 'Триггер create_match_on_mutual_like успешно исправлен!' as status;
COMMENT ON FUNCTION create_match_on_mutual_like() IS
'Триггер автоматически создает матч при взаимном лайке.
Исправлено: использование правильных имен полей (target_user_id, type)
и правильной структуры notifications (data jsonb).';

View File

@@ -0,0 +1,49 @@
-- Исправление триггера notify_new_message для использования правильной схемы notifications
-- Проблема: триггер использует content и reference_id вместо data (jsonb)
-- Удаляем старый триггер и функцию
DROP TRIGGER IF EXISTS notify_new_message_trigger ON messages;
DROP FUNCTION IF EXISTS notify_new_message();
-- Создаём новую функцию с правильной схемой
CREATE OR REPLACE FUNCTION notify_new_message()
RETURNS TRIGGER AS $$
DECLARE
recipient_id UUID;
BEGIN
-- Определяем получателя сообщения (второго участника матча)
SELECT CASE
WHEN m.user_id_1 = NEW.sender_id THEN m.user_id_2
ELSE m.user_id_1
END INTO recipient_id
FROM matches m
WHERE m.id = NEW.match_id;
-- Создаём уведомление с правильной структурой (data jsonb)
IF recipient_id IS NOT NULL THEN
INSERT INTO notifications (user_id, type, data, created_at)
VALUES (
recipient_id,
'new_message',
jsonb_build_object(
'message_id', NEW.id,
'match_id', NEW.match_id,
'sender_id', NEW.sender_id,
'content_preview', LEFT(NEW.content, 50)
),
NOW()
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Создаём триггер заново
CREATE TRIGGER notify_new_message_trigger
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION notify_new_message();
-- Проверка
SELECT 'Trigger notify_new_message fixed successfully' AS status;

View File

@@ -0,0 +1,46 @@
-- Исправление триггера update_last_active для работы с messages и swipes
-- Проблема: в messages есть sender_id, а в swipes есть user_id
-- Удаляем старые триггеры
DROP TRIGGER IF EXISTS update_last_active_on_message ON messages;
DROP TRIGGER IF EXISTS update_last_active_on_swipe ON swipes;
DROP FUNCTION IF EXISTS update_last_active();
-- Создаём функцию для обновления last_active для отправителя сообщения
CREATE OR REPLACE FUNCTION update_last_active_on_message()
RETURNS TRIGGER AS $$
BEGIN
UPDATE profiles
SET last_active = CURRENT_TIMESTAMP
WHERE user_id = NEW.sender_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Создаём функцию для обновления last_active при свайпе
CREATE OR REPLACE FUNCTION update_last_active_on_swipe()
RETURNS TRIGGER AS $$
BEGIN
UPDATE profiles
SET last_active = CURRENT_TIMESTAMP
WHERE user_id = NEW.user_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Создаём триггер для messages
CREATE TRIGGER update_last_active_on_message
AFTER INSERT ON messages
FOR EACH ROW
EXECUTE FUNCTION update_last_active_on_message();
-- Создаём триггер для swipes
CREATE TRIGGER update_last_active_on_swipe
AFTER INSERT ON swipes
FOR EACH ROW
EXECUTE FUNCTION update_last_active_on_swipe();
-- Проверка
SELECT 'Triggers update_last_active fixed successfully' AS status;

View File

@@ -34,7 +34,7 @@ class TelegramTinderBot {
this.localizationService = LocalizationService.getInstance();
this.commandHandlers = new CommandHandlers(this.bot);
this.messageHandlers = new MessageHandlers(this.bot);
this.messageHandlers = new MessageHandlers(this.bot, this.notificationService);
this.callbackHandlers = new CallbackHandlers(this.bot, this.messageHandlers);
this.notificationHandlers = new NotificationHandlers(this.bot);

View File

@@ -31,7 +31,9 @@ export class CallbackHandlers {
this.bot = bot;
this.profileService = new ProfileService();
this.matchingService = new MatchingService();
this.chatService = new ChatService();
// Получаем notificationService из messageHandlers (если есть)
const notificationService = (messageHandlers as any).notificationService;
this.chatService = new ChatService(notificationService);
this.messageHandlers = messageHandlers;
this.profileEditController = new ProfileEditController(this.profileService);
this.enhancedChatHandlers = new EnhancedChatHandlers(bot);
@@ -86,6 +88,107 @@ export class CallbackHandlers {
await this.handleEditHobbies(chatId, telegramId);
} else if (data === 'edit_city') {
await this.handleEditCity(chatId, telegramId);
} else if (data === 'confirm_city') {
try {
const states = (this.messageHandlers as any).userStates as Map<string, any>;
const userState = states ? states.get(telegramId) : null;
if (userState && userState.step === 'confirm_city') {
// Подтверждаем город и переводим пользователя к вводу био
userState.step = 'waiting_bio';
console.log(`User ${telegramId} confirmed city: ${userState.data.city}`);
// Убираем inline-кнопки из сообщения с подтверждением
try {
// clear inline keyboard
await this.bot.editMessageReplyMarkup({ inline_keyboard: [] } as any, { chat_id: chatId, message_id: query.message?.message_id });
} catch (e) {
// ignore
}
await this.bot.sendMessage(chatId, `✅ Город подтверждён: *${userState.data.city}*\n\n📝 Теперь расскажите немного о себе (био):`, { parse_mode: 'Markdown' });
} else {
await this.bot.answerCallbackQuery(query.id, { text: 'Контекст не найден. Повторите, пожалуйста.' });
}
} catch (error) {
console.error('Error confirming city via callback:', error);
await this.bot.answerCallbackQuery(query.id, { text: 'Ошибка при подтверждении города' });
}
} else if (data === 'edit_city_manual') {
try {
const states = (this.messageHandlers as any).userStates as Map<string, any>;
const userState = states ? states.get(telegramId) : null;
if (userState) {
userState.step = 'waiting_city';
console.log(`User ${telegramId} chose to enter city manually`);
try {
// clear inline keyboard
await this.bot.editMessageReplyMarkup({ inline_keyboard: [] } as any, { chat_id: chatId, message_id: query.message?.message_id });
} catch (e) { }
await this.bot.sendMessage(chatId, '✏️ Введите название вашего города вручную:', { reply_markup: { remove_keyboard: true } });
} else {
await this.bot.answerCallbackQuery(query.id, { text: 'Контекст не найден. Повторите, пожалуйста.' });
}
} catch (error) {
console.error('Error switching to manual city input via callback:', error);
await this.bot.answerCallbackQuery(query.id, { text: 'Ошибка' });
}
} else if (data === 'confirm_city_edit') {
try {
const editState = this.messageHandlers.profileEditStates.get(telegramId);
if (editState && editState.field === 'city' && editState.tempCity) {
console.log(`User ${telegramId} confirmed city edit: ${editState.tempCity}`);
// Обновляем город в профиле
await this.messageHandlers.updateProfileField(telegramId, 'city', editState.tempCity);
// Очищаем состояние
this.messageHandlers.clearProfileEditState(telegramId);
// Убираем inline-кнопки
try {
await this.bot.editMessageReplyMarkup({ inline_keyboard: [] } as any, { chat_id: chatId, message_id: query.message?.message_id });
} catch (e) { }
await this.bot.sendMessage(chatId, '✅ Город успешно обновлён!');
setTimeout(async () => {
const keyboard = {
inline_keyboard: [
[
{ text: '✏️ Продолжить редактирование', callback_data: 'edit_profile' },
{ text: '👀 Предпросмотр', callback_data: 'preview_profile' }
],
[{ text: '🏠 Главное меню', callback_data: 'main_menu' }]
]
};
await this.bot.sendMessage(chatId, 'Выберите действие:', { reply_markup: keyboard });
}, 500);
} else {
await this.bot.answerCallbackQuery(query.id, { text: 'Контекст не найден. Повторите, пожалуйста.' });
}
} catch (error) {
console.error('Error confirming city edit via callback:', error);
await this.bot.answerCallbackQuery(query.id, { text: 'Ошибка при подтверждении города' });
}
} else if (data === 'edit_city_manual_edit') {
try {
const editState = this.messageHandlers.profileEditStates.get(telegramId);
if (editState && editState.field === 'city') {
console.log(`User ${telegramId} chose to re-enter city during edit`);
// Очищаем временный город, но оставляем состояние редактирования
delete editState.tempCity;
try {
await this.bot.editMessageReplyMarkup({ inline_keyboard: [] } as any, { chat_id: chatId, message_id: query.message?.message_id });
} catch (e) { }
await this.bot.sendMessage(chatId, '✏️ Введите название вашего города вручную или отправьте геолокацию:', {
reply_markup: {
keyboard: [
[{ text: '📍 Отправить геолокацию', request_location: true }]
],
resize_keyboard: true,
one_time_keyboard: true
}
});
} else {
await this.bot.answerCallbackQuery(query.id, { text: 'Контекст не найден. Повторите, пожалуйста.' });
}
} catch (error) {
console.error('Error switching to re-enter city during edit via callback:', error);
await this.bot.answerCallbackQuery(query.id, { text: 'Ошибка' });
}
} else if (data === 'edit_job') {
await this.handleEditJob(chatId, telegramId);
} else if (data === 'edit_education') {
@@ -184,6 +287,12 @@ export class CallbackHandlers {
await this.likeBackHandler.handleLikeBack(chatId, telegramId, targetUserId);
}
// Быстрый переход в чат из уведомлений
else if (data.startsWith('open_chat:')) {
const matchId = data.replace('open_chat:', '');
await this.enhancedChatHandlers.openNativeChat(chatId, telegramId, matchId);
}
// Матчи и чаты
else if (data === 'view_matches') {
await this.handleViewMatches(chatId, telegramId);
@@ -975,14 +1084,22 @@ export class CallbackHandlers {
profileText += '\n📝 ' + (profile.bio || 'Описание не указано') + '\n';
// Хобби с хэштегами
if (profile.hobbies && profile.hobbies.trim()) {
const hobbiesArray = profile.hobbies.split(',').map(hobby => hobby.trim()).filter(hobby => hobby);
const formattedHobbies = hobbiesArray.map(hobby => '#' + hobby).join(' ');
profileText += '\n🎯 ' + formattedHobbies + '\n';
if (profile.hobbies) {
let hobbiesArray: string[] = [];
if (typeof profile.hobbies === 'string') {
hobbiesArray = profile.hobbies.split(',').map(hobby => hobby.trim()).filter(hobby => hobby);
} else if (Array.isArray(profile.hobbies)) {
hobbiesArray = (profile.hobbies as string[]).filter((hobby: string) => hobby && hobby.trim());
}
if (hobbiesArray.length > 0) {
const formattedHobbies = hobbiesArray.map(hobby => '#' + hobby).join(' ');
profileText += '\n🎯 ' + formattedHobbies + '\n';
}
}
if (profile.interests.length > 0) {
profileText += '\n<EFBFBD> Интересы: ' + profile.interests.join(', ');
profileText += '\n💡 Интересы: ' + profile.interests.join(', ');
}
let keyboard: InlineKeyboardMarkup;
@@ -1192,8 +1309,15 @@ export class CallbackHandlers {
// Редактирование города
async handleEditCity(chatId: number, telegramId: string): Promise<void> {
this.messageHandlers.setWaitingForInput(parseInt(telegramId), 'city');
await this.bot.sendMessage(chatId, '🏙️ *Введите ваш город:*\n\nНапример: Москва', {
parse_mode: 'Markdown'
await this.bot.sendMessage(chatId, '🏙️ *Укажите ваш город:*\n\nВыберите один из вариантов:', {
parse_mode: 'Markdown',
reply_markup: {
keyboard: [
[{ text: '📍 Отправить геолокацию', request_location: true }]
],
resize_keyboard: true,
one_time_keyboard: true
}
});
}
@@ -1762,10 +1886,18 @@ export class CallbackHandlers {
candidateText += '\n📝 ' + (candidate.bio || 'Описание отсутствует') + '\n';
// Хобби с хэштегами
if (candidate.hobbies && candidate.hobbies.trim()) {
const hobbiesArray = candidate.hobbies.split(',').map(hobby => hobby.trim()).filter(hobby => hobby);
const formattedHobbies = hobbiesArray.map(hobby => '#' + hobby).join(' ');
candidateText += '\n🎯 ' + formattedHobbies + '\n';
if (candidate.hobbies) {
let hobbiesArray: string[] = [];
if (typeof candidate.hobbies === 'string') {
hobbiesArray = candidate.hobbies.split(',').map(hobby => hobby.trim()).filter(hobby => hobby);
} else if (Array.isArray(candidate.hobbies)) {
hobbiesArray = (candidate.hobbies as string[]).filter((hobby: string) => hobby && hobby.trim());
}
if (hobbiesArray.length > 0) {
const formattedHobbies = hobbiesArray.map(hobby => '#' + hobby).join(' ');
candidateText += '\n🎯 ' + formattedHobbies + '\n';
}
}
if (candidate.interests.length > 0) {

View File

@@ -11,9 +11,9 @@ export class EnhancedChatHandlers {
constructor(bot: TelegramBot) {
this.bot = bot;
this.chatService = new ChatService();
this.profileService = new ProfileService();
this.notificationService = new NotificationService(bot);
this.chatService = new ChatService(this.notificationService);
}
// ===== НАТИВНЫЙ ИНТЕРФЕЙС ЧАТОВ =====

View File

@@ -2,6 +2,7 @@ import TelegramBot, { Message, InlineKeyboardMarkup } from 'node-telegram-bot-ap
import { ProfileService } from '../services/profileService';
import { ChatService } from '../services/chatService';
import { EnhancedChatHandlers } from './enhancedChatHandlers';
import { KakaoMapService } from '../services/kakaoMapService';
// Состояния пользователей для создания профилей
interface UserState {
@@ -19,6 +20,7 @@ interface ChatState {
interface ProfileEditState {
waitingForInput: boolean;
field: string;
tempCity?: string; // Временное хранение города для подтверждения
}
export class MessageHandlers {
@@ -26,15 +28,27 @@ export class MessageHandlers {
private profileService: ProfileService;
private chatService: ChatService;
private enhancedChatHandlers: EnhancedChatHandlers;
private notificationService: any;
private kakaoMapService: KakaoMapService | null = null;
private userStates: Map<string, UserState> = new Map();
private chatStates: Map<string, ChatState> = new Map();
private profileEditStates: Map<string, ProfileEditState> = new Map();
public profileEditStates: Map<string, ProfileEditState> = new Map();
constructor(bot: TelegramBot) {
constructor(bot: TelegramBot, notificationService?: any) {
this.bot = bot;
this.profileService = new ProfileService();
this.chatService = new ChatService();
this.notificationService = notificationService;
this.chatService = new ChatService(notificationService);
this.enhancedChatHandlers = new EnhancedChatHandlers(bot);
// Инициализируем Kakao Maps, если есть API ключ
const kakaoApiKey = process.env.KAKAO_REST_API_KEY || process.env.KAKAO_MAP_REST_KEY;
if (kakaoApiKey) {
this.kakaoMapService = new KakaoMapService(kakaoApiKey);
console.log('✅ Kakao Maps service initialized');
} else {
console.warn('⚠️ KAKAO_REST_API_KEY or KAKAO_MAP_REST_KEY not found, location features will be limited');
}
}
register(): void {
@@ -55,7 +69,7 @@ export class MessageHandlers {
const profileEditState = this.profileEditStates.get(userId);
// Проверяем на нативные чаты (прямые сообщения в контексте чата)
if (msg.text && await this.enhancedChatHandlers.handleIncomingChatMessage(msg.chat.id, msg.text)) {
if (await this.enhancedChatHandlers.handleIncomingChatMessage(msg, userId)) {
return; // Сообщение обработано как сообщение в чате
}
@@ -127,22 +141,68 @@ export class MessageHandlers {
userState.data.age = age;
userState.step = 'waiting_city';
await this.bot.sendMessage(chatId, '📍 Прекрасно! В каком городе вы живете?');
// Запрашиваем геолокацию или текст
await this.bot.sendMessage(
chatId,
'📍 Прекрасно! В каком городе вы живете?\n\n' +
'💡 Вы можете:\n' +
'• Отправить геолокацию 📍 (кнопка ниже)\n' +
'• Написать название города вручную',
{
reply_markup: {
keyboard: [
[{ text: '📍 Отправить геолокацию', request_location: true }]
],
one_time_keyboard: true,
resize_keyboard: true
}
}
);
break;
case 'waiting_city':
if (!msg.text) {
await this.bot.sendMessage(chatId, '❌ Пожалуйста, отправьте название города');
// Обработка геолокации
if (msg.location) {
await this.handleLocationForCity(msg, userId, userState);
return;
}
userState.data.city = msg.text.trim();
userState.step = 'waiting_bio';
// Обработка текста
if (!msg.text) {
await this.bot.sendMessage(chatId, '❌ Пожалуйста, отправьте название города или геолокацию');
return;
}
const cityInput = msg.text.trim();
console.log(`User ${userId} entered city manually: ${cityInput}`);
// Временно сохраняем город и запрашиваем подтверждение
userState.data.city = cityInput;
userState.step = 'confirm_city';
await this.bot.sendMessage(
chatId,
'📝 Теперь расскажите немного о себе (био):\n\n' +
'💡 Например: хобби, интересы, что ищете в отношениях и т.д.'
chatId,
`<EFBFBD> Вы указали город: *${cityInput}*\n\n` +
'Подтвердите или введите заново.',
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: '✅ Подтвердить', callback_data: 'confirm_city' },
{ text: '✏️ Ввести заново', callback_data: 'edit_city_manual' }
]
]
}
}
);
break;
case 'confirm_city':
// Этот случай обрабатывается через callback_data, но на случай если пользователь напишет текст
await this.bot.sendMessage(
chatId,
'⚠️ Пожалуйста, используйте кнопки для подтверждения города.'
);
break;
@@ -428,6 +488,47 @@ export class MessageHandlers {
}
value = distance;
break;
case 'hobbies':
// Разбиваем строку с запятыми на массив
if (typeof value === 'string') {
value = value.split(',').map(hobby => hobby.trim()).filter(hobby => hobby.length > 0);
}
break;
case 'city':
// Обработка города: поддержка геолокации и текстового ввода
if (msg.location) {
// Обработка геолокации
await this.handleLocationForCityEdit(msg, userId);
return; // Выходим из функции, так как требуется подтверждение
} else if (msg.text) {
// Обработка текстового ввода города
const cityInput = msg.text.trim();
console.log(`User ${userId} entered city manually during edit: ${cityInput}`);
// Сохраняем временно в состояние редактирования
const editState = this.profileEditStates.get(userId);
if (editState) {
editState.tempCity = cityInput;
}
// Требуем подтверждения
await this.bot.sendMessage(chatId, `📍 Вы указали город: *${cityInput}*\n\одтвердите или введите заново.`, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: '✅ Подтвердить', callback_data: 'confirm_city_edit' },
{ text: '✏️ Ввести заново', callback_data: 'edit_city_manual_edit' }
]
]
}
});
return; // Выходим, ждем подтверждения
} else {
isValid = false;
errorMessage = '❌ Пожалуйста, отправьте название города или геолокацию';
break;
}
}
if (!isValid) {
@@ -537,4 +638,194 @@ export class MessageHandlers {
await this.profileService.updateProfile(profile.userId, updates);
}
}
// Обработка геолокации для определения города
private async handleLocationForCity(msg: Message, userId: string, userState: UserState): Promise<void> {
const chatId = msg.chat.id;
if (!msg.location) {
await this.bot.sendMessage(chatId, '❌ Не удалось получить геолокацию');
return;
}
try {
// Показываем индикатор загрузки
await this.bot.sendChatAction(chatId, 'typing');
if (!this.kakaoMapService) {
// Если Kakao Maps не настроен, используем координаты как есть
await this.bot.sendMessage(
chatId,
'⚠️ Автоматическое определение города недоступно.\n' +
'Пожалуйста, введите название вашего города вручную.',
{ reply_markup: { remove_keyboard: true } }
);
return;
}
// Показываем индикатор загрузки
await this.bot.sendChatAction(chatId, 'typing');
if (!this.kakaoMapService) {
// Если Kakao Maps не настроен, используем координаты как есть
console.warn(`KakaoMaps not configured - user ${userId} sent coords: ${msg.location.latitude},${msg.location.longitude}`);
await this.bot.sendMessage(
chatId,
'⚠️ Автоматическое определение города недоступно.\n' +
'Пожалуйста, введите название вашего города вручную.',
{ reply_markup: { remove_keyboard: true } }
);
return;
}
// Логируем входные координаты
console.log(`Processing coordinates for user ${userId}: lat=${msg.location.latitude}, lon=${msg.location.longitude}`);
// Получаем адрес через Kakao Maps
const address = await this.kakaoMapService.getAddressFromCoordinates(
msg.location.latitude,
msg.location.longitude
);
if (!address) {
console.warn(`KakaoMaps returned no address for user ${userId} coords: ${msg.location.latitude},${msg.location.longitude}`);
await this.bot.sendMessage(
chatId,
'❌ Не удалось определить город по вашей геолокации.\n' +
'Пожалуйста, введите название города вручную.',
{ reply_markup: { remove_keyboard: true } }
);
return;
}
// Получаем название города для сохранения
const cityName = this.kakaoMapService.getCityNameForProfile(address);
const displayAddress = this.kakaoMapService.formatAddressForDisplay(address);
// Логируем результат
console.log(`KakaoMaps resolved for user ${userId}: city=${cityName}, address=${displayAddress}`);
// Временно сохраняем город (пока не подтвержден пользователем)
userState.data.city = cityName;
userState.step = 'confirm_city';
// Отправляем пользователю информацию с кнопками подтверждения
await this.bot.sendMessage(
chatId,
`✅ Мы распознали местоположение: *${displayAddress}*\n\n` +
'Пожалуйста, подтвердите город проживания или введите название вручную.',
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: '✅ Подтвердить', callback_data: 'confirm_city' },
{ text: '✏️ Ввести вручную', callback_data: 'edit_city_manual' }
]
]
}
}
);
await this.bot.sendMessage(
chatId,
'📝 Теперь расскажите немного о себе (био):\n\n' +
'💡 Например: хобби, интересы, что ищете в отношениях и т.д.'
);
} catch (error) {
console.error('Error handling location for city:', error);
await this.bot.sendMessage(
chatId,
'❌ Произошла ошибка при определении города.\n' +
'Пожалуйста, введите название города вручную.',
{ reply_markup: { remove_keyboard: true } }
);
}
}
// Обработка геолокации для определения города при редактировании профиля
private async handleLocationForCityEdit(msg: Message, userId: string): Promise<void> {
const chatId = msg.chat.id;
if (!msg.location) {
await this.bot.sendMessage(chatId, '❌ Не удалось получить геолокацию');
return;
}
try {
// Показываем индикатор загрузки
await this.bot.sendChatAction(chatId, 'typing');
if (!this.kakaoMapService) {
console.warn(`KakaoMaps not configured - user ${userId} sent coords during edit: ${msg.location.latitude},${msg.location.longitude}`);
await this.bot.sendMessage(
chatId,
'⚠️ Автоматическое определение города недоступно.\n' +
'Пожалуйста, введите название вашего города вручную.',
{ reply_markup: { remove_keyboard: true } }
);
return;
}
// Логируем входные координаты
console.log(`Processing coordinates for user ${userId} during edit: lat=${msg.location.latitude}, lon=${msg.location.longitude}`);
// Получаем адрес через Kakao Maps
const address = await this.kakaoMapService.getAddressFromCoordinates(
msg.location.latitude,
msg.location.longitude
);
if (!address) {
console.warn(`KakaoMaps returned no address for user ${userId} during edit coords: ${msg.location.latitude},${msg.location.longitude}`);
await this.bot.sendMessage(
chatId,
'❌ Не удалось определить город по вашей геолокации.\n' +
'Пожалуйста, введите название города вручную.',
{ reply_markup: { remove_keyboard: true } }
);
return;
}
// Получаем название города для сохранения
const cityName = this.kakaoMapService.getCityNameForProfile(address);
const displayAddress = this.kakaoMapService.formatAddressForDisplay(address);
// Логируем результат
console.log(`KakaoMaps resolved for user ${userId} during edit: city=${cityName}, address=${displayAddress}`);
// Временно сохраняем город в состояние редактирования
const editState = this.profileEditStates.get(userId);
if (editState) {
editState.tempCity = cityName;
}
// Отправляем пользователю информацию с кнопками подтверждения
await this.bot.sendMessage(
chatId,
`✅ Мы распознали местоположение: *${displayAddress}*\n\n` +
'Пожалуйста, подтвердите город проживания или введите название вручную.',
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: '✅ Подтвердить', callback_data: 'confirm_city_edit' },
{ text: '✏️ Ввести вручную', callback_data: 'edit_city_manual_edit' }
]
]
}
}
);
} catch (error) {
console.error('Error handling location for city during edit:', error);
await this.bot.sendMessage(
chatId,
'❌ Произошла ошибка при определении города.\n' +
'Пожалуйста, введите название города вручную.',
{ reply_markup: { remove_keyboard: true } }
);
}
}
}

View File

@@ -6,9 +6,11 @@ import { v4 as uuidv4 } from 'uuid';
export class ChatService {
private profileService: ProfileService;
private notificationService: any; // Добавим позже правильный тип
constructor() {
constructor(notificationService?: any) {
this.profileService = new ProfileService();
this.notificationService = notificationService;
}
// Получить все чаты (матчи) пользователя
@@ -149,7 +151,7 @@ export class ChatService {
}
const row = messageResult.rows[0];
return new Message({
const message = new Message({
id: row.id,
matchId: row.match_id,
senderId: row.sender_id,
@@ -158,6 +160,26 @@ export class ChatService {
isRead: row.is_read,
createdAt: new Date(row.created_at)
});
// Определяем получателя (второго участника матча)
const match = matchResult.rows[0];
const receiverId = match.user_id_1 === senderId ? match.user_id_2 : match.user_id_1;
// Отправляем уведомление получателю о новом сообщении
if (this.notificationService) {
try {
await this.notificationService.sendMessageNotification(
receiverId,
senderId,
content,
matchId
);
} catch (notifError) {
console.error('Error sending message notification:', notifError);
}
}
return message;
} catch (error) {
console.error('Error sending message:', error);
return null;

View File

@@ -0,0 +1,162 @@
import axios from 'axios';
export interface KakaoCoordinates {
latitude: number;
longitude: number;
}
export interface KakaoAddress {
addressName: string; // Полный адрес
region1: string; // Область (예: 서울특별시)
region2: string; // Район (예: 강남구)
region3: string; // Подрайон (예: 역삼동)
city?: string; // Город (обработанный)
}
export class KakaoMapService {
private readonly apiKey: string;
private readonly baseUrl = 'https://dapi.kakao.com/v2/local';
constructor(apiKey: string) {
if (!apiKey) {
throw new Error('Kakao Maps API key is required');
}
this.apiKey = apiKey;
}
/**
* Получить адрес по координатам (Reverse Geocoding)
*/
async getAddressFromCoordinates(latitude: number, longitude: number): Promise<KakaoAddress | null> {
try {
const response = await axios.get(`${this.baseUrl}/geo/coord2address.json`, {
headers: {
'Authorization': `KakaoAK ${this.apiKey}`
},
params: {
x: longitude,
y: latitude,
input_coord: 'WGS84'
}
});
if (response.data && response.data.documents && response.data.documents.length > 0) {
const doc = response.data.documents[0];
const address = doc.address || doc.road_address;
if (!address) {
return null;
}
// Извлекаем город из региона
const city = this.extractCity(address.region_1depth_name, address.region_2depth_name);
return {
addressName: address.address_name || '',
region1: address.region_1depth_name || '',
region2: address.region_2depth_name || '',
region3: address.region_3depth_name || '',
city
};
}
return null;
} catch (error) {
console.error('Error getting address from Kakao Maps:', error);
if (axios.isAxiosError(error)) {
console.error('Response:', error.response?.data);
}
return null;
}
}
/**
* Извлечь название города из регионов
* Примеры:
* - 서울특별시 → Seoul
* - 경기도 수원시 → Suwon
* - 부산광역시 → Busan
*/
private extractCity(region1: string, region2: string): string {
// Убираем суффиксы типов административных единиц
const cleanRegion1 = region1
.replace('특별시', '') // Особый город (Сеул)
.replace('광역시', '') // Метрополитен
.replace('특별자치시', '') // Особый автономный город
.replace('도', '') // Провинция
.trim();
const cleanRegion2 = region2
.replace('시', '') // Город
.replace('군', '') // Округ
.trim();
// Если region1 - это город (Сеул, Пусан, и т.д.), возвращаем его
const specialCities = ['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종'];
if (specialCities.some(city => region1.includes(city))) {
return this.translateCityName(cleanRegion1);
}
// Иначе возвращаем region2 (город в провинции)
if (cleanRegion2) {
return this.translateCityName(cleanRegion2);
}
// Если не удалось определить, возвращаем очищенный region1
return this.translateCityName(cleanRegion1);
}
/**
* Перевод названий городов на английский/латиницу
*/
private translateCityName(koreanName: string): string {
const cityMap: { [key: string]: string } = {
'서울': 'Seoul',
'부산': 'Busan',
'대구': 'Daegu',
'인천': 'Incheon',
'광주': 'Gwangju',
'대전': 'Daejeon',
'울산': 'Ulsan',
'세종': 'Sejong',
'수원': 'Suwon',
'성남': 'Seongnam',
'고양': 'Goyang',
'용인': 'Yongin',
'창원': 'Changwon',
'청주': 'Cheongju',
'전주': 'Jeonju',
'천안': 'Cheonan',
'안산': 'Ansan',
'안양': 'Anyang',
'제주': 'Jeju',
'평택': 'Pyeongtaek',
'화성': 'Hwaseong',
'포항': 'Pohang',
'진주': 'Jinju',
'강릉': 'Gangneung',
'김해': 'Gimhae',
'춘천': 'Chuncheon',
'원주': 'Wonju'
};
return cityMap[koreanName] || koreanName;
}
/**
* Форматировать адрес для отображения пользователю
*/
formatAddressForDisplay(address: KakaoAddress): string {
if (address.city) {
return `${address.city}, ${address.region1}`;
}
return `${address.region2}, ${address.region1}`;
}
/**
* Получить краткое название города для сохранения в профиле
*/
getCityNameForProfile(address: KakaoAddress): string {
return address.city || address.region2;
}
}

View File

@@ -427,15 +427,20 @@ export class MatchingService {
const userId = userProfile.userId;
// Определяем, каким должен быть пол показываемых профилей
let targetGender: string;
let targetGender: string | null;
let genderCondition: string;
if (userProfile.interestedIn === 'male' || userProfile.interestedIn === 'female') {
// Конкретный пол
targetGender = userProfile.interestedIn;
genderCondition = `AND p.gender = '${targetGender}'`;
} else {
// Если "both" или другое значение, показываем противоположный пол
targetGender = userProfile.gender === 'male' ? 'female' : 'male';
// Если "both" - показываем всех кроме своего пола
targetGender = null;
genderCondition = `AND p.gender != '${userProfile.gender}'`;
}
console.log(`[DEBUG] Определен целевой пол для поиска: ${targetGender}`);
console.log(`[DEBUG] Определен целевой пол для поиска: ${targetGender || 'any (except self)'}, условие: ${genderCondition}`);
// Получаем список просмотренных профилей из новой таблицы profile_views
// и добавляем также профили из свайпов для полной совместимости
@@ -490,7 +495,7 @@ export class MatchingService {
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND p.gender = '${targetGender}'
${genderCondition}
${excludeCondition}
`;
@@ -502,14 +507,14 @@ export class MatchingService {
console.log(`[DEBUG] Найдено ${availableProfilesCount} доступных профилей`);
// Используем определенный ранее targetGender для поиска
console.log(`[DEBUG] Поиск кандидата для gender=${targetGender}, возраст: ${userProfile.searchPreferences.minAge}-${userProfile.searchPreferences.maxAge}`);
console.log(`[DEBUG] Поиск кандидата для gender=${targetGender || 'any'}, возраст: ${userProfile.searchPreferences.minAge}-${userProfile.searchPreferences.maxAge}`);
const candidateQuery = `
SELECT p.*, u.telegram_id, u.username, u.first_name, u.last_name
FROM profiles p
JOIN users u ON p.user_id = u.id
WHERE p.is_visible = true
AND p.gender = '${targetGender}'
${genderCondition}
AND p.age BETWEEN ${userProfile.searchPreferences.minAge} AND ${userProfile.searchPreferences.maxAge}
${excludeCondition}
ORDER BY RANDOM()

View File

@@ -1118,10 +1118,10 @@ export class NotificationService {
const lastMessageTime = new Date(result.rows[0].created_at);
const now = new Date();
const hoursSinceLastMessage = (now.getTime() - lastMessageTime.getTime()) / (1000 * 60 * 60);
const secondsSinceLastMessage = (now.getTime() - lastMessageTime.getTime()) / 1000;
// Считаем активным если последнее сообщение было менее 10 минут назад
return hoursSinceLastMessage < (10 / 60);
// Считаем активным если последнее сообщение было менее 30 секунд назад
return secondsSinceLastMessage < 30;
} catch (error) {
console.error('Error checking user activity:', error);
return false;

View File

@@ -46,16 +46,32 @@ export class ProfileService {
updatedAt: now
});
// Сохранение в базу данных
// Сохранение в базу данных (UPSERT для избежания конфликта с триггером)
await query(`
INSERT INTO profiles (
id, user_id, name, age, gender, interested_in, bio, photos,
city, education, job, height, religion, dating_goal,
is_verified, is_visible, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (user_id) DO UPDATE SET
name = EXCLUDED.name,
age = EXCLUDED.age,
gender = EXCLUDED.gender,
interested_in = EXCLUDED.interested_in,
bio = EXCLUDED.bio,
photos = EXCLUDED.photos,
city = EXCLUDED.city,
education = EXCLUDED.education,
job = EXCLUDED.job,
height = EXCLUDED.height,
religion = EXCLUDED.religion,
dating_goal = EXCLUDED.dating_goal,
is_verified = EXCLUDED.is_verified,
is_visible = EXCLUDED.is_visible,
updated_at = EXCLUDED.updated_at
`, [
profileId, userId, profile.name, profile.age, profile.gender, profile.interestedIn,
profile.bio, JSON.stringify(profile.photos), profile.city, profile.education, profile.job,
profile.bio, profile.photos, profile.city, profile.education, profile.job,
profile.height, profile.religion, profile.datingGoal,
profile.isVerified, profile.isVisible, profile.createdAt, profile.updatedAt
]);
@@ -168,9 +184,10 @@ export class ProfileService {
switch (key) {
case 'photos':
case 'interests':
case 'hobbies':
updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`);
// Для PostgreSQL массивы должны быть преобразованы в JSON-строку
updateValues.push(JSON.stringify(value));
// PostgreSQL принимает нативные массивы
updateValues.push(Array.isArray(value) ? value : [value]);
break;
case 'location':
// Пропускаем обработку местоположения, так как колонки location нет

View File

@@ -0,0 +1,152 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
// Создание расширения для генерации UUID
pgm.createExtension('pgcrypto', { ifNotExists: true });
// Таблица пользователей
pgm.createTable('users', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
telegram_id: { type: 'bigint', notNull: true, unique: true },
username: { type: 'varchar(255)' },
first_name: { type: 'varchar(255)' },
last_name: { type: 'varchar(255)' },
language_code: { type: 'varchar(10)', default: 'en' },
is_active: { type: 'boolean', default: true },
created_at: { type: 'timestamp', default: pgm.func('NOW()') },
last_active_at: { type: 'timestamp', default: pgm.func('NOW()') },
updated_at: { type: 'timestamp', default: pgm.func('NOW()') },
premium: { type: 'boolean', default: false },
premium_expires_at: { type: 'timestamp' }
});
// Таблица профилей
pgm.createTable('profiles', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
user_id: { type: 'uuid', references: 'users(id)', onDelete: 'CASCADE' },
name: { type: 'varchar(255)', notNull: true },
age: {
type: 'integer',
notNull: true,
check: 'age >= 18 AND age <= 100'
},
gender: {
type: 'varchar(10)',
notNull: true,
check: "gender IN ('male', 'female', 'other')"
},
interested_in: {
type: 'varchar(10)',
notNull: true,
check: "interested_in IN ('male', 'female', 'both')"
},
looking_for: {
type: 'varchar(20)',
default: 'both',
check: "looking_for IN ('male', 'female', 'both')"
},
bio: { type: 'text' },
photos: { type: 'jsonb', default: '[]' },
interests: { type: 'jsonb', default: '[]' },
city: { type: 'varchar(255)' },
education: { type: 'varchar(255)' },
job: { type: 'varchar(255)' },
height: { type: 'integer' },
religion: { type: 'varchar(255)' },
dating_goal: { type: 'varchar(255)' },
smoking: { type: 'boolean' },
drinking: { type: 'boolean' },
has_kids: { type: 'boolean' },
location_lat: { type: 'decimal(10,8)' },
location_lon: { type: 'decimal(11,8)' },
search_min_age: { type: 'integer', default: 18 },
search_max_age: { type: 'integer', default: 50 },
search_max_distance: { type: 'integer', default: 50 },
is_verified: { type: 'boolean', default: false },
is_visible: { type: 'boolean', default: true },
created_at: { type: 'timestamp', default: pgm.func('NOW()') },
updated_at: { type: 'timestamp', default: pgm.func('NOW()') }
});
// Таблица свайпов
pgm.createTable('swipes', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
user_id: { type: 'uuid', references: 'users(id)', onDelete: 'CASCADE' },
target_user_id: { type: 'uuid', references: 'users(id)', onDelete: 'CASCADE' },
type: {
type: 'varchar(20)',
notNull: true,
check: "type IN ('like', 'pass', 'superlike')"
},
created_at: { type: 'timestamp', default: pgm.func('NOW()') },
is_match: { type: 'boolean', default: false }
});
pgm.addConstraint('swipes', 'unique_swipe', {
unique: ['user_id', 'target_user_id']
});
// Таблица матчей
pgm.createTable('matches', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
user_id_1: { type: 'uuid', references: 'users(id)', onDelete: 'CASCADE' },
user_id_2: { type: 'uuid', references: 'users(id)', onDelete: 'CASCADE' },
created_at: { type: 'timestamp', default: pgm.func('NOW()') },
last_message_at: { type: 'timestamp' },
is_active: { type: 'boolean', default: true },
is_super_match: { type: 'boolean', default: false },
unread_count_1: { type: 'integer', default: 0 },
unread_count_2: { type: 'integer', default: 0 }
});
pgm.addConstraint('matches', 'unique_match', {
unique: ['user_id_1', 'user_id_2']
});
// Таблица сообщений
pgm.createTable('messages', {
id: { type: 'uuid', primaryKey: true, default: pgm.func('gen_random_uuid()') },
match_id: { type: 'uuid', references: 'matches(id)', onDelete: 'CASCADE' },
sender_id: { type: 'uuid', references: 'users(id)', onDelete: 'CASCADE' },
receiver_id: { type: 'uuid', references: 'users(id)', onDelete: 'CASCADE' },
content: { type: 'text', notNull: true },
message_type: {
type: 'varchar(20)',
default: 'text',
check: "message_type IN ('text', 'photo', 'gif', 'sticker')"
},
created_at: { type: 'timestamp', default: pgm.func('NOW()') },
is_read: { type: 'boolean', default: false }
});
// Создание индексов
pgm.createIndex('users', 'telegram_id');
pgm.createIndex('profiles', 'user_id');
pgm.createIndex('profiles', ['location_lat', 'location_lon'], {
where: 'location_lat IS NOT NULL AND location_lon IS NOT NULL'
});
pgm.createIndex('profiles', ['age', 'gender', 'interested_in']);
pgm.createIndex('swipes', ['user_id', 'target_user_id']);
pgm.createIndex('matches', ['user_id_1', 'user_id_2']);
pgm.createIndex('messages', ['match_id', 'created_at']);
};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.dropTable('messages');
pgm.dropTable('matches');
pgm.dropTable('swipes');
pgm.dropTable('profiles');
pgm.dropTable('users');
pgm.dropExtension('pgcrypto');
};

View File

@@ -0,0 +1,25 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
// Добавляем колонки, которые могли быть пропущены в схеме
pgm.addColumns('profiles', {
hobbies: { type: 'text' }
});
};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.dropColumns('profiles', ['hobbies']);
};

View File

@@ -0,0 +1,18 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {};

View File

@@ -0,0 +1,29 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
// Добавляем отсутствующие колонки в таблицу profiles
pgm.addColumns('profiles', {
religion: { type: 'varchar(255)' },
dating_goal: { type: 'varchar(255)' },
smoking: { type: 'boolean' },
drinking: { type: 'boolean' },
has_kids: { type: 'boolean' }
}, { ifNotExists: true });
};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.dropColumns('profiles', ['religion', 'dating_goal', 'smoking', 'drinking', 'has_kids'], { ifExists: true });
};

View File

@@ -0,0 +1,18 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {};

View File

@@ -0,0 +1,18 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {};

View File

@@ -0,0 +1,42 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
// Изменяем тип столбцов с boolean на varchar для хранения строковых значений
pgm.alterColumn('profiles', 'smoking', {
type: 'varchar(50)',
using: 'smoking::text'
});
pgm.alterColumn('profiles', 'drinking', {
type: 'varchar(50)',
using: 'drinking::text'
});
// has_kids оставляем boolean, так как у него всего два состояния
};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
// Возвращаем столбцы к типу boolean
pgm.alterColumn('profiles', 'smoking', {
type: 'boolean',
using: "CASE WHEN smoking = 'regularly' OR smoking = 'sometimes' THEN true ELSE false END"
});
pgm.alterColumn('profiles', 'drinking', {
type: 'boolean',
using: "CASE WHEN drinking = 'regularly' OR drinking = 'sometimes' THEN true ELSE false END"
});
};

View File

@@ -0,0 +1,50 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
// Создание представления для совместимости со старым кодом (swipes)
pgm.sql(`
CREATE OR REPLACE VIEW swipes_view AS
SELECT
id,
user_id AS swiper_id,
target_user_id AS swiped_id,
type AS direction,
created_at,
is_match
FROM swipes;
`);
// Создание представления для совместимости со старым кодом (matches)
pgm.sql(`
CREATE OR REPLACE VIEW matches_view AS
SELECT
id,
user_id_1 AS user1_id,
user_id_2 AS user2_id,
created_at AS matched_at,
is_active AS status,
last_message_at,
is_super_match,
unread_count_1,
unread_count_2
FROM matches;
`);
};
/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @param run {() => void | undefined}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.sql(`DROP VIEW IF EXISTS swipes_view;`);
pgm.sql(`DROP VIEW IF EXISTS matches_view;`);
};

View File

@@ -0,0 +1,52 @@
/* eslint-disable camelcase */
exports.shorthands = undefined;
exports.up = pgm => {
// Проверяем существование таблицы scheduled_notifications
pgm.sql(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'scheduled_notifications'
) THEN
-- Проверяем, нет ли уже столбца processed
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'scheduled_notifications' AND column_name = 'processed'
) THEN
-- Добавляем столбец processed
ALTER TABLE scheduled_notifications ADD COLUMN processed BOOLEAN DEFAULT FALSE;
END IF;
ELSE
-- Создаем таблицу, если она не существует
CREATE TABLE scheduled_notifications (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
type VARCHAR(50) NOT NULL,
data JSONB,
scheduled_at TIMESTAMP NOT NULL,
processed BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
END IF;
END
$$;
`);
};
exports.down = pgm => {
pgm.sql(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'scheduled_notifications' AND column_name = 'processed'
) THEN
ALTER TABLE scheduled_notifications DROP COLUMN processed;
END IF;
END
$$;
`);
};