diff --git a/.env.example b/.env.example index 069ba3f..1855091 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/Dockerfile b/Dockerfile index 8b3336c..7cd385d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Makefile b/Makefile index 4bc34a6..e8106c1 100644 --- a/Makefile +++ b/Makefile @@ -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 конфигурации..." diff --git a/bin/apply_direct_sql.sh b/bin/apply_direct_sql.sh old mode 100755 new mode 100644 diff --git a/bin/apply_migrations.sh b/bin/apply_migrations.sh old mode 100755 new mode 100644 diff --git a/bin/backup_db.sh b/bin/backup_db.sh old mode 100755 new mode 100644 diff --git a/bin/compile_ts_migrations.sh b/bin/compile_ts_migrations.sh old mode 100755 new mode 100644 diff --git a/bin/create_consolidated_migration.sh b/bin/create_consolidated_migration.sh old mode 100755 new mode 100644 diff --git a/bin/create_release.sh b/bin/create_release.sh old mode 100755 new mode 100644 diff --git a/bin/fix_docker.sh b/bin/fix_docker.sh old mode 100755 new mode 100644 diff --git a/bin/fix_line_endings.sh b/bin/fix_line_endings.sh old mode 100755 new mode 100644 diff --git a/bin/fix_permissions.sh b/bin/fix_permissions.sh old mode 100755 new mode 100644 diff --git a/bin/init_database.sh b/bin/init_database.sh new file mode 100644 index 0000000..85446ac --- /dev/null +++ b/bin/init_database.sh @@ -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!" diff --git a/bin/install_docker.sh b/bin/install_docker.sh old mode 100755 new mode 100644 diff --git a/bin/install_ubuntu.sh b/bin/install_ubuntu.sh old mode 100755 new mode 100644 diff --git a/bin/run_full_migration.sh b/bin/run_full_migration.sh old mode 100755 new mode 100644 diff --git a/bin/run_sql_migrations.sh b/bin/run_sql_migrations.sh old mode 100755 new mode 100644 diff --git a/bin/setup.sh b/bin/setup.sh old mode 100755 new mode 100644 diff --git a/bin/start_bot.sh b/bin/start_bot.sh old mode 100755 new mode 100644 diff --git a/bin/update.sh b/bin/update.sh old mode 100755 new mode 100644 diff --git a/deploy.sh b/deploy.sh old mode 100755 new mode 100644 diff --git a/docker-compose.yml b/docker-compose.yml index 272f4bb..c5dc748 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/new_docker-keyring.gpg b/new_docker-keyring.gpg new file mode 100644 index 0000000..e5dc8cf Binary files /dev/null and b/new_docker-keyring.gpg differ diff --git a/scripts/add-hobbies-column.js b/scripts/add-hobbies-column.js index cf7930c..0ffb6d2 100644 --- a/scripts/add-hobbies-column.js +++ b/scripts/add-hobbies-column.js @@ -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() { diff --git a/scripts/create_profile_fix.js b/scripts/create_profile_fix.js index 7738517..faf4f5e 100644 --- a/scripts/create_profile_fix.js +++ b/scripts/create_profile_fix.js @@ -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() { diff --git a/scripts/migrate-sync.js b/scripts/migrate-sync.js index 3855eb6..3f1f86a 100644 --- a/scripts/migrate-sync.js +++ b/scripts/migrate-sync.js @@ -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() { diff --git a/sql/fix_match_trigger.sql b/sql/fix_match_trigger.sql new file mode 100644 index 0000000..6b96e10 --- /dev/null +++ b/sql/fix_match_trigger.sql @@ -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).'; diff --git a/sql/fix_notify_message_trigger.sql b/sql/fix_notify_message_trigger.sql new file mode 100644 index 0000000..45b179f --- /dev/null +++ b/sql/fix_notify_message_trigger.sql @@ -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; diff --git a/sql/fix_update_last_active_trigger.sql b/sql/fix_update_last_active_trigger.sql new file mode 100644 index 0000000..76c614f --- /dev/null +++ b/sql/fix_update_last_active_trigger.sql @@ -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; diff --git a/src/bot.ts b/src/bot.ts index efea339..be62c29 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -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); diff --git a/src/handlers/callbackHandlers.ts b/src/handlers/callbackHandlers.ts index 09c37b8..19fa09c 100644 --- a/src/handlers/callbackHandlers.ts +++ b/src/handlers/callbackHandlers.ts @@ -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; + 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; + 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� Интересы: ' + 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 { 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) { diff --git a/src/handlers/enhancedChatHandlers.ts b/src/handlers/enhancedChatHandlers.ts index 72726b8..5775b7a 100644 --- a/src/handlers/enhancedChatHandlers.ts +++ b/src/handlers/enhancedChatHandlers.ts @@ -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); } // ===== НАТИВНЫЙ ИНТЕРФЕЙС ЧАТОВ ===== diff --git a/src/handlers/messageHandlers.ts b/src/handlers/messageHandlers.ts index f1617fb..cb642b1 100644 --- a/src/handlers/messageHandlers.ts +++ b/src/handlers/messageHandlers.ts @@ -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 = new Map(); private chatStates: Map = new Map(); - private profileEditStates: Map = new Map(); + public profileEditStates: Map = 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, + `� Вы указали город: *${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\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 { + 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 { + 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 } } + ); + } + } } diff --git a/src/services/chatService.ts b/src/services/chatService.ts index d06acdd..c571f7f 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -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; diff --git a/src/services/kakaoMapService.ts b/src/services/kakaoMapService.ts new file mode 100644 index 0000000..6cf103e --- /dev/null +++ b/src/services/kakaoMapService.ts @@ -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 { + 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; + } +} diff --git a/src/services/matchingService.ts b/src/services/matchingService.ts index ff570d7..3c94162 100644 --- a/src/services/matchingService.ts +++ b/src/services/matchingService.ts @@ -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() diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 833c86c..8331ef3 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -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; diff --git a/src/services/profileService.ts b/src/services/profileService.ts index fe35dd6..fe7a4bd 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -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 нет diff --git a/temp_migrations/1758144488937_initial-schema.js b/temp_migrations/1758144488937_initial-schema.js new file mode 100644 index 0000000..9f6c344 --- /dev/null +++ b/temp_migrations/1758144488937_initial-schema.js @@ -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} + */ +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} + */ +export const down = (pgm) => { + pgm.dropTable('messages'); + pgm.dropTable('matches'); + pgm.dropTable('swipes'); + pgm.dropTable('profiles'); + pgm.dropTable('users'); + pgm.dropExtension('pgcrypto'); +}; diff --git a/temp_migrations/1758144618548_add-missing-profile-columns.js b/temp_migrations/1758144618548_add-missing-profile-columns.js new file mode 100644 index 0000000..25e99d3 --- /dev/null +++ b/temp_migrations/1758144618548_add-missing-profile-columns.js @@ -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} + */ +export const up = (pgm) => { + // Добавляем колонки, которые могли быть пропущены в схеме + pgm.addColumns('profiles', { + hobbies: { type: 'text' } + }); +}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => { + pgm.dropColumns('profiles', ['hobbies']); +}; diff --git a/temp_migrations/1758147898012_add-missing-religion-columns.js b/temp_migrations/1758147898012_add-missing-religion-columns.js new file mode 100644 index 0000000..97e8e32 --- /dev/null +++ b/temp_migrations/1758147898012_add-missing-religion-columns.js @@ -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} + */ +export const up = (pgm) => {}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => {}; diff --git a/temp_migrations/1758147903378_add-missing-religion-columns.js b/temp_migrations/1758147903378_add-missing-religion-columns.js new file mode 100644 index 0000000..75328e7 --- /dev/null +++ b/temp_migrations/1758147903378_add-missing-religion-columns.js @@ -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} + */ +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} + */ +export const down = (pgm) => { + pgm.dropColumns('profiles', ['religion', 'dating_goal', 'smoking', 'drinking', 'has_kids'], { ifExists: true }); +}; diff --git a/temp_migrations/1758148526228_update-lifestyle-columns-to-varchar.js b/temp_migrations/1758148526228_update-lifestyle-columns-to-varchar.js new file mode 100644 index 0000000..97e8e32 --- /dev/null +++ b/temp_migrations/1758148526228_update-lifestyle-columns-to-varchar.js @@ -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} + */ +export const up = (pgm) => {}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => {}; diff --git a/temp_migrations/1758148549355_update-lifestyle-columns-to-varchar.js b/temp_migrations/1758148549355_update-lifestyle-columns-to-varchar.js new file mode 100644 index 0000000..97e8e32 --- /dev/null +++ b/temp_migrations/1758148549355_update-lifestyle-columns-to-varchar.js @@ -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} + */ +export const up = (pgm) => {}; + +/** + * @param pgm {import('node-pg-migrate').MigrationBuilder} + * @param run {() => void | undefined} + * @returns {Promise | void} + */ +export const down = (pgm) => {}; diff --git a/temp_migrations/1758148562238_update-lifestyle-columns-to-varchar.js b/temp_migrations/1758148562238_update-lifestyle-columns-to-varchar.js new file mode 100644 index 0000000..c7d496b --- /dev/null +++ b/temp_migrations/1758148562238_update-lifestyle-columns-to-varchar.js @@ -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} + */ +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} + */ +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" + }); +}; diff --git a/temp_migrations/1758149087361_add-column-synonyms.js b/temp_migrations/1758149087361_add-column-synonyms.js new file mode 100644 index 0000000..cd8b29c --- /dev/null +++ b/temp_migrations/1758149087361_add-column-synonyms.js @@ -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} + */ +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} + */ +export const down = (pgm) => { + pgm.sql(`DROP VIEW IF EXISTS swipes_view;`); + pgm.sql(`DROP VIEW IF EXISTS matches_view;`); +}; diff --git a/temp_migrations/1758156426793_add-processed-column-to-notifications.js b/temp_migrations/1758156426793_add-processed-column-to-notifications.js new file mode 100644 index 0000000..497729c --- /dev/null +++ b/temp_migrations/1758156426793_add-processed-column-to-notifications.js @@ -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 + $$; + `); +};