From e275a9856bdb6be3aaca6be79f6c0a28d2654411 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Thu, 18 Sep 2025 10:38:29 +0900 Subject: [PATCH 1/4] Fix JSON format issues with photos and add multi-photo gallery support --- docker-compose.yml | 12 +- init-notifications-db.js | 174 ++++++++++++ ...3_add-processed-column-to-notifications.js | 52 ++++ package.json | 1 + src/handlers/callbackHandlers.ts | 162 +++++++++-- src/handlers/enhancedChatHandlers.ts | 31 +- src/handlers/likeBackHandler.ts | 76 +++++ src/scripts/enhanceNotifications.ts | 133 +++++++++ src/services/matchingService.ts | 83 ++++++ src/services/notificationService.ts | 265 +++++++++++++++--- src/services/profileService.ts | 4 +- test-connection.js | 37 +++ test-db.js | 0 13 files changed, 953 insertions(+), 77 deletions(-) create mode 100644 init-notifications-db.js create mode 100644 migrations/1758156426793_add-processed-column-to-notifications.js create mode 100644 src/handlers/likeBackHandler.ts create mode 100644 src/scripts/enhanceNotifications.ts create mode 100644 test-connection.js create mode 100644 test-db.js diff --git a/docker-compose.yml b/docker-compose.yml index 8ddb875..760e31c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,12 +9,12 @@ services: - db environment: - NODE_ENV=production - - DB_HOST=db - - DB_PORT=5432 - - DB_NAME=telegram_tinder_bot - - DB_USERNAME=postgres - - DB_PASSWORD=${DB_PASSWORD} - - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - DB_HOST={DB_HOST} + - DB_PORT={DB_PORT} + - DB_NAME={DB_NAME} + - DB_USERNAME={DB_USERNAME} + - DB_PASSWORD={DB_PASSWORD} + - TELEGRAM_BOT_TOKEN={TELEGRAM_BOT_TOKEN} volumes: - ./uploads:/app/uploads networks: diff --git a/init-notifications-db.js b/init-notifications-db.js new file mode 100644 index 0000000..bcd29d0 --- /dev/null +++ b/init-notifications-db.js @@ -0,0 +1,174 @@ +require('dotenv').config(); +const { Pool } = require('pg'); +const { v4: uuidv4 } = require('uuid'); + +// Функция для запуска скрипта +async function initializeDatabase() { + console.log('Starting database initialization script...'); + + const pool = new Pool({ + host: 'localhost', // Используем localhost + port: 5432, // Используем стандартный порт 5432 + database: 'telegram_tinder_bot', + user: 'postgres', + password: '', + max: 5, + connectionTimeoutMillis: 5000 + }); + + console.log('DB Connection Details:'); + console.log('- Host: localhost'); + console.log('- Port: 5432'); + console.log('- Database: telegram_tinder_bot'); + console.log('- User: postgres'); + + try { + // Проверяем подключение + console.log('Testing connection...'); + const client = await pool.connect(); + + try { + console.log('✅ Connected to database successfully!'); + + // 1. Создаем расширение для генерации UUID если его нет + console.log('Creating UUID extension...'); + await client.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + + // 2. Создаем таблицу для шаблонов уведомлений + console.log('Creating notification_templates table...'); + await client.query(` + CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(50) NOT NULL UNIQUE, + title TEXT NOT NULL, + message_template TEXT NOT NULL, + button_template JSONB NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + // 3. Вставляем базовые шаблоны + console.log('Inserting notification templates...'); + + const templates = [ + { + id: uuidv4(), + type: 'new_like', + title: 'Новый лайк!', + message_template: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + }) + }, + { + id: uuidv4(), + type: 'super_like', + title: 'Супер-лайк!', + message_template: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + }) + }, + { + id: uuidv4(), + type: 'new_match', + title: 'Новый матч!', + message_template: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_native_chat_{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'native_chats' } + ] + ] + }) + }, + { + id: uuidv4(), + type: 'new_message', + title: 'Новое сообщение!', + message_template: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_native_chat_{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'native_chats' } + ] + ] + }) + } + ]; + + // Вставляем шаблоны с проверкой на конфликты + for (const template of templates) { + await client.query(` + INSERT INTO notification_templates + (id, type, title, message_template, button_template, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (type) DO UPDATE + SET title = $3, + message_template = $4, + button_template = $5 + `, [template.id, template.type, template.title, template.message_template, template.button_template]); + } + + console.log('✅ Notification templates created/updated successfully'); + + // 4. Создаем таблицу для хранения логов уведомлений если её нет + console.log('Creating notifications table...'); + await client.query(` + CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL, + type VARCHAR(50) NOT NULL, + data JSONB NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + console.log('✅ Notifications table created successfully'); + + // 5. Проверяем, что таблицы созданы + const tablesResult = await client.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name IN ('notification_templates', 'notifications') + `); + + console.log('Created tables:'); + tablesResult.rows.forEach(row => { + console.log(`- ${row.table_name}`); + }); + + } finally { + client.release(); + await pool.end(); + } + + console.log('✅ Database initialization completed successfully'); + + } catch (error) { + console.error('❌ Database initialization error:', error); + } +} + +// Запускаем скрипт +initializeDatabase(); diff --git a/migrations/1758156426793_add-processed-column-to-notifications.js b/migrations/1758156426793_add-processed-column-to-notifications.js new file mode 100644 index 0000000..497729c --- /dev/null +++ b/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 + $$; + `); +}; diff --git a/package.json b/package.json index 3579cb5..ef721e7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "premium:direct": "ts-node src/scripts/setPremiumDirectConnect.ts", "db:info": "ts-node src/scripts/getDatabaseInfo.ts", "db:test-data": "ts-node src/scripts/createTestData.ts", + "enhance-notifications": "ts-node src/scripts/enhanceNotifications.ts", "update": "bash ./bin/update.sh", "update:win": ".\\bin\\update.bat", "start:sh": "bash ./bin/start_bot.sh" diff --git a/src/handlers/callbackHandlers.ts b/src/handlers/callbackHandlers.ts index 26a9e37..0df3dd6 100644 --- a/src/handlers/callbackHandlers.ts +++ b/src/handlers/callbackHandlers.ts @@ -10,6 +10,7 @@ import { VipController } from '../controllers/vipController'; import { VipService } from '../services/vipService'; import { TranslationController } from '../controllers/translationController'; import { t } from '../services/localizationService'; +import { LikeBackHandler } from './likeBackHandler'; export class CallbackHandlers { private bot: TelegramBot; @@ -22,6 +23,7 @@ export class CallbackHandlers { private vipController: VipController; private vipService: VipService; private translationController: TranslationController; + private likeBackHandler: LikeBackHandler; constructor(bot: TelegramBot, messageHandlers: MessageHandlers) { this.bot = bot; @@ -34,6 +36,7 @@ export class CallbackHandlers { this.vipController = new VipController(bot); this.vipService = new VipService(); this.translationController = new TranslationController(); + this.likeBackHandler = new LikeBackHandler(bot); } register(): void { @@ -167,6 +170,12 @@ export class CallbackHandlers { await this.handleMorePhotos(chatId, telegramId, targetUserId); } + // Обработка лайков и ответных лайков из уведомлений + else if (data.startsWith('like_back:')) { + const targetUserId = data.replace('like_back:', ''); + await this.likeBackHandler.handleLikeBack(chatId, telegramId, targetUserId); + } + // Матчи и чаты else if (data === 'view_matches') { await this.handleViewMatches(chatId, telegramId); @@ -385,9 +394,15 @@ export class CallbackHandlers { await this.bot.sendMessage(chatId, '👍 Лайк отправлен!'); await this.showNextCandidate(chatId, telegramId); } - } catch (error) { - await this.bot.sendMessage(chatId, '❌ Ошибка при отправке лайка'); - console.error('Like error:', error); + } catch (error: any) { + // Проверяем, связана ли ошибка с уже существующим свайпом + if (error.message === 'Already swiped this profile' || error.code === 'ALREADY_SWIPED') { + await this.bot.sendMessage(chatId, '❓ Вы уже оценивали этот профиль ранее'); + await this.showNextCandidate(chatId, telegramId); + } else { + await this.bot.sendMessage(chatId, '❌ Ошибка при отправке лайка'); + console.error('Like error:', error); + } } } @@ -402,9 +417,15 @@ export class CallbackHandlers { await this.matchingService.performSwipe(telegramId, targetTelegramId, 'pass'); await this.showNextCandidate(chatId, telegramId); - } catch (error) { - await this.bot.sendMessage(chatId, '❌ Ошибка при отправке дизлайка'); - console.error('Dislike error:', error); + } catch (error: any) { + // Проверяем, связана ли ошибка с уже существующим свайпом + if (error.message === 'Already swiped this profile' || error.code === 'ALREADY_SWIPED') { + await this.bot.sendMessage(chatId, '❓ Вы уже оценивали этот профиль ранее'); + await this.showNextCandidate(chatId, telegramId); + } else { + await this.bot.sendMessage(chatId, '❌ Ошибка при отправке дизлайка'); + console.error('Dislike error:', error); + } } } @@ -443,9 +464,73 @@ export class CallbackHandlers { await this.bot.sendMessage(chatId, '💖 Супер лайк отправлен!'); await this.showNextCandidate(chatId, telegramId); } + } catch (error: any) { + // Проверяем, связана ли ошибка с уже существующим свайпом + if (error.message === 'Already swiped this profile' || error.code === 'ALREADY_SWIPED') { + await this.bot.sendMessage(chatId, '❓ Вы уже оценивали этот профиль ранее'); + await this.showNextCandidate(chatId, telegramId); + } else { + await this.bot.sendMessage(chatId, '❌ Ошибка при отправке супер лайка'); + console.error('Superlike error:', error); + } + } + } + + // Обработка обратного лайка из уведомления + async handleLikeBack(chatId: number, telegramId: string, targetUserId: string): Promise { + try { + // Получаем информацию о пользователях + const targetProfile = await this.profileService.getProfileByUserId(targetUserId); + + if (!targetProfile) { + await this.bot.sendMessage(chatId, '❌ Не удалось найти профиль'); + return; + } + + // Получаем telegram ID целевого пользователя для свайпа + const targetTelegramId = await this.profileService.getTelegramIdByUserId(targetUserId); + if (!targetTelegramId) { + await this.bot.sendMessage(chatId, '❌ Не удалось найти пользователя'); + return; + } + + // Выполняем свайп + const result = await this.matchingService.performSwipe(telegramId, targetTelegramId, 'like'); + + if (result.isMatch) { + // Это матч! + await this.bot.sendMessage( + chatId, + '🎉 *Поздравляем! Это взаимно!*\n\n' + + `Вы и *${targetProfile.name}* понравились друг другу!\n` + + 'Теперь вы можете начать общение.', + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: `start_chat:${targetUserId}` }], + [{ text: '👀 Посмотреть профиль', callback_data: `view_profile:${targetUserId}` }] + ] + } + } + ); + } else { + await this.bot.sendMessage( + chatId, + '❤️ Вам понравился профиль ' + targetProfile.name + '!\n\n' + + 'Если вы также понравитесь этому пользователю, будет создан матч.', + { + reply_markup: { + inline_keyboard: [ + [{ text: '🔍 Продолжить поиск', callback_data: 'start_browsing' }] + ] + } + } + ); + } } catch (error) { - await this.bot.sendMessage(chatId, '❌ Ошибка при отправке супер лайка'); - console.error('Superlike error:', error); + console.error('Error in handleLikeBack:', error); + await this.bot.sendMessage(chatId, '❌ Произошла ошибка при обработке лайка'); } } @@ -476,9 +561,28 @@ export class CallbackHandlers { return; } - for (let i = 1; i < targetProfile.photos.length; i++) { - const photoFileId = targetProfile.photos[i]; - await this.bot.sendPhoto(chatId, photoFileId); + // Отправляем фотографии в виде медиа-группы (коллажа) + // Создаем массив объектов медиа для группового отправления + const mediaGroup = targetProfile.photos.slice(1).map((photoFileId, index) => ({ + type: 'photo' as const, + media: photoFileId, + caption: index === 0 ? `📸 Дополнительные фото ${targetProfile.name}` : undefined + })); + + try { + // Отправляем все фото одним сообщением (медиа-группой) + await this.bot.sendMediaGroup(chatId, mediaGroup); + } catch (error) { + console.error('Error sending media group:', error); + + // Если не получилось отправить медиа-группой, отправляем по одной + for (let i = 1; i < targetProfile.photos.length; i++) { + try { + await this.bot.sendPhoto(chatId, targetProfile.photos[i]); + } catch (photoError) { + console.error(`Error sending photo ${i}:`, photoError); + } + } } const keyboard: InlineKeyboardMarkup = { @@ -807,6 +911,7 @@ export class CallbackHandlers { // Вспомогательные методы async showProfile(chatId: number, profile: Profile, isOwner: boolean = false, viewerId?: string): Promise { + const hasMultiplePhotos = profile.photos.length > 1; const mainPhotoFileId = profile.photos[0]; // Первое фото - главное let profileText = '👤 ' + profile.name + ', ' + profile.age + '\n'; @@ -876,20 +981,43 @@ export class CallbackHandlers { if (hasValidPhoto) { try { - await this.bot.sendPhoto(chatId, mainPhotoFileId, { - caption: profileText, - reply_markup: keyboard - }); + if (hasMultiplePhotos) { + // Если есть несколько фото, отправляем их как медиа-группу (коллаж) + const mediaGroup = profile.photos.map((photoFileId, index) => ({ + type: 'photo' as const, + media: photoFileId, + caption: index === 0 ? profileText : undefined, + parse_mode: index === 0 ? 'Markdown' as const : undefined + })); + + // Сначала отправляем медиа-группу + await this.bot.sendMediaGroup(chatId, mediaGroup); + + // Затем отправляем отдельное сообщение с кнопками + await this.bot.sendMessage(chatId, '📸 Выберите действие:', { + reply_markup: keyboard + }); + } else { + // Если только одно фото, отправляем его с текстом + await this.bot.sendPhoto(chatId, mainPhotoFileId, { + caption: profileText, + reply_markup: keyboard, + parse_mode: 'Markdown' + }); + } } catch (error) { + console.error('Error sending profile photos:', error); // Если не удалось отправить фото, отправляем текст await this.bot.sendMessage(chatId, '🖼 Фото недоступно\n\n' + profileText, { - reply_markup: keyboard + reply_markup: keyboard, + parse_mode: 'Markdown' }); } } else { // Отправляем как текстовое сообщение await this.bot.sendMessage(chatId, profileText, { - reply_markup: keyboard + reply_markup: keyboard, + parse_mode: 'Markdown' }); } } diff --git a/src/handlers/enhancedChatHandlers.ts b/src/handlers/enhancedChatHandlers.ts index 59a353c..72726b8 100644 --- a/src/handlers/enhancedChatHandlers.ts +++ b/src/handlers/enhancedChatHandlers.ts @@ -168,25 +168,24 @@ export class EnhancedChatHandlers { // ===== СИСТЕМА УВЕДОМЛЕНИЙ ===== - // Отправить уведомление о новом сообщении + // Отправить уведомление о новом сообщении - теперь используем NotificationService async sendMessageNotification(receiverTelegramId: string, senderName: string, messagePreview: string, matchId: string): Promise { try { - const receiverChatId = parseInt(receiverTelegramId); + // Получаем идентификаторы пользователей для использования в NotificationService + const receiverUserId = await this.profileService.getUserIdByTelegramId(receiverTelegramId); + const sender = await this.chatService.getMatchInfo(matchId, receiverTelegramId); - await this.bot.sendMessage( - receiverChatId, - `💌 *Новое сообщение от ${senderName}*\n\n` + - `"${this.escapeMarkdown(messagePreview)}"\n\n` + - '👆 Нажмите "Открыть чат" для ответа', - { - parse_mode: 'Markdown', - reply_markup: { - inline_keyboard: [ - [{ text: '💬 Открыть чат', callback_data: `open_native_chat_${matchId}` }], - [{ text: '📱 Все чаты', callback_data: 'native_chats' }] - ] - } - } + if (!receiverUserId || !sender?.otherUserId) { + console.error('Failed to get user IDs for notification'); + return; + } + + // Используем сервис уведомлений для отправки более красивого уведомления + await this.notificationService.sendMessageNotification( + receiverUserId, + sender.otherUserId, + messagePreview, + matchId ); } catch (error) { console.error('Error sending message notification:', error); diff --git a/src/handlers/likeBackHandler.ts b/src/handlers/likeBackHandler.ts new file mode 100644 index 0000000..583d1f5 --- /dev/null +++ b/src/handlers/likeBackHandler.ts @@ -0,0 +1,76 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { ProfileService } from '../services/profileService'; +import { MatchingService } from '../services/matchingService'; + +export class LikeBackHandler { + private bot: TelegramBot; + private profileService: ProfileService; + private matchingService: MatchingService; + + constructor(bot: TelegramBot) { + this.bot = bot; + this.profileService = new ProfileService(); + this.matchingService = new MatchingService(); + } + + // Функция для обработки обратного лайка из уведомления + async handleLikeBack(chatId: number, telegramId: string, targetUserId: string): Promise { + try { + // Получаем информацию о пользователях + const [userId, targetProfile] = await Promise.all([ + this.profileService.getUserIdByTelegramId(telegramId), + this.profileService.getProfileByUserId(targetUserId) + ]); + + if (!userId || !targetProfile) { + await this.bot.sendMessage(chatId, '❌ Не удалось найти профиль'); + return; + } + + // Проверяем, есть ли уже свайп + const existingSwipe = await this.matchingService.getSwipeBetweenUsers(userId, targetUserId); + if (existingSwipe) { + await this.bot.sendMessage(chatId, '❓ Вы уже оценили этот профиль ранее.'); + return; + } + + // Создаем свайп (лайк) + const result = await this.matchingService.createSwipe(userId, targetUserId, 'like'); + + if (result.isMatch) { + // Это матч! + await this.bot.sendMessage( + chatId, + '🎉 *Поздравляем! Это взаимно!*\n\n' + + `Вы и *${targetProfile.name}* понравились друг другу!\n` + + 'Теперь вы можете начать общение.', + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: `start_chat:${targetUserId}` }], + [{ text: '👀 Посмотреть профиль', callback_data: `view_profile:${targetUserId}` }] + ] + } + } + ); + } else { + await this.bot.sendMessage( + chatId, + '❤️ Вам понравился профиль ' + targetProfile.name + '!\n\n' + + 'Если вы также понравитесь этому пользователю, будет создан матч.', + { + reply_markup: { + inline_keyboard: [ + [{ text: '🔍 Продолжить поиск', callback_data: 'start_browsing' }] + ] + } + } + ); + } + } catch (error) { + console.error('Error in handleLikeBack:', error); + await this.bot.sendMessage(chatId, '❌ Произошла ошибка при обработке лайка'); + } + } +} diff --git a/src/scripts/enhanceNotifications.ts b/src/scripts/enhanceNotifications.ts new file mode 100644 index 0000000..440d737 --- /dev/null +++ b/src/scripts/enhanceNotifications.ts @@ -0,0 +1,133 @@ +import { query } from '../database/connection'; +import { v4 as uuidv4 } from 'uuid'; +import dotenv from 'dotenv'; + +// Загружаем переменные окружения +dotenv.config(); + +/** + * Скрипт для обновления механизма уведомлений + */ +export async function enhanceNotifications() { + try { + console.log('Enhancing notifications system...'); + console.log('DB Connection Details:'); + console.log(`- Host: ${process.env.DB_HOST}`); + console.log(`- Port: ${process.env.DB_PORT}`); + console.log(`- Database: ${process.env.DB_NAME}`); + + // 1. Создаем расширение для генерации UUID, если его нет + await query(` + CREATE EXTENSION IF NOT EXISTS "uuid-ossp" + `); + + // 2. Создаем таблицу для хранения типов уведомлений и шаблонов сообщений, если её нет + await query(` + CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(50) NOT NULL UNIQUE, + title TEXT NOT NULL, + message_template TEXT NOT NULL, + button_template JSONB NOT NULL, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + // 2. Вставляем базовые шаблоны для различных типов уведомлений + const templates = [ + { + id: uuidv4(), + type: 'new_like', + title: 'Новый лайк!', + message_template: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + }) + }, + { + id: uuidv4(), + type: 'super_like', + title: 'Супер-лайк!', + message_template: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + }) + }, + { + id: uuidv4(), + type: 'new_match', + title: 'Новый матч!', + message_template: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_native_chat_{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'native_chats' } + ] + ] + }) + }, + { + id: uuidv4(), + type: 'new_message', + title: 'Новое сообщение!', + message_template: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + button_template: JSON.stringify({ + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_native_chat_{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'native_chats' } + ] + ] + }) + } + ]; + + // Вставляем шаблоны с проверкой на конфликты + for (const template of templates) { + await query(` + INSERT INTO notification_templates + (id, type, title, message_template, button_template, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (type) DO UPDATE + SET title = $3, + message_template = $4, + button_template = $5 + `, [template.id, template.type, template.title, template.message_template, template.button_template]); + } + + console.log('✅ Notification templates updated successfully'); + + return true; + } catch (error) { + console.error('❌ Error enhancing notifications:', error); + return false; + } +} + +// Если запускаем файл напрямую +if (require.main === module) { + enhanceNotifications().then(() => { + console.log('Notification system enhancement completed'); + process.exit(0); + }).catch(error => { + console.error('Error:', error); + process.exit(1); + }); +} diff --git a/src/services/matchingService.ts b/src/services/matchingService.ts index 9dde927..e064a44 100644 --- a/src/services/matchingService.ts +++ b/src/services/matchingService.ts @@ -152,6 +152,89 @@ export class MatchingService { return this.mapEntityToSwipe(result.rows[0]); } + + // Получить свайп между двумя пользователями (псевдоним для getSwipe) + async getSwipeBetweenUsers(userId: string, targetUserId: string): Promise { + return this.getSwipe(userId, targetUserId); + } + + // Создать свайп (лайк, дислайк или суперлайк) + async createSwipe(userId: string, targetUserId: string, swipeType: SwipeType): Promise<{ + swipe: Swipe; + isMatch: boolean; + match?: Match; + }> { + const swipeId = uuidv4(); + let isMatch = false; + let match: Match | undefined; + + await transaction(async (client) => { + // Создаем свайп + await client.query(` + INSERT INTO swipes (id, user_id, target_user_id, type, created_at) + VALUES ($1, $2, $3, $4, $5) + `, [swipeId, userId, targetUserId, swipeType, new Date()]); + + // Если это лайк или суперлайк, проверяем взаимность + if (swipeType === 'like' || swipeType === 'superlike') { + const reciprocalSwipe = await client.query(` + SELECT * FROM swipes + WHERE user_id = $1 AND target_user_id = $2 AND type IN ('like', 'superlike') + `, [targetUserId, userId]); + + if (reciprocalSwipe.rows.length > 0) { + // Проверяем, что матч еще не существует + const existingMatch = await client.query(` + SELECT * FROM matches + WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1) + `, [userId, targetUserId]); + + if (existingMatch.rows.length === 0) { + isMatch = true; + const matchId = uuidv4(); + const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].type === 'superlike'; + + // Упорядочиваем пользователей для консистентности + const [user1Id, user2Id] = userId < targetUserId ? [userId, targetUserId] : [targetUserId, userId]; + + // Создаем матч + await client.query(` + INSERT INTO matches (id, user_id_1, user_id_2, created_at, is_active, is_super_match) + VALUES ($1, $2, $3, $4, $5, $6) + `, [matchId, user1Id, user2Id, new Date(), true, isSuperMatch]); + + match = new Match({ + id: matchId, + userId1: user1Id, + userId2: user2Id, + createdAt: new Date(), + isActive: true, + isSuperMatch: isSuperMatch, + unreadCount1: 0, + unreadCount2: 0 + }); + + // Обновляем свайпы, отмечая что они образуют матч + await client.query(` + UPDATE swipes SET is_match = true + WHERE (user_id = $1 AND target_user_id = $2) OR (user_id = $2 AND target_user_id = $1) + `, [userId, targetUserId]); + } + } + } + }); + + const swipe = new Swipe({ + id: swipeId, + userId, + targetUserId, + type: swipeType, + timestamp: new Date(), + isMatch + }); + + return { swipe, isMatch, match }; + } // Получить все матчи пользователя по telegram ID async getUserMatches(telegramId: string, limit: number = 50): Promise { diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 707426f..a34e59a 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -1,7 +1,6 @@ import TelegramBot from 'node-telegram-bot-api'; import { query } from '../database/connection'; import { ProfileService } from './profileService'; -import config from '../../config/default.json'; export interface NotificationData { userId: string; @@ -19,6 +18,126 @@ export class NotificationService { this.profileService = new ProfileService(); } + // Получить шаблон уведомления из базы данных или использовать встроенный + private async getNotificationTemplate(type: string): Promise<{ + title: string; + messageTemplate: string; + buttonTemplate: any; + }> { + try { + // Попытка получить шаблон из базы данных + const result = await query(` + SELECT title, message_template, button_template + FROM notification_templates + WHERE type = $1 + `, [type]); + + if (result.rows.length > 0) { + return { + title: result.rows[0].title, + messageTemplate: result.rows[0].message_template, + buttonTemplate: result.rows[0].button_template + }; + } + } catch (error: any) { + console.log('Using default template as database is not available:', error.message); + } + + // Если не удалось получить из базы или произошла ошибка, используем встроенные шаблоны + const defaultTemplates: Record = { + 'new_like': { + title: 'Новый лайк!', + messageTemplate: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + buttonTemplate: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + 'super_like': { + title: 'Супер-лайк!', + messageTemplate: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + buttonTemplate: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + 'new_match': { + title: 'Новый матч!', + messageTemplate: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + buttonTemplate: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_native_chat_{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'native_chats' } + ] + ] + } + }, + 'new_message': { + title: 'Новое сообщение!', + messageTemplate: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + buttonTemplate: { + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_native_chat_{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'native_chats' } + ] + ] + } + } + }; + + return defaultTemplates[type] || { + title: 'Уведомление', + messageTemplate: 'Новое уведомление', + buttonTemplate: { inline_keyboard: [] } + }; + } + + // Применить данные к шаблону + private applyTemplateData(template: string, data: Record): string { + return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + return data[key] !== undefined ? data[key] : ''; + }); + } + + // Применить данные к шаблону кнопок + private applyTemplateDataToButtons(buttonTemplate: any, data: Record): any { + const result = JSON.parse(JSON.stringify(buttonTemplate)); // глубокая копия + + // Рекурсивная функция для замены в любой вложенности + const replaceInObject = (obj: any): any => { + if (typeof obj === 'string') { + return this.applyTemplateData(obj, data); + } else if (Array.isArray(obj)) { + return obj.map(item => replaceInObject(item)); + } else if (obj !== null && typeof obj === 'object') { + const newObj: Record = {}; + for (const key in obj) { + newObj[key] = replaceInObject(obj[key]); + } + return newObj; + } + return obj; + }; + + return replaceInObject(result); + } + // Отправить уведомление о новом лайке async sendLikeNotification(targetTelegramId: string, likerTelegramId: string, isSuperLike: boolean = false): Promise { try { @@ -30,25 +149,41 @@ export class NotificationService { if (!targetUser || !likerProfile || !this.bot) { return; } - - const message = isSuperLike - ? `⭐ ${likerProfile.name} отправил вам суперлайк!` - : `💖 ${likerProfile.name} поставил вам лайк!`; - + + // Получаем шаблон уведомления + const templateType = isSuperLike ? 'super_like' : 'new_like'; + const template = await this.getNotificationTemplate(templateType); + + // Подготовка данных для шаблона + const templateData = { + name: likerProfile.name, + age: likerProfile.age.toString(), + city: likerProfile.city || '', + userId: likerProfile.userId + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление await this.bot.sendMessage(targetUser.telegram_id, message, { - reply_markup: { - inline_keyboard: [[ - { text: '👀 Посмотреть профиль', callback_data: `view_profile:${likerProfile.userId}` }, - { text: '💕 Начать знакомиться', callback_data: 'start_browsing' } - ]] - } + parse_mode: 'Markdown', + reply_markup: keyboard }); // Логируем уведомление await this.logNotification({ userId: targetUser.id, - type: isSuperLike ? 'super_like' : 'new_like', - data: { likerUserId: likerProfile.userId, likerName: likerProfile.name } + type: templateType, + data: { + likerUserId: likerProfile.userId, + likerName: likerProfile.name, + age: likerProfile.age, + city: likerProfile.city + } }); } catch (error) { console.error('Error sending like notification:', error); @@ -67,22 +202,50 @@ export class NotificationService { return; } - const message = `🎉 У вас новый матч с ${matchedProfile.name}!\n\nТеперь вы можете начать общение.`; + // Получаем матч-ID для перехода в чат + const matchResult = await query(` + SELECT id FROM matches + WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1) + AND is_active = true + `, [userId, matchedUserId]); + const matchId = matchResult.rows[0]?.id; + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('new_match'); + + // Подготовка данных для шаблона + const templateData = { + name: matchedProfile.name, + age: matchedProfile.age.toString(), + city: matchedProfile.city || '', + userId: matchedProfile.userId, + matchId: matchId || '' + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление await this.bot.sendMessage(user.telegram_id, message, { - reply_markup: { - inline_keyboard: [[ - { text: '💬 Написать сообщение', callback_data: `start_chat:${matchedUserId}` }, - { text: '👀 Посмотреть профиль', callback_data: `view_profile:${matchedUserId}` } - ]] - } + parse_mode: 'Markdown', + reply_markup: keyboard }); // Логируем уведомление await this.logNotification({ userId, type: 'new_match', - data: { matchedUserId, matchedName: matchedProfile.name } + data: { + matchedUserId, + matchedName: matchedProfile.name, + age: matchedProfile.age, + city: matchedProfile.city, + matchId + } }); } catch (error) { console.error('Error sending match notification:', error); @@ -90,7 +253,7 @@ export class NotificationService { } // Отправить уведомление о новом сообщении - async sendMessageNotification(receiverId: string, senderId: string, messageContent: string): Promise { + async sendMessageNotification(receiverId: string, senderId: string, messageContent: string, matchId?: string): Promise { try { const [receiver, senderProfile] = await Promise.all([ this.getUserByUserId(receiverId), @@ -107,25 +270,55 @@ export class NotificationService { return; // Не отправляем уведомление, если пользователь активен в чате } + // Если matchId не передан, пытаемся его получить + let actualMatchId = matchId; + if (!actualMatchId) { + const matchResult = await query(` + SELECT id FROM matches + WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1) + AND is_active = true + `, [receiverId, senderId]); + + actualMatchId = matchResult.rows[0]?.id; + } + const truncatedMessage = messageContent.length > 50 ? messageContent.substring(0, 50) + '...' : messageContent; - - const message = `💬 Новое сообщение от ${senderProfile.name}:\n\n${truncatedMessage}`; - + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('new_message'); + + // Подготовка данных для шаблона + const templateData = { + name: senderProfile.name, + message: truncatedMessage, + userId: senderProfile.userId, + matchId: actualMatchId || '' + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление await this.bot.sendMessage(receiver.telegram_id, message, { - reply_markup: { - inline_keyboard: [[ - { text: '💬 Ответить', callback_data: `open_chat:${senderId}` } - ]] - } + parse_mode: 'Markdown', + reply_markup: keyboard }); // Логируем уведомление await this.logNotification({ userId: receiverId, type: 'new_message', - data: { senderId, senderName: senderProfile.name, messageContent: truncatedMessage } + data: { + senderId, + senderName: senderProfile.name, + messageContent: truncatedMessage, + matchId: actualMatchId + } }); } catch (error) { console.error('Error sending message notification:', error); @@ -364,7 +557,7 @@ export class NotificationService { type VARCHAR(50) NOT NULL, data JSONB, scheduled_at TIMESTAMP NOT NULL, - is_processed BOOLEAN DEFAULT FALSE, + processed BOOLEAN DEFAULT FALSE, created_at TIMESTAMP DEFAULT NOW() ) `); @@ -373,7 +566,7 @@ export class NotificationService { // Получаем запланированные уведомления const result = await query(` SELECT * FROM scheduled_notifications - WHERE scheduled_at <= $1 AND is_processed = false + WHERE scheduled_at <= $1 AND processed = false ORDER BY scheduled_at ASC LIMIT 100 `, [new Date()]); @@ -393,7 +586,7 @@ export class NotificationService { // Отмечаем как обработанное await query( - 'UPDATE scheduled_notifications SET is_processed = true WHERE id = $1', + 'UPDATE scheduled_notifications SET processed = true WHERE id = $1', [notification.id] ); } catch (error) { @@ -404,4 +597,4 @@ export class NotificationService { console.error('Error processing scheduled notifications:', error); } } -} \ No newline at end of file +} diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 8b1a367..6b4fcdb 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -169,8 +169,8 @@ export class ProfileService { case 'photos': case 'interests': updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`); - // Для PostgreSQL массивы передаем как есть, не как JSON строки - updateValues.push(value); + // Для PostgreSQL массивы должны быть преобразованы в JSON-строку + updateValues.push(JSON.stringify(value)); break; case 'location': // Пропускаем обработку местоположения, так как колонки location нет diff --git a/test-connection.js b/test-connection.js new file mode 100644 index 0000000..56810cd --- /dev/null +++ b/test-connection.js @@ -0,0 +1,37 @@ +require('dotenv').config(); +const { Pool } = require('pg'); + +// Используем параметры напрямую из .env +const pool = new Pool({ + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + max: 5, + connectionTimeoutMillis: 5000 +}); + +console.log('DB Connection Details:'); +console.log(`- Host: ${process.env.DB_HOST}`); +console.log(`- Port: ${process.env.DB_PORT}`); +console.log(`- Database: ${process.env.DB_NAME}`); +console.log(`- User: ${process.env.DB_USERNAME}`); + +async function testConnection() { + try { + const client = await pool.connect(); + try { + const result = await client.query('SELECT NOW() as current_time'); + console.log('✅ Connected to database successfully!'); + console.log(`Current database time: ${result.rows[0].current_time}`); + } finally { + client.release(); + } + await pool.end(); + } catch (error) { + console.error('❌ Failed to connect to database:', error); + } +} + +testConnection(); diff --git a/test-db.js b/test-db.js new file mode 100644 index 0000000..e69de29 From 85027a774705bd7815bbe170d57d130d58c902a2 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Thu, 18 Sep 2025 11:42:18 +0900 Subject: [PATCH 2/4] mainly functional matching --- fixes.md | 90 +++++++++ .../1631980000000_add_profile_views_table.ts | 44 +++++ profile_views_patch.ts | 153 ++++++++++++++ scripts/checkDatabase.js | 66 +++++++ scripts/checkProfileViews.js | 64 ++++++ scripts/cleanDatabase.js | 55 ++++++ scripts/clearDatabase.js | 66 +++++++ scripts/clearDatabase.mjs | 81 ++++++++ scripts/clear_database.sql | 26 +++ scripts/createProfileViewsTable.js | 86 ++++++++ scripts/createProfileViewsTable.ts | 70 +++++++ scripts/testMatching.js | 102 ++++++++++ scripts/testProfileViews.js | 98 +++++++++ src/services/matchingService.ts | 186 ++++++++++++------ src/services/profileService.ts | 69 +++++-- 15 files changed, 1179 insertions(+), 77 deletions(-) create mode 100644 fixes.md create mode 100644 migrations/1631980000000_add_profile_views_table.ts create mode 100644 profile_views_patch.ts create mode 100644 scripts/checkDatabase.js create mode 100644 scripts/checkProfileViews.js create mode 100644 scripts/cleanDatabase.js create mode 100644 scripts/clearDatabase.js create mode 100644 scripts/clearDatabase.mjs create mode 100644 scripts/clear_database.sql create mode 100644 scripts/createProfileViewsTable.js create mode 100644 scripts/createProfileViewsTable.ts create mode 100644 scripts/testMatching.js create mode 100644 scripts/testProfileViews.js diff --git a/fixes.md b/fixes.md new file mode 100644 index 0000000..ded6c38 --- /dev/null +++ b/fixes.md @@ -0,0 +1,90 @@ +# Исправления ошибок в коде + +## Проблема: Несоответствие имен столбцов в таблице swipes + +В коде обнаружены несоответствия в названиях столбцов при работе с таблицей `swipes`. Используются два разных варианта именования: + +1. `user_id` и `target_user_id` +2. `swiper_id` и `swiped_id` + +Судя по ошибкам в консоли и анализу кода, корректными именами столбцов являются `user_id` и `target_user_id`. + +## Необходимые исправления + +### 1. В файле `profileService.ts` - метод `deleteProfile`: + +```typescript +// Неверно: +await client.query('DELETE FROM swipes WHERE swiper_id = $1 OR swiped_id = $1', [userId]); + +// Исправить на: +await client.query('DELETE FROM swipes WHERE user_id = $1 OR target_user_id = $1', [userId]); +``` + +### 2. В файле `matchingService.ts` - метод `performSwipe`: + +```typescript +// Неверно: +const reciprocalSwipe = await client.query(` + SELECT * FROM swipes + WHERE swiper_id = $1 AND swiped_id = $2 AND direction IN ('right', 'super') +`, [targetUserId, userId]); + +// Исправить на: +const reciprocalSwipe = await client.query(` + SELECT * FROM swipes + WHERE user_id = $1 AND target_user_id = $2 AND direction IN ('right', 'super') +`, [targetUserId, userId]); +``` + +### 3. В файле `matchingService.ts` - метод `getRecentLikes`: + +```typescript +// Неверно (если используется метод mapEntityToSwipe): +private mapEntityToSwipe(entity: any): Swipe { + return new Swipe({ + id: entity.id, + userId: entity.swiper_id, + targetUserId: entity.swiped_id, + type: this.convertDirectionToSwipeType(entity.direction), + timestamp: entity.created_at, + isMatch: entity.is_match + }); +} + +// Исправить на: +private mapEntityToSwipe(entity: any): Swipe { + return new Swipe({ + id: entity.id, + userId: entity.user_id, + targetUserId: entity.target_user_id, + type: this.convertDirectionToSwipeType(entity.direction), + timestamp: entity.created_at, + isMatch: entity.is_match + }); +} +``` + +### 4. В файле `matchingService.ts` - метод `getDailySwipeStats`: + +```typescript +// Неверно: +const result = await query(` + SELECT direction, COUNT(*) as count + FROM swipes + WHERE swiper_id = $1 AND created_at >= $2 + GROUP BY direction +`, [userId, today]); + +// Исправить на: +const result = await query(` + SELECT direction, COUNT(*) as count + FROM swipes + WHERE user_id = $1 AND created_at >= $2 + GROUP BY direction +`, [userId, today]); +``` + +## Примечание + +После внесения исправлений рекомендуется проверить все остальные места в коде, где могут использоваться эти имена столбцов, и убедиться в их согласованности. diff --git a/migrations/1631980000000_add_profile_views_table.ts b/migrations/1631980000000_add_profile_views_table.ts new file mode 100644 index 0000000..73b79c6 --- /dev/null +++ b/migrations/1631980000000_add_profile_views_table.ts @@ -0,0 +1,44 @@ +import { MigrationBuilder, ColumnDefinitions } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +export async function up(pgm: MigrationBuilder): Promise { + // Создание таблицы profile_views для хранения информации о просмотренных профилях + pgm.createTable('profile_views', { + id: { type: 'uuid', primaryKey: true, default: pgm.func('uuid_generate_v4()') }, + viewer_id: { + type: 'uuid', + notNull: true, + references: 'users', + onDelete: 'CASCADE' + }, + viewed_profile_id: { + type: 'uuid', + notNull: true, + references: 'profiles(user_id)', + onDelete: 'CASCADE' + }, + view_date: { type: 'timestamp', notNull: true, default: pgm.func('now()') }, + view_type: { type: 'varchar(20)', notNull: true, default: 'browse' }, // browse, match, like, etc. + }); + + // Создание индекса для быстрого поиска по паре (просмотревший - просмотренный) + pgm.createIndex('profile_views', ['viewer_id', 'viewed_profile_id'], { + unique: true, + name: 'profile_views_viewer_viewed_idx' + }); + + // Индекс для быстрого поиска по viewer_id + pgm.createIndex('profile_views', ['viewer_id'], { + name: 'profile_views_viewer_idx' + }); + + // Индекс для быстрого поиска по viewed_profile_id + pgm.createIndex('profile_views', ['viewed_profile_id'], { + name: 'profile_views_viewed_idx' + }); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable('profile_views', { cascade: true }); +} diff --git a/profile_views_patch.ts b/profile_views_patch.ts new file mode 100644 index 0000000..f9abfb6 --- /dev/null +++ b/profile_views_patch.ts @@ -0,0 +1,153 @@ +// Патч для учета просмотренных профилей в функциональности бота + +// 1. Добавляем функцию recordProfileView в ProfileController +import { Profile, ProfileData } from '../models/Profile'; +import { ProfileService } from '../services/profileService'; + +export class ProfileController { + constructor(private profileService: ProfileService) {} + + // Существующие методы... + + // Новый метод для записи просмотра профиля + async recordProfileView(viewerTelegramId: string, viewedTelegramId: string, viewType: string = 'browse'): Promise { + try { + // Получаем внутренние ID пользователей + const viewerId = await this.profileService.getUserIdByTelegramId(viewerTelegramId); + const viewedId = await this.profileService.getUserIdByTelegramId(viewedTelegramId); + + if (!viewerId || !viewedId) { + console.error('Не удалось найти пользователей для записи просмотра профиля'); + return false; + } + + // Проверяем существование таблицы profile_views + const checkTableResult = await this.profileService.checkTableExists('profile_views'); + + if (checkTableResult) { + // Записываем просмотр + await this.profileService.recordProfileView(viewerId, viewedId, viewType); + console.log(`Просмотр профиля записан: ${viewerTelegramId} просмотрел ${viewedTelegramId}`); + return true; + } else { + console.log('Таблица profile_views не существует, просмотр не записан'); + return false; + } + } catch (error) { + console.error('Ошибка при записи просмотра профиля:', error); + return false; + } + } + + // Новый метод для получения списка просмотренных профилей + async getViewedProfiles(telegramId: string, limit: number = 50): Promise { + try { + // Получаем внутренний ID пользователя + const userId = await this.profileService.getUserIdByTelegramId(telegramId); + + if (!userId) { + console.error('Не удалось найти пользователя для получения списка просмотренных профилей'); + return []; + } + + // Проверяем существование таблицы profile_views + const checkTableResult = await this.profileService.checkTableExists('profile_views'); + + if (checkTableResult) { + // Получаем список просмотренных профилей + return await this.profileService.getViewedProfiles(userId, limit); + } else { + console.log('Таблица profile_views не существует, возвращаем пустой список'); + return []; + } + } catch (error) { + console.error('Ошибка при получении списка просмотренных профилей:', error); + return []; + } + } +} + +// 2. Добавляем функцию для проверки существования таблицы в ProfileService +async checkTableExists(tableName: string): Promise { + try { + const result = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, [tableName]); + + return result.rows.length > 0 && result.rows[0].exists; + } catch (error) { + console.error(`Ошибка проверки существования таблицы ${tableName}:`, error); + return false; + } +} + +// 3. Обновляем обработчик показа профиля, чтобы записывать просмотры +async function handleShowProfile(ctx: any) { + // Существующий код... + + // После успешного отображения профиля записываем просмотр + const viewerTelegramId = ctx.from.id.toString(); + const viewedTelegramId = candidateProfile.telegram_id.toString(); + + try { + const profileController = new ProfileController(new ProfileService()); + await profileController.recordProfileView(viewerTelegramId, viewedTelegramId, 'browse'); + } catch (error) { + console.error('Ошибка при записи просмотра профиля:', error); + } + + // Остальной код... +} + +// 4. Обновляем функцию getNextCandidate, чтобы учитывать просмотренные профили +async function getNextCandidate(ctx: any) { + const telegramId = ctx.from.id.toString(); + const isNewUser = false; // Определяем, является ли пользователь новым + + try { + // Сначала пытаемся получить профили, которые пользователь еще не просматривал + const matchingService = new MatchingService(); + const profileService = new ProfileService(); + const profileController = new ProfileController(profileService); + + // Получаем UUID пользователя + const userId = await profileService.getUserIdByTelegramId(telegramId); + + if (!userId) { + console.error('Не удалось найти пользователя для получения следующего кандидата'); + return null; + } + + // Получаем список просмотренных профилей + const viewedProfiles = await profileController.getViewedProfiles(telegramId); + + // Получаем профиль пользователя + const userProfile = await profileService.getProfileByTelegramId(telegramId); + + if (!userProfile) { + console.error('Не удалось найти профиль пользователя для получения следующего кандидата'); + return null; + } + + // Ищем подходящий профиль с учетом просмотренных + const nextCandidate = await matchingService.getNextCandidate(telegramId, isNewUser); + + // Если найден кандидат, записываем просмотр + if (nextCandidate) { + const viewedTelegramId = await profileService.getTelegramIdByUserId(nextCandidate.userId); + + if (viewedTelegramId) { + await profileController.recordProfileView(telegramId, viewedTelegramId, 'browse'); + } + } + + return nextCandidate; + } catch (error) { + console.error('Ошибка при получении следующего кандидата:', error); + return null; + } +} diff --git a/scripts/checkDatabase.js b/scripts/checkDatabase.js new file mode 100644 index 0000000..c0b65c0 --- /dev/null +++ b/scripts/checkDatabase.js @@ -0,0 +1,66 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD +}); + +async function checkDatabase() { + const client = await pool.connect(); + try { + console.log('\n===== ПРОВЕРКА СОСТОЯНИЯ БАЗЫ ДАННЫХ ====='); + + // Проверка таблицы users + const usersResult = await client.query('SELECT COUNT(*) as count FROM users'); + console.log(`Пользователей в БД: ${usersResult.rows[0].count}`); + if (parseInt(usersResult.rows[0].count) > 0) { + const users = await client.query('SELECT id, telegram_id, username, first_name FROM users LIMIT 10'); + console.log('Последние пользователи:'); + users.rows.forEach(user => { + console.log(` - ID: ${user.id.substring(0, 8)}... | Telegram: ${user.telegram_id} | Имя: ${user.first_name || user.username}`); + }); + } + + // Проверка таблицы profiles + const profilesResult = await client.query('SELECT COUNT(*) as count FROM profiles'); + console.log(`\nПрофилей в БД: ${profilesResult.rows[0].count}`); + if (parseInt(profilesResult.rows[0].count) > 0) { + const profiles = await client.query(` + SELECT p.id, p.user_id, p.name, p.age, p.gender, p.interested_in, p.is_visible + FROM profiles p + ORDER BY p.created_at DESC + LIMIT 10 + `); + console.log('Последние профили:'); + profiles.rows.forEach(profile => { + console.log(` - ID: ${profile.id.substring(0, 8)}... | UserID: ${profile.user_id.substring(0, 8)}... | Имя: ${profile.name} | Возраст: ${profile.age} | Пол: ${profile.gender} | Интересы: ${profile.interested_in} | Виден: ${profile.is_visible}`); + }); + } + + // Проверка таблицы swipes + const swipesResult = await client.query('SELECT COUNT(*) as count FROM swipes'); + console.log(`\nСвайпов в БД: ${swipesResult.rows[0].count}`); + + // Проверка таблицы profile_views + const viewsResult = await client.query('SELECT COUNT(*) as count FROM profile_views'); + console.log(`Просмотров профилей в БД: ${viewsResult.rows[0].count}`); + + // Проверка таблицы matches + const matchesResult = await client.query('SELECT COUNT(*) as count FROM matches'); + console.log(`Матчей в БД: ${matchesResult.rows[0].count}`); + + console.log('\n===== ПРОВЕРКА ЗАВЕРШЕНА =====\n'); + } catch (e) { + console.error('Ошибка при проверке базы данных:', e); + } finally { + client.release(); + await pool.end(); + } +} + +// Запускаем проверку +checkDatabase(); diff --git a/scripts/checkProfileViews.js b/scripts/checkProfileViews.js new file mode 100644 index 0000000..22ebad7 --- /dev/null +++ b/scripts/checkProfileViews.js @@ -0,0 +1,64 @@ +// Скрипт для проверки таблицы profile_views +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD +}); + +async function checkProfileViewsTable() { + const client = await pool.connect(); + try { + console.log('Проверка таблицы profile_views...'); + + // Проверяем наличие таблицы + const tableCheck = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'profile_views' + ); + `); + + const tableExists = tableCheck.rows[0].exists; + console.log(`Таблица profile_views ${tableExists ? 'существует' : 'не существует'}`); + + if (tableExists) { + // Проверяем количество записей в таблице + const countResult = await client.query('SELECT COUNT(*) FROM profile_views'); + console.log(`Количество записей в таблице: ${countResult.rows[0].count}`); + + // Получаем данные из таблицы + const dataResult = await client.query(` + SELECT pv.*, + v.telegram_id as viewer_telegram_id, + vp.telegram_id as viewed_telegram_id + FROM profile_views pv + LEFT JOIN users v ON pv.viewer_id = v.id + LEFT JOIN users vp ON pv.viewed_profile_id = vp.id + LIMIT 10 + `); + + if (dataResult.rows.length > 0) { + console.log('Данные из таблицы profile_views:'); + dataResult.rows.forEach((row, index) => { + console.log(`${index + 1}. Просмотр: ${row.viewer_telegram_id || 'Неизвестно'} → ${row.viewed_telegram_id || 'Неизвестно'}, дата: ${row.view_date}`); + }); + } else { + console.log('Таблица profile_views пуста'); + } + } + } catch (error) { + console.error('Ошибка при проверке таблицы profile_views:', error); + } finally { + client.release(); + await pool.end(); + } +} + +// Запускаем проверку +checkProfileViewsTable(); diff --git a/scripts/cleanDatabase.js b/scripts/cleanDatabase.js new file mode 100644 index 0000000..8ad6c9c --- /dev/null +++ b/scripts/cleanDatabase.js @@ -0,0 +1,55 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD +}); + +async function cleanDatabase() { + const client = await pool.connect(); + try { + console.log('Очистка базы данных...'); + await client.query('BEGIN'); + + // Отключаем временно foreign key constraints + await client.query('SET CONSTRAINTS ALL DEFERRED'); + + // Очищаем таблицы в правильном порядке + console.log('Очистка таблицы messages...'); + await client.query('DELETE FROM messages'); + + console.log('Очистка таблицы profile_views...'); + await client.query('DELETE FROM profile_views'); + + console.log('Очистка таблицы matches...'); + await client.query('DELETE FROM matches'); + + console.log('Очистка таблицы swipes...'); + await client.query('DELETE FROM swipes'); + + console.log('Очистка таблицы profiles...'); + await client.query('DELETE FROM profiles'); + + console.log('Очистка таблицы users...'); + await client.query('DELETE FROM users'); + + // Возвращаем foreign key constraints + await client.query('SET CONSTRAINTS ALL IMMEDIATE'); + + await client.query('COMMIT'); + console.log('✅ База данных успешно очищена'); + } catch (e) { + await client.query('ROLLBACK'); + console.error('❌ Ошибка при очистке базы данных:', e); + } finally { + client.release(); + await pool.end(); + } +} + +// Запускаем функцию очистки +cleanDatabase(); diff --git a/scripts/clearDatabase.js b/scripts/clearDatabase.js new file mode 100644 index 0000000..28b17f3 --- /dev/null +++ b/scripts/clearDatabase.js @@ -0,0 +1,66 @@ +// Скрипт для очистки всех таблиц в базе данных +import { Pool } from 'pg'; +import dotenv from 'dotenv'; + +// Загружаем переменные окружения из .env файла +dotenv.config(); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD +}); + +async function clearDatabase() { + const client = await pool.connect(); + try { + console.log('Начинаем очистку базы данных...'); + + // Начинаем транзакцию + await client.query('BEGIN'); + + // Отключаем внешние ключи на время выполнения (если они используются) + // await client.query('SET session_replication_role = \'replica\''); + + // Очистка таблиц в порядке, учитывающем зависимости + console.log('Очистка таблицы сообщений...'); + await client.query('TRUNCATE TABLE messages CASCADE'); + + console.log('Очистка таблицы просмотров профилей...'); + await client.query('TRUNCATE TABLE profile_views CASCADE'); + + console.log('Очистка таблицы свайпов...'); + await client.query('TRUNCATE TABLE swipes CASCADE'); + + console.log('Очистка таблицы матчей...'); + await client.query('TRUNCATE TABLE matches CASCADE'); + + console.log('Очистка таблицы профилей...'); + await client.query('TRUNCATE TABLE profiles CASCADE'); + + console.log('Очистка таблицы пользователей...'); + await client.query('TRUNCATE TABLE users CASCADE'); + + // Возвращаем внешние ключи (если они использовались) + // await client.query('SET session_replication_role = \'origin\''); + + // Фиксируем транзакцию + await client.query('COMMIT'); + + console.log('Все таблицы успешно очищены!'); + } catch (error) { + // В случае ошибки откатываем транзакцию + await client.query('ROLLBACK'); + console.error('Произошла ошибка при очистке базы данных:', error); + } finally { + // Освобождаем клиента + client.release(); + // Закрываем пул соединений + await pool.end(); + } +} + +// Запускаем функцию очистки +clearDatabase(); diff --git a/scripts/clearDatabase.mjs b/scripts/clearDatabase.mjs new file mode 100644 index 0000000..a0835f7 --- /dev/null +++ b/scripts/clearDatabase.mjs @@ -0,0 +1,81 @@ +// Скрипт для очистки всех таблиц в базе данных +import { Pool } from 'pg'; +import dotenv from 'dotenv'; + +// Загружаем переменные окружения из .env файла +dotenv.config(); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD +}); + +async function clearDatabase() { + const client = await pool.connect(); + try { + console.log('Начинаем очистку базы данных...'); + + // Начинаем транзакцию + await client.query('BEGIN'); + + // Получаем список существующих таблиц + const tablesResult = await client.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE' + `); + + const tables = tablesResult.rows.map(row => row.table_name); + console.log('Найдены таблицы:', tables.join(', ')); + + // Очистка таблиц в порядке, учитывающем зависимости + if (tables.includes('messages')) { + console.log('Очистка таблицы messages...'); + await client.query('TRUNCATE TABLE messages CASCADE'); + } + + if (tables.includes('swipes')) { + console.log('Очистка таблицы swipes...'); + await client.query('TRUNCATE TABLE swipes CASCADE'); + } + + if (tables.includes('matches')) { + console.log('Очистка таблицы matches...'); + await client.query('TRUNCATE TABLE matches CASCADE'); + } + + if (tables.includes('profiles')) { + console.log('Очистка таблицы profiles...'); + await client.query('TRUNCATE TABLE profiles CASCADE'); + } + + if (tables.includes('users')) { + console.log('Очистка таблицы users...'); + await client.query('TRUNCATE TABLE users CASCADE'); + } + + // Возвращаем внешние ключи (если они использовались) + // await client.query('SET session_replication_role = \'origin\''); + + // Фиксируем транзакцию + await client.query('COMMIT'); + + console.log('Все таблицы успешно очищены!'); + } catch (error) { + // В случае ошибки откатываем транзакцию + await client.query('ROLLBACK'); + console.error('Произошла ошибка при очистке базы данных:', error); + } finally { + // Освобождаем клиента + client.release(); + // Закрываем пул соединений + await pool.end(); + } +} + +// Запускаем функцию очистки +clearDatabase(); diff --git a/scripts/clear_database.sql b/scripts/clear_database.sql new file mode 100644 index 0000000..f1071a9 --- /dev/null +++ b/scripts/clear_database.sql @@ -0,0 +1,26 @@ +-- Скрипт для очистки всех таблиц в базе данных +-- Важно: таблицы очищаются в порядке, учитывающем зависимости между ними + +-- Отключаем внешние ключи на время выполнения (если они используются) +-- SET session_replication_role = 'replica'; + +-- Очистка таблицы сообщений +TRUNCATE TABLE messages CASCADE; + +-- Очистка таблицы просмотров профилей +TRUNCATE TABLE profile_views CASCADE; + +-- Очистка таблицы свайпов +TRUNCATE TABLE swipes CASCADE; + +-- Очистка таблицы матчей +TRUNCATE TABLE matches CASCADE; + +-- Очистка таблицы профилей +TRUNCATE TABLE profiles CASCADE; + +-- Очистка таблицы пользователей +TRUNCATE TABLE users CASCADE; + +-- Возвращаем внешние ключи (если они использовались) +-- SET session_replication_role = 'origin'; diff --git a/scripts/createProfileViewsTable.js b/scripts/createProfileViewsTable.js new file mode 100644 index 0000000..dc9899d --- /dev/null +++ b/scripts/createProfileViewsTable.js @@ -0,0 +1,86 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD +}); + +async function createProfileViewsTable() { + const client = await pool.connect(); + try { + console.log('Creating profile_views table...'); + await client.query('BEGIN'); + + // Включаем расширение uuid-ossp, если оно еще не включено + await client.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + + // Создаем таблицу profile_views, если она не существует + await client.query(` + CREATE TABLE IF NOT EXISTS profile_views ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + viewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + viewed_profile_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + view_date TIMESTAMP NOT NULL DEFAULT NOW(), + view_type VARCHAR(20) NOT NULL DEFAULT 'browse' + ) + `); + + // Создаем уникальный индекс для пары (просмотревший - просмотренный) + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE indexname = 'profile_views_viewer_viewed_idx' + ) THEN + CREATE UNIQUE INDEX profile_views_viewer_viewed_idx + ON profile_views (viewer_id, viewed_profile_id); + END IF; + END $$; + `); + + // Создаем индекс для быстрого поиска по viewer_id + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE indexname = 'profile_views_viewer_idx' + ) THEN + CREATE INDEX profile_views_viewer_idx + ON profile_views (viewer_id); + END IF; + END $$; + `); + + // Создаем индекс для быстрого поиска по viewed_profile_id + await client.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_indexes + WHERE indexname = 'profile_views_viewed_idx' + ) THEN + CREATE INDEX profile_views_viewed_idx + ON profile_views (viewed_profile_id); + END IF; + END $$; + `); + + await client.query('COMMIT'); + console.log('Table profile_views created successfully'); + } catch (e) { + await client.query('ROLLBACK'); + console.error('Error creating table:', e); + } finally { + client.release(); + await pool.end(); + } +} + +// Запускаем функцию создания таблицы +createProfileViewsTable(); diff --git a/scripts/createProfileViewsTable.ts b/scripts/createProfileViewsTable.ts new file mode 100644 index 0000000..101e274 --- /dev/null +++ b/scripts/createProfileViewsTable.ts @@ -0,0 +1,70 @@ +// Скрипт для создания таблицы profile_views + +// Функция для ручного запуска создания таблицы profile_views +async function createProfileViewsTable() { + const client = await require('../database/connection').pool.connect(); + try { + console.log('Создание таблицы profile_views...'); + + // Проверяем, существует ли уже таблица profile_views + const tableCheck = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'profile_views' + ); + `); + + if (tableCheck.rows[0].exists) { + console.log('Таблица profile_views уже существует, пропускаем создание'); + return; + } + + // Начинаем транзакцию + await client.query('BEGIN'); + + // Создаем таблицу profile_views + await client.query(` + CREATE TABLE profile_views ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + viewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + viewed_profile_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + view_date TIMESTAMP NOT NULL DEFAULT NOW(), + view_type VARCHAR(20) NOT NULL DEFAULT 'browse' + ); + `); + + // Создаем индекс для быстрого поиска по паре (просмотревший - просмотренный) + await client.query(` + CREATE UNIQUE INDEX profile_views_viewer_viewed_idx ON profile_views (viewer_id, viewed_profile_id); + `); + + // Индекс для быстрого поиска по viewer_id + await client.query(` + CREATE INDEX profile_views_viewer_idx ON profile_views (viewer_id); + `); + + // Индекс для быстрого поиска по viewed_profile_id + await client.query(` + CREATE INDEX profile_views_viewed_idx ON profile_views (viewed_profile_id); + `); + + // Фиксируем транзакцию + await client.query('COMMIT'); + + console.log('Таблица profile_views успешно создана!'); + } catch (error) { + // В случае ошибки откатываем транзакцию + await client.query('ROLLBACK'); + console.error('Произошла ошибка при создании таблицы profile_views:', error); + } finally { + // Освобождаем клиента + client.release(); + } +} + +// Запускаем функцию создания таблицы +createProfileViewsTable() + .then(() => console.log('Скрипт выполнен')) + .catch(err => console.error('Ошибка выполнения скрипта:', err)) + .finally(() => process.exit()); diff --git a/scripts/testMatching.js b/scripts/testMatching.js new file mode 100644 index 0000000..3eaa217 --- /dev/null +++ b/scripts/testMatching.js @@ -0,0 +1,102 @@ +require('dotenv').config(); +const { MatchingService } = require('../dist/services/matchingService'); +const { ProfileService } = require('../dist/services/profileService'); + +// Функция для создания тестовых пользователей +async function createTestUsers() { + const profileService = new ProfileService(); + + console.log('Создание тестовых пользователей...'); + + // Создаем мужской профиль + const maleUserId = await profileService.ensureUser('123456', { + username: 'test_male', + first_name: 'Иван', + last_name: 'Тестов' + }); + + await profileService.createProfile(maleUserId, { + name: 'Иван', + age: 30, + gender: 'male', + interestedIn: 'female', + bio: 'Тестовый мужской профиль', + photos: ['photo1.jpg'], + city: 'Москва', + searchPreferences: { + minAge: 18, + maxAge: 45, + maxDistance: 50 + } + }); + console.log(`Создан мужской профиль: userId=${maleUserId}, telegramId=123456`); + + // Создаем женский профиль + const femaleUserId = await profileService.ensureUser('654321', { + username: 'test_female', + first_name: 'Анна', + last_name: 'Тестова' + }); + + await profileService.createProfile(femaleUserId, { + name: 'Анна', + age: 28, + gender: 'female', + interestedIn: 'male', + bio: 'Тестовый женский профиль', + photos: ['photo2.jpg'], + city: 'Москва', + searchPreferences: { + minAge: 25, + maxAge: 40, + maxDistance: 30 + } + }); + console.log(`Создан женский профиль: userId=${femaleUserId}, telegramId=654321`); + + console.log('Тестовые пользователи созданы успешно'); +} + +// Функция для тестирования подбора анкет +async function testMatching() { + console.log('\n===== ТЕСТИРОВАНИЕ ПОДБОРА АНКЕТ ====='); + + const matchingService = new MatchingService(); + + console.log('\nТест 1: Получение анкеты для мужского профиля (должна вернуться женская анкета)'); + const femaleProfile = await matchingService.getNextCandidate('123456', true); + if (femaleProfile) { + console.log(`✓ Получена анкета: ${femaleProfile.name}, возраст: ${femaleProfile.age}, пол: ${femaleProfile.gender}`); + } else { + console.log('✗ Анкета не найдена'); + } + + console.log('\nТест 2: Получение анкеты для женского профиля (должна вернуться мужская анкета)'); + const maleProfile = await matchingService.getNextCandidate('654321', true); + if (maleProfile) { + console.log(`✓ Получена анкета: ${maleProfile.name}, возраст: ${maleProfile.age}, пол: ${maleProfile.gender}`); + } else { + console.log('✗ Анкета не найдена'); + } + + console.log('\n===== ТЕСТИРОВАНИЕ ЗАВЕРШЕНО =====\n'); + + // Завершение работы скрипта + process.exit(0); +} + +// Главная функция +async function main() { + try { + // Создаем тестовых пользователей + await createTestUsers(); + + // Тестируем подбор анкет + await testMatching(); + } catch (error) { + console.error('Ошибка при выполнении тестов:', error); + process.exit(1); + } +} + +main(); diff --git a/scripts/testProfileViews.js b/scripts/testProfileViews.js new file mode 100644 index 0000000..55195b9 --- /dev/null +++ b/scripts/testProfileViews.js @@ -0,0 +1,98 @@ +// Тестирование работы с таблицей profile_views +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD +}); + +// Функция для тестирования записи просмотра профиля +async function testRecordProfileView(viewerId, viewedProfileId) { + const client = await pool.connect(); + try { + console.log(`Запись просмотра профиля: ${viewerId} просмотрел ${viewedProfileId}`); + + // Получаем UUID пользователей + const viewerResult = await client.query('SELECT id FROM users WHERE telegram_id = $1', [viewerId]); + if (viewerResult.rows.length === 0) { + console.log(`Пользователь с telegram_id ${viewerId} не найден, создаём нового пользователя`); + const newUserResult = await client.query(` + INSERT INTO users (telegram_id, username, first_name, last_name) + VALUES ($1, $2, $3, $4) RETURNING id + `, [viewerId, `user_${viewerId}`, `Имя ${viewerId}`, `Фамилия ${viewerId}`]); + + var viewerUuid = newUserResult.rows[0].id; + } else { + var viewerUuid = viewerResult.rows[0].id; + } + + const viewedResult = await client.query('SELECT id FROM users WHERE telegram_id = $1', [viewedProfileId]); + if (viewedResult.rows.length === 0) { + console.log(`Пользователь с telegram_id ${viewedProfileId} не найден, создаём нового пользователя`); + const newUserResult = await client.query(` + INSERT INTO users (telegram_id, username, first_name, last_name) + VALUES ($1, $2, $3, $4) RETURNING id + `, [viewedProfileId, `user_${viewedProfileId}`, `Имя ${viewedProfileId}`, `Фамилия ${viewedProfileId}`]); + + var viewedUuid = newUserResult.rows[0].id; + } else { + var viewedUuid = viewedResult.rows[0].id; + } + + console.log(`UUID просматривающего: ${viewerUuid}`); + console.log(`UUID просматриваемого: ${viewedUuid}`); + + // Записываем просмотр + await client.query(` + INSERT INTO profile_views (viewer_id, viewed_profile_id, view_type, view_date) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (viewer_id, viewed_profile_id) DO UPDATE + SET view_date = NOW(), view_type = $3 + `, [viewerUuid, viewedUuid, 'browse']); + + console.log('Просмотр профиля успешно записан'); + + // Получаем список просмотренных профилей + const viewedProfiles = await client.query(` + SELECT v.viewed_profile_id, v.view_date, u.telegram_id + FROM profile_views v + JOIN users u ON u.id = v.viewed_profile_id + WHERE v.viewer_id = $1 + ORDER BY v.view_date DESC + `, [viewerUuid]); + + console.log('Список просмотренных профилей:'); + viewedProfiles.rows.forEach((row, index) => { + console.log(`${index + 1}. ID: ${row.telegram_id}, просмотрен: ${row.view_date}`); + }); + + return true; + } catch (error) { + console.error('Ошибка записи просмотра профиля:', error); + return false; + } finally { + client.release(); + } +} + +// Запускаем тест +async function runTest() { + try { + // Тестируем запись просмотра профиля + await testRecordProfileView(123456, 789012); + await testRecordProfileView(123456, 345678); + await testRecordProfileView(789012, 123456); + + console.log('Тесты завершены успешно'); + } catch (error) { + console.error('Ошибка при выполнении тестов:', error); + } finally { + await pool.end(); + } +} + +runTest(); diff --git a/src/services/matchingService.ts b/src/services/matchingService.ts index e064a44..6d54a21 100644 --- a/src/services/matchingService.ts +++ b/src/services/matchingService.ts @@ -17,24 +17,6 @@ export class MatchingService { } // Выполнить свайп - // Конвертация типов свайпов между API и БД - private convertSwipeTypeToDirection(swipeType: SwipeType): string { - switch (swipeType) { - case 'like': return 'right'; - case 'pass': return 'left'; - case 'superlike': return 'super'; - default: return 'left'; - } - } - - private convertDirectionToSwipeType(direction: string): SwipeType { - switch (direction) { - case 'right': return 'like'; - case 'left': return 'pass'; - case 'super': return 'superlike'; - default: return 'pass'; - } - } async performSwipe(telegramId: string, targetTelegramId: string, swipeType: SwipeType): Promise<{ swipe: Swipe; @@ -63,22 +45,21 @@ export class MatchingService { } const swipeId = uuidv4(); - const direction = this.convertSwipeTypeToDirection(swipeType); let isMatch = false; let match: Match | undefined; await transaction(async (client) => { // Создаем свайп await client.query(` - INSERT INTO swipes (id, user_id, target_user_id, direction, created_at) + INSERT INTO swipes (id, user_id, target_user_id, type, created_at) VALUES ($1, $2, $3, $4, $5) - `, [swipeId, userId, targetUserId, direction, new Date()]); + `, [swipeId, userId, targetUserId, swipeType, new Date()]); // Если это лайк или суперлайк, проверяем взаимность if (swipeType === 'like' || swipeType === 'superlike') { const reciprocalSwipe = await client.query(` SELECT * FROM swipes - WHERE swiper_id = $1 AND swiped_id = $2 AND direction IN ('right', 'super') + WHERE user_id = $1 AND target_user_id = $2 AND type IN ('like', 'superlike') `, [targetUserId, userId]); if (reciprocalSwipe.rows.length > 0) { @@ -91,7 +72,7 @@ export class MatchingService { if (existingMatch.rows.length === 0) { isMatch = true; const matchId = uuidv4(); - const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].direction === 'super'; + const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].type === 'superlike'; // Упорядочиваем пользователей для консистентности const [user1Id, user2Id] = userId < targetUserId ? [userId, targetUserId] : [targetUserId, userId]; @@ -300,7 +281,7 @@ export class MatchingService { async getRecentLikes(userId: string, limit: number = 20): Promise { const result = await query(` SELECT * FROM swipes - WHERE target_user_id = $1 AND direction IN ('right', 'super') AND is_match = false + WHERE target_user_id = $1 AND type IN ('like', 'superlike') AND is_match = false ORDER BY created_at DESC LIMIT $2 `, [userId, limit]); @@ -319,10 +300,10 @@ export class MatchingService { today.setHours(0, 0, 0, 0); const result = await query(` - SELECT direction, COUNT(*) as count + SELECT type, COUNT(*) as count FROM swipes - WHERE swiper_id = $1 AND created_at >= $2 - GROUP BY direction + WHERE user_id = $1 AND created_at >= $2 + GROUP BY type `, [userId, today]); const stats = { @@ -336,11 +317,11 @@ export class MatchingService { const count = parseInt(row.count); stats.total += count; - switch (row.direction) { + switch (row.type) { case 'like': stats.likes = count; break; - case 'super': + case 'superlike': stats.superlikes = count; break; case 'pass': @@ -382,9 +363,9 @@ export class MatchingService { private mapEntityToSwipe(entity: any): Swipe { return new Swipe({ id: entity.id, - userId: entity.swiper_id, - targetUserId: entity.swiped_id, - type: this.convertDirectionToSwipeType(entity.direction), + userId: entity.user_id || entity.swiper_id, + targetUserId: entity.target_user_id || entity.swiped_id, + type: entity.type || 'pass', timestamp: entity.created_at, isMatch: entity.is_match }); @@ -412,8 +393,8 @@ export class MatchingService { FROM swipes s1 JOIN swipes s2 ON s1.user_id = s2.target_user_id AND s1.target_user_id = s2.user_id WHERE s1.user_id = $1 - AND s1.direction IN ('right', 'super') - AND s2.direction IN ('right', 'super') + AND s1.type IN ('like', 'superlike') + AND s2.type IN ('like', 'superlike') AND NOT EXISTS ( SELECT 1 FROM matches m WHERE (m.user_id_1 = s1.user_id AND m.user_id_2 = s1.target_user_id) @@ -426,73 +407,156 @@ export class MatchingService { // Получить следующего кандидата для просмотра async getNextCandidate(telegramId: string, isNewUser: boolean = false): Promise { + console.log(`[DEBUG] getNextCandidate вызван для telegramId=${telegramId}, isNewUser=${isNewUser}`); + // Сначала получаем профиль пользователя по telegramId const userProfile = await this.profileService.getProfileByTelegramId(telegramId); if (!userProfile) { + console.log(`[ERROR] Профиль пользователя с telegramId=${telegramId} не найден`); throw new BotError('User profile not found', 'PROFILE_NOT_FOUND'); } + console.log(`[DEBUG] Найден профиль пользователя:`, JSON.stringify({ + userId: userProfile.userId, + gender: userProfile.gender, + interestedIn: userProfile.interestedIn, + minAge: userProfile.searchPreferences?.minAge, + maxAge: userProfile.searchPreferences?.maxAge + })); // Получаем UUID пользователя const userId = userProfile.userId; - - // Получаем список уже просмотренных пользователей - const viewedUsers = await query(` - SELECT DISTINCT target_user_id - FROM swipes - WHERE user_id = $1 - `, [userId]); - - const viewedUserIds = viewedUsers.rows.map((row: any) => row.target_user_id); - viewedUserIds.push(userId); // Исключаем самого себя - - // Если это новый пользователь или у пользователя мало просмотренных профилей, - // показываем всех пользователей по очереди (исключая только себя) - let excludeCondition = ''; - if (!isNewUser) { - excludeCondition = viewedUserIds.length > 0 - ? `AND p.user_id NOT IN (${viewedUserIds.map((_: any, i: number) => `$${i + 2}`).join(', ')})` - : ''; + // Определяем, каким должен быть пол показываемых профилей + let targetGender: string; + if (userProfile.interestedIn === 'male' || userProfile.interestedIn === 'female') { + targetGender = userProfile.interestedIn; } else { - // Для новых пользователей исключаем только себя - excludeCondition = `AND p.user_id != $2`; + // Если "both" или другое значение, показываем противоположный пол + targetGender = userProfile.gender === 'male' ? 'female' : 'male'; + } + + console.log(`[DEBUG] Определен целевой пол для поиска: ${targetGender}`); + + // Получаем список просмотренных профилей из новой таблицы profile_views + // и добавляем также профили из свайпов для полной совместимости + console.log(`[DEBUG] Запрашиваем просмотренные и свайпнутые профили для userId=${userId}`); + const [viewedProfilesResult, swipedProfilesResult] = await Promise.all([ + query(` + SELECT DISTINCT viewed_profile_id + FROM profile_views + WHERE viewer_id = $1 + `, [userId]), + query(` + SELECT DISTINCT target_user_id + FROM swipes + WHERE user_id = $1 + `, [userId]) + ]); + console.log(`[DEBUG] Найдено ${viewedProfilesResult.rows.length} просмотренных и ${swipedProfilesResult.rows.length} свайпнутых профилей`); + + // Объединяем просмотренные и свайпнутые профили в один список + const viewedUserIds = [ + ...viewedProfilesResult.rows.map((row: any) => row.viewed_profile_id), + ...swipedProfilesResult.rows.map((row: any) => row.target_user_id) + ]; + + // Всегда добавляем самого пользователя в список исключений + viewedUserIds.push(userId); + + // Удаляем дубликаты + const uniqueViewedIds = [...new Set(viewedUserIds)]; + console.log(`[DEBUG] Всего ${uniqueViewedIds.length} уникальных исключаемых профилей`); + + // Формируем параметры запроса + let params: any[] = []; + let excludeCondition: string = ''; + + // Для новых пользователей исключаем только себя + if (isNewUser || uniqueViewedIds.length <= 1) { + params = [userId]; + excludeCondition = 'AND p.user_id != $1'; + console.log(`[DEBUG] Режим нового пользователя: исключаем только самого себя`); + } else { + // Для остальных исключаем все просмотренные профили + params = [...uniqueViewedIds]; + const placeholders = uniqueViewedIds.map((_: any, i: number) => `$${i + 1}`).join(', '); + excludeCondition = `AND p.user_id NOT IN (${placeholders})`; + console.log(`[DEBUG] Стандартный режим: исключаем ${uniqueViewedIds.length} профилей`); } - // Ищем подходящих кандидатов + // Выполним предварительный запрос для проверки наличия доступных анкет + const countQuery = ` + SELECT COUNT(*) as count + FROM profiles p + JOIN users u ON p.user_id = u.id + WHERE p.is_visible = true + AND p.gender = '${targetGender}' + ${excludeCondition} + `; + + console.log(`[DEBUG] Проверка наличия подходящих анкет...`); + console.log(`[DEBUG] SQL запрос count: ${countQuery}`); + console.log(`[DEBUG] Параметры count: ${JSON.stringify(params)}`); + const countResult = await query(countQuery, params); + const availableProfilesCount = parseInt(countResult.rows[0]?.count || '0'); + console.log(`[DEBUG] Найдено ${availableProfilesCount} доступных профилей`); + + // Используем определенный ранее targetGender для поиска + console.log(`[DEBUG] Поиск кандидата для gender=${targetGender}, возраст: ${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 = $1 + AND p.gender = '${targetGender}' AND p.age BETWEEN ${userProfile.searchPreferences.minAge} AND ${userProfile.searchPreferences.maxAge} ${excludeCondition} ORDER BY RANDOM() LIMIT 1 `; + console.log(`[DEBUG] SQL запрос: ${candidateQuery}`); + console.log(`[DEBUG] Параметры: ${JSON.stringify(params)}`); - const params = [userProfile.interestedIn, ...viewedUserIds]; const result = await query(candidateQuery, params); + console.log(`[DEBUG] Результаты запроса: найдено ${result.rows.length} профилей`); if (result.rows.length === 0) { + console.log(`[DEBUG] Подходящие кандидаты не найдены`); return null; } const candidateData = result.rows[0]; + console.log(`[DEBUG] Найден подходящий кандидат: ${candidateData.name}, возраст: ${candidateData.age}`); + + // Записываем просмотр профиля в новую таблицу profile_views + try { + const viewerTelegramId = telegramId; + const viewedTelegramId = candidateData.telegram_id.toString(); + + console.log(`[DEBUG] Записываем просмотр профиля: viewer=${viewerTelegramId}, viewed=${viewedTelegramId}`); + // Асинхронно записываем просмотр, но не ждем завершения + this.profileService.recordProfileView(viewerTelegramId, viewedTelegramId, 'browse') + .catch(err => console.error(`[ERROR] Ошибка записи просмотра профиля:`, err)); + } catch (err) { + console.error(`[ERROR] Ошибка записи просмотра профиля:`, err); + } // Используем ProfileService для правильного маппинга данных - return this.profileService.mapEntityToProfile(candidateData); + const profile = this.profileService.mapEntityToProfile(candidateData); + console.log(`[DEBUG] Профиль преобразован и возвращается клиенту`); + return profile; } // VIP функция: поиск кандидатов по цели знакомства async getCandidatesWithGoal(userProfile: Profile, targetGoal: string): Promise { const swipedUsersResult = await query(` - SELECT swiped_id + SELECT target_user_id FROM swipes - WHERE swiper_id = $1 + WHERE user_id = $1 `, [userProfile.userId]); - const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.swiped_id); + const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.target_user_id); swipedUserIds.push(userProfile.userId); // Исключаем себя let candidateQuery = ` diff --git a/src/services/profileService.ts b/src/services/profileService.ts index 6b4fcdb..fe35dd6 100644 --- a/src/services/profileService.ts +++ b/src/services/profileService.ts @@ -496,7 +496,7 @@ export class ProfileService { // Удаляем связанные данные await client.query('DELETE FROM messages WHERE sender_id = $1 OR receiver_id = $1', [userId]); await client.query('DELETE FROM matches WHERE user_id_1 = $1 OR user_id_2 = $1', [userId]); - await client.query('DELETE FROM swipes WHERE swiper_id = $1 OR swiped_id = $1', [userId]); + await client.query('DELETE FROM swipes WHERE user_id = $1 OR target_user_id = $1', [userId]); await client.query('DELETE FROM profiles WHERE user_id = $1', [userId]); }); return true; @@ -526,16 +526,38 @@ export class ProfileService { // Записать просмотр профиля async recordProfileView(viewerId: string, viewedProfileId: string, viewType: string = 'browse'): Promise { try { + // Преобразуем строковые ID в числа для запросов + const viewerTelegramId = typeof viewerId === 'string' ? parseInt(viewerId) : viewerId; + const viewedTelegramId = typeof viewedProfileId === 'string' ? parseInt(viewedProfileId) : viewedProfileId; + + // Получаем внутренние ID пользователей + const viewerIdResult = await query('SELECT id FROM users WHERE telegram_id = $1', [viewerTelegramId]); + if (viewerIdResult.rows.length === 0) { + throw new Error(`User with telegram_id ${viewerId} not found`); + } + + const viewedUserResult = await query('SELECT id FROM users WHERE telegram_id = $1', [viewedTelegramId]); + if (viewedUserResult.rows.length === 0) { + throw new Error(`User with telegram_id ${viewedProfileId} not found`); + } + + const viewerUuid = viewerIdResult.rows[0].id; + const viewedUuid = viewedUserResult.rows[0].id; + + // Не записываем просмотры своего профиля + if (viewerUuid === viewedUuid) { + console.log('Skipping self-view record'); + return; + } + await query(` - INSERT INTO profile_views (viewer_id, viewed_profile_id, view_type) - VALUES ( - (SELECT id FROM users WHERE telegram_id = $1), - (SELECT id FROM profiles WHERE user_id = (SELECT id FROM users WHERE telegram_id = $2)), - $3 - ) + INSERT INTO profile_views (viewer_id, viewed_profile_id, view_type, view_date) + VALUES ($1, $2, $3, NOW()) ON CONFLICT (viewer_id, viewed_profile_id) DO UPDATE - SET viewed_at = CURRENT_TIMESTAMP, view_type = EXCLUDED.view_type - `, [viewerId, viewedProfileId, viewType]); + SET view_date = NOW(), view_type = $3 + `, [viewerUuid, viewedUuid, viewType]); + + console.log(`Recorded profile view: ${viewerId} viewed ${viewedProfileId}`); } catch (error) { console.error('Error recording profile view:', error); } @@ -547,8 +569,7 @@ export class ProfileService { const result = await query(` SELECT COUNT(*) as count FROM profile_views pv - JOIN profiles p ON pv.viewed_profile_id = p.id - WHERE p.user_id = $1 + WHERE viewed_profile_id = $1 `, [userId]); return parseInt(result.rows[0].count) || 0; @@ -562,14 +583,12 @@ export class ProfileService { async getProfileViewers(userId: string, limit: number = 10): Promise { try { const result = await query(` - SELECT DISTINCT p.*, u.telegram_id, u.username, u.first_name, u.last_name + SELECT DISTINCT p.* FROM profile_views pv - JOIN profiles target_p ON pv.viewed_profile_id = target_p.id JOIN users viewer_u ON pv.viewer_id = viewer_u.id JOIN profiles p ON viewer_u.id = p.user_id - JOIN users u ON p.user_id = u.id - WHERE target_p.user_id = $1 - ORDER BY pv.viewed_at DESC + WHERE pv.viewed_profile_id = $1 + ORDER BY pv.view_date DESC LIMIT $2 `, [userId, limit]); @@ -579,4 +598,22 @@ export class ProfileService { return []; } } + + // Получить список просмотренных профилей + async getViewedProfiles(userId: string, limit: number = 50): Promise { + try { + const result = await query(` + SELECT viewed_profile_id + FROM profile_views + WHERE viewer_id = $1 + ORDER BY view_date DESC + LIMIT $2 + `, [userId, limit]); + + return result.rows.map((row: any) => row.viewed_profile_id); + } catch (error) { + console.error('Error getting viewed profiles:', error); + return []; + } + } } \ No newline at end of file From 5ea3e8c1f3315a3af91237015daa7060053a9854 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Thu, 18 Sep 2025 13:46:35 +0900 Subject: [PATCH 3/4] alpha-test --- migrations/add_user_state_columns.sql | 14 + scripts/README.md | 76 + scripts/addPremiumColumn.js | 58 + scripts/checkCallbackHandlers.js | 88 ++ scripts/checkUserTable.js | 74 + scripts/createNotificationTables.js | 259 ++++ scripts/fixCallbackHandlers.js | 142 ++ scripts/fixDatabaseStructure.js | 170 +++ scripts/fix_all_notifications.js | 48 + scripts/fix_notification_callbacks.js | 332 +++++ scripts/setPremiumStatus.js | 73 + scripts/testCallbacks.js | 85 ++ scripts/testVipMethod.js | 81 + scripts/testVipStatus.js | 75 + scripts/update_bot_with_notifications.js | 104 ++ src/bot.ts | 51 +- src/handlers/callbackHandlers.ts | 76 +- .../callbackHandlers.ts.backup-1758166633763 | 606 ++++++++ src/handlers/callbackHandlers.ts.original | Bin 0 -> 204286 bytes src/handlers/callbackHandlers.ts.stub | 606 ++++++++ src/handlers/commandHandlers.ts | 20 +- src/handlers/notificationHandlers.ts | 644 ++++++++ src/scripts/setPremiumForAll.ts | 26 +- src/services/matchingService.ts | 2 +- src/services/notificationService.ts | 992 +++++++++++-- src/services/notificationService.ts.new | 1316 +++++++++++++++++ src/services/vipService.ts | 43 +- 27 files changed, 5887 insertions(+), 174 deletions(-) create mode 100644 migrations/add_user_state_columns.sql create mode 100644 scripts/README.md create mode 100644 scripts/addPremiumColumn.js create mode 100644 scripts/checkCallbackHandlers.js create mode 100644 scripts/checkUserTable.js create mode 100644 scripts/createNotificationTables.js create mode 100644 scripts/fixCallbackHandlers.js create mode 100644 scripts/fixDatabaseStructure.js create mode 100644 scripts/fix_all_notifications.js create mode 100644 scripts/fix_notification_callbacks.js create mode 100644 scripts/setPremiumStatus.js create mode 100644 scripts/testCallbacks.js create mode 100644 scripts/testVipMethod.js create mode 100644 scripts/testVipStatus.js create mode 100644 scripts/update_bot_with_notifications.js create mode 100644 src/handlers/callbackHandlers.ts.backup-1758166633763 create mode 100644 src/handlers/callbackHandlers.ts.original create mode 100644 src/handlers/callbackHandlers.ts.stub create mode 100644 src/handlers/notificationHandlers.ts create mode 100644 src/services/notificationService.ts.new diff --git a/migrations/add_user_state_columns.sql b/migrations/add_user_state_columns.sql new file mode 100644 index 0000000..0c6c0cd --- /dev/null +++ b/migrations/add_user_state_columns.sql @@ -0,0 +1,14 @@ +-- Добавление столбцов state и state_data в таблицу users для обработки состояний пользователя + +-- Добавляем столбец state для хранения текущего состояния пользователя +ALTER TABLE users ADD COLUMN IF NOT EXISTS state VARCHAR(255) NULL; + +-- Добавляем столбец state_data для хранения дополнительных данных о состоянии +ALTER TABLE users ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}'::jsonb; + +-- Добавляем индекс для быстрого поиска по state +CREATE INDEX IF NOT EXISTS idx_users_state ON users(state); + +-- Комментарий к столбцам +COMMENT ON COLUMN users.state IS 'Текущее состояние пользователя (например, ожидание ввода)'; +COMMENT ON COLUMN users.state_data IS 'Дополнительные данные о состоянии пользователя в формате JSON'; diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6651500 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,76 @@ +# Исправление проблем с уведомлениями в боте + +Этот набор скриптов предназначен для исправления проблем с обработкой уведомлений в боте. + +## Описание проблемы + +После внедрения системы уведомлений и связанных с ней изменений в базе данных, возникла проблема с обработкой callback запросов. Бот перестал реагировать на все callback запросы, кроме тех, что связаны с уведомлениями. + +Проблема вызвана следующими факторами: +1. Отсутствие или неверная структура таблиц в базе данных для хранения уведомлений +2. Отсутствие необходимых полей `state` и `state_data` в таблице `users` +3. Отсутствие правильной регистрации обработчиков уведомлений в файле `bot.ts` + +## Решение + +Для решения проблемы были созданы следующие скрипты: + +### 1. `fix_notification_callbacks.js` +Проверяет и создает необходимые таблицы и столбцы в базе данных: +- Таблицы `notifications`, `scheduled_notifications`, `notification_templates` +- Столбцы `notification_settings`, `state`, `state_data` в таблице `users` + +### 2. `update_bot_with_notifications.js` +Обновляет файл `bot.ts`: +- Добавляет импорт класса `NotificationHandlers` +- Добавляет объявление поля `notificationHandlers` в класс `TelegramTinderBot` +- Добавляет создание экземпляра `NotificationHandlers` в конструкторе +- Добавляет регистрацию обработчиков уведомлений в методе `registerHandlers` + +### 3. `fix_all_notifications.js` +Запускает оба скрипта последовательно для полного исправления проблемы + +## Как использовать + +1. Остановите бота, если он запущен: + ```bash + # Нажмите Ctrl+C в терминале, где запущен бот + # или найдите процесс и завершите его + ``` + +2. Запустите комплексный скрипт исправления: + ```bash + node scripts/fix_all_notifications.js + ``` + +3. После успешного выполнения скрипта перезапустите бота: + ```bash + npm run start + ``` + +## Проверка результата + +После запуска бота убедитесь, что: +1. Бот отвечает на все callback запросы (включая кнопки, не связанные с уведомлениями) +2. Настройки уведомлений работают корректно (команда /notifications или кнопка в меню настроек) +3. Уведомления о лайках, супер-лайках и новых матчах приходят пользователям + +## Если проблемы остались + +Если после выполнения всех шагов проблемы остались, выполните следующие проверки: + +1. Проверьте логи бота на наличие ошибок +2. Проверьте структуру базы данных: + ```sql + \dt -- Список всех таблиц + \d notifications -- Структура таблицы notifications + \d scheduled_notifications -- Структура таблицы scheduled_notifications + \d notification_templates -- Структура таблицы notification_templates + \d users -- Убедитесь, что поля state, state_data и notification_settings существуют + ``` + +3. Проверьте код в файлах: + - `src/bot.ts`: должен содержать импорт, создание и регистрацию `NotificationHandlers` + - `src/handlers/callbackHandlers.ts`: должен правильно обрабатывать все callback-запросы + +В случае обнаружения ошибок, исправьте их вручную и перезапустите бота. diff --git a/scripts/addPremiumColumn.js b/scripts/addPremiumColumn.js new file mode 100644 index 0000000..1d3a247 --- /dev/null +++ b/scripts/addPremiumColumn.js @@ -0,0 +1,58 @@ +// Скрипт для добавления колонки premium в таблицу users и установки premium для всех пользователей +require('dotenv').config(); +const { Pool } = require('pg'); + +// Создаем пул соединений +const pool = new Pool({ + user: process.env.DB_USERNAME, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: parseInt(process.env.DB_PORT || '5432') +}); + +async function setAllUsersToPremium() { + try { + console.log('Проверяем наличие столбца premium в таблице users...'); + + const result = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'premium' + ); + `); + + if (!result.rows[0].exists) { + console.log('🔄 Добавляем столбец premium...'); + await pool.query(`ALTER TABLE users ADD COLUMN premium BOOLEAN DEFAULT false;`); + console.log('✅ Столбец premium успешно добавлен'); + } else { + console.log('✅ Столбец premium уже существует'); + } + + console.log('Устанавливаем премиум-статус для всех пользователей...'); + + const updateResult = await pool.query(` + UPDATE users + SET premium = true + WHERE true + RETURNING id, telegram_id, premium + `); + + console.log(`✅ Успешно установлен премиум-статус для ${updateResult.rows.length} пользователей:`); + updateResult.rows.forEach(row => { + console.log(`ID: ${row.id.substr(0, 8)}... | Telegram ID: ${row.telegram_id} | Premium: ${row.premium}`); + }); + + console.log('🎉 Все пользователи теперь имеют премиум-статус!'); + } catch (error) { + console.error('Ошибка при установке премиум-статуса:', error); + } finally { + await pool.end(); + console.log('Соединение с базой данных закрыто'); + } +} + +setAllUsersToPremium(); diff --git a/scripts/checkCallbackHandlers.js b/scripts/checkCallbackHandlers.js new file mode 100644 index 0000000..31087aa --- /dev/null +++ b/scripts/checkCallbackHandlers.js @@ -0,0 +1,88 @@ +// Скрипт для анализа и отладки проблем с обработчиками коллбэков +require('dotenv').config(); +const fs = require('fs'); +const path = require('path'); + +function analyzeCallbackHandlers() { + const filePath = path.join(__dirname, '..', 'src', 'handlers', 'callbackHandlers.ts'); + const content = fs.readFileSync(filePath, 'utf-8'); + + // Проверяем наличие реализаций методов + const methodsToCheck = [ + 'handleCreateProfile', + 'handleGenderSelection', + 'handleViewMyProfile', + 'handleEditProfile', + 'handleManagePhotos', + 'handleStartBrowsing', + 'handleSettings' + ]; + + const issues = []; + let debugInfo = []; + + methodsToCheck.forEach(method => { + debugInfo.push(`Проверяем метод: ${method}`); + + // Проверяем наличие полной реализации метода (не только сигнатуры) + const methodSignatureRegex = new RegExp(`async\\s+${method}\\s*\\([^)]*\\)\\s*:\\s*Promise\\s*{`, 'g'); + const hasSignature = methodSignatureRegex.test(content); + + const methodBodyRegex = new RegExp(`async\\s+${method}\\s*\\([^)]*\\)\\s*:\\s*Promise\\s*{[\\s\\S]+?}`, 'g'); + const methodMatch = content.match(methodBodyRegex); + + debugInfo.push(` Сигнатура найдена: ${hasSignature}`); + debugInfo.push(` Реализация найдена: ${methodMatch !== null}`); + + if (methodMatch) { + const methodContent = methodMatch[0]; + debugInfo.push(` Длина метода: ${methodContent.length} символов`); + + // Проверяем, содержит ли метод только заглушку + const isStub = methodContent.includes('// Заглушка метода') || + (!methodContent.includes('await') && methodContent.split('\n').length <= 3); + + if (isStub) { + issues.push(`❌ Метод ${method} содержит только заглушку, нет реальной реализации`); + } else { + debugInfo.push(` Метод ${method} имеет полную реализацию`); + } + } else if (hasSignature) { + issues.push(`❌ Метод ${method} имеет только сигнатуру, но нет реализации`); + } else { + issues.push(`❌ Метод ${method} не найден в файле`); + } + }); + + // Проверяем регистрацию обработчиков для NotificationHandlers + const notificationHandlersRegex = /this\.notificationHandlers\s*=\s*new\s+NotificationHandlers\(bot\);/g; + const hasNotificationHandlers = notificationHandlersRegex.test(content); + debugInfo.push(`NotificationHandlers инициализирован: ${hasNotificationHandlers}`); + + // Проверяем обработку коллбэка notifications + const notificationsCallbackRegex = /if\s*\(data\s*===\s*['"]notifications['"].*?\)/g; + const hasNotificationsCallback = notificationsCallbackRegex.test(content); + debugInfo.push(`Обработчик для callback 'notifications' найден: ${hasNotificationsCallback}`); + + // Выводим результаты + console.log('\n=== Анализ CallbackHandlers.ts ===\n'); + if (issues.length > 0) { + console.log('НАЙДЕНЫ ПРОБЛЕМЫ:'); + issues.forEach(issue => console.log(issue)); + console.log('\nРЕКОМЕНДАЦИИ:'); + console.log('1. Восстановите оригинальные реализации методов вместо заглушек'); + console.log('2. Убедитесь, что методы содержат необходимую бизнес-логику'); + console.log('3. Проверьте, что все коллбэки правильно обрабатываются'); + } else { + console.log('✅ Проблем не обнаружено'); + } + + console.log('\n=== Отладочная информация ===\n'); + debugInfo.forEach(info => console.log(info)); + + // Проверяем количество методов в файле + const asyncMethodsCount = (content.match(/async\s+handle[A-Za-z]+\s*\(/g) || []).length; + console.log(`\nВсего async методов в файле: ${asyncMethodsCount}`); +} + +analyzeCallbackHandlers(); diff --git a/scripts/checkUserTable.js b/scripts/checkUserTable.js new file mode 100644 index 0000000..811b3a6 --- /dev/null +++ b/scripts/checkUserTable.js @@ -0,0 +1,74 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + user: process.env.DB_USER || 'postgres', + host: process.env.DB_HOST || 'localhost', + database: process.env.DB_NAME || 'telegram_tinder_db', + password: process.env.DB_PASSWORD || 'postgres', + port: parseInt(process.env.DB_PORT || '5432') +}); + +async function checkUserTableStructure() { + try { + // Получаем информацию о структуре таблицы users + const result = await pool.query(` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'users' + ORDER BY ordinal_position; + `); + + console.log('=== Структура таблицы users ==='); + console.table(result.rows); + + // Проверяем наличие столбцов state и state_data + const stateColumn = result.rows.find(row => row.column_name === 'state'); + const stateDataColumn = result.rows.find(row => row.column_name === 'state_data'); + + if (!stateColumn) { + console.log('❌ Столбец state отсутствует в таблице users'); + } else { + console.log('✅ Столбец state присутствует в таблице users'); + } + + if (!stateDataColumn) { + console.log('❌ Столбец state_data отсутствует в таблице users'); + } else { + console.log('✅ Столбец state_data присутствует в таблице users'); + } + + // Добавляем эти столбцы, если их нет + if (!stateColumn || !stateDataColumn) { + console.log('🔄 Добавление отсутствующих столбцов...'); + + await pool.query(` + ALTER TABLE users + ADD COLUMN IF NOT EXISTS state VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}'::jsonb; + `); + + console.log('✅ Столбцы успешно добавлены'); + } + + // Проверяем наличие других таблиц, связанных с уведомлениями + const tablesResult = await pool.query(` + SELECT tablename + FROM pg_catalog.pg_tables + WHERE schemaname = 'public' + AND tablename IN ('notifications', 'notification_settings', 'scheduled_notifications'); + `); + + console.log('\n=== Таблицы для уведомлений ==='); + console.table(tablesResult.rows); + + // Закрываем соединение + await pool.end(); + + } catch (error) { + console.error('Ошибка при проверке структуры таблицы:', error); + await pool.end(); + } +} + +checkUserTableStructure(); diff --git a/scripts/createNotificationTables.js b/scripts/createNotificationTables.js new file mode 100644 index 0000000..23ef00d --- /dev/null +++ b/scripts/createNotificationTables.js @@ -0,0 +1,259 @@ +const { Pool } = require('pg'); +const dotenv = require('dotenv'); +const uuid = require('uuid'); + +dotenv.config(); + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +async function createNotificationTables() { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + console.log('Creating UUID extension if not exists...'); + await client.query(` + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + `); + + // Проверяем существование таблицы notifications + const notificationsExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'notifications' + ) as exists + `); + + if (!notificationsExists.rows[0].exists) { + console.log('Creating notifications table...'); + await client.query(` + CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + console.log('Creating index on notifications...'); + await client.query(` + CREATE INDEX idx_notifications_user_id ON notifications (user_id); + CREATE INDEX idx_notifications_type ON notifications (type); + CREATE INDEX idx_notifications_created_at ON notifications (created_at); + `); + } else { + console.log('Notifications table already exists.'); + } + + // Проверяем существование таблицы scheduled_notifications + const scheduledExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'scheduled_notifications' + ) as exists + `); + + if (!scheduledExists.rows[0].exists) { + console.log('Creating scheduled_notifications table...'); + await client.query(` + CREATE TABLE scheduled_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + scheduled_at TIMESTAMP NOT NULL, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + + console.log('Creating index on scheduled_notifications...'); + await client.query(` + CREATE INDEX idx_scheduled_notifications_user_id ON scheduled_notifications (user_id); + CREATE INDEX idx_scheduled_notifications_scheduled_at ON scheduled_notifications (scheduled_at); + CREATE INDEX idx_scheduled_notifications_processed ON scheduled_notifications (processed); + `); + } else { + console.log('Scheduled_notifications table already exists.'); + } + + // Проверяем существование таблицы notification_templates + const templatesExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'notification_templates' + ) as exists + `); + + if (!templatesExists.rows[0].exists) { + console.log('Creating notification_templates table...'); + await client.query(` + CREATE TABLE notification_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(50) NOT NULL UNIQUE, + title TEXT NOT NULL, + message_template TEXT NOT NULL, + button_template JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + } else { + console.log('Notification_templates table already exists.'); + } + + // Проверяем наличие колонки notification_settings в таблице users + const settingsColumnExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'notification_settings' + ) as exists + `); + + if (!settingsColumnExists.rows[0].exists) { + console.log('Adding notification_settings column to users table...'); + await client.query(` + ALTER TABLE users + ADD COLUMN notification_settings JSONB DEFAULT '{ + "newMatches": true, + "newMessages": true, + "newLikes": true, + "reminders": true, + "dailySummary": true, + "timePreference": "evening", + "doNotDisturb": false + }'::jsonb + `); + } else { + console.log('Notification_settings column already exists in users table.'); + } + + // Заполнение таблицы шаблонов уведомлений базовыми шаблонами + if (!templatesExists.rows[0].exists) { + console.log('Populating notification templates...'); + + const templates = [ + { + type: 'new_like', + title: 'Новый лайк!', + message_template: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'super_like', + title: 'Супер-лайк!', + message_template: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'new_match', + title: 'Новый матч!', + message_template: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'new_message', + title: 'Новое сообщение!', + message_template: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + button_template: { + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'match_reminder', + title: 'Напоминание о матче', + message_template: '💕 У вас есть матч с *{{name}}*, но вы еще не начали общение!\n\nНе упустите шанс познакомиться поближе!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }] + ] + } + }, + { + type: 'inactive_matches', + title: 'Неактивные матчи', + message_template: '⏰ У вас {{count}} неактивных матчей!\n\nПродолжите общение, чтобы не упустить интересные знакомства!', + button_template: { + inline_keyboard: [ + [{ text: '📋 Открыть матчи', callback_data: 'view_matches' }], + [{ text: '💕 Смотреть новые анкеты', callback_data: 'start_browsing' }] + ] + } + }, + { + type: 'like_summary', + title: 'Сводка лайков', + message_template: '💖 У вас {{count}} новых лайков!\n\nПосмотрите, кто проявил к вам интерес сегодня!', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть лайки', callback_data: 'view_likes' }], + [{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }] + ] + } + } + ]; + + for (const template of templates) { + await client.query(` + INSERT INTO notification_templates (id, type, title, message_template, button_template) + VALUES ($1, $2, $3, $4, $5) + `, [ + uuid.v4(), + template.type, + template.title, + template.message_template, + JSON.stringify(template.button_template) + ]); + } + } + + await client.query('COMMIT'); + console.log('Successfully created notification tables'); + } catch (err) { + await client.query('ROLLBACK'); + console.error('Error creating notification tables:', err); + } finally { + client.release(); + pool.end(); + } +} + +createNotificationTables().catch(err => console.error('Failed to create notification tables:', err)); diff --git a/scripts/fixCallbackHandlers.js b/scripts/fixCallbackHandlers.js new file mode 100644 index 0000000..e5182cf --- /dev/null +++ b/scripts/fixCallbackHandlers.js @@ -0,0 +1,142 @@ +// Скрипт для восстановления оригинальной функциональности callbackHandlers.ts +const fs = require('fs'); +const path = require('path'); + +// Находим самую последнюю версию файла callbackHandlers.ts в репозитории +const { execSync } = require('child_process'); + +try { + console.log('Поиск оригинальной версии CallbackHandlers.ts с полной функциональностью...'); + + // Находим коммиты, содержащие значительные изменения в файле (более 1000 символов) + const commits = execSync('git log --format="%H" -- src/handlers/callbackHandlers.ts') + .toString() + .trim() + .split('\n'); + + console.log(`Найдено ${commits.length} коммитов с изменениями файла`); + + // Пробуем разные коммиты, начиная с последнего, чтобы найти полную реализацию + let foundFullImplementation = false; + let fullImplementationContent = ''; + + for (const commit of commits) { + console.log(`Проверяем коммит ${commit.substring(0, 8)}...`); + + try { + const fileContent = execSync(`git show ${commit}:src/handlers/callbackHandlers.ts`).toString(); + + // Проверяем, содержит ли файл полные реализации методов + const hasFullImplementations = !fileContent.includes('// Заглушка метода') && + fileContent.includes('await this.bot.sendMessage'); + + if (hasFullImplementations) { + console.log(`✅ Найдена полная реализация в коммите ${commit.substring(0, 8)}`); + fullImplementationContent = fileContent; + foundFullImplementation = true; + break; + } else { + console.log(`❌ Коммит ${commit.substring(0, 8)} не содержит полной реализации`); + } + } catch (error) { + console.error(`Ошибка при проверке коммита ${commit}:`, error.message); + } + } + + if (!foundFullImplementation) { + console.error('❌ Не удалось найти полную реализацию в истории коммитов'); + process.exit(1); + } + + // Теперь получаем текущую версию файла с поддержкой уведомлений + console.log('Получаем текущую версию с поддержкой уведомлений...'); + const currentFilePath = path.join(__dirname, '..', 'src', 'handlers', 'callbackHandlers.ts'); + const currentContent = fs.readFileSync(currentFilePath, 'utf-8'); + + // Сначала создаем бэкап текущего файла + const backupPath = currentFilePath + '.backup-' + Date.now(); + fs.writeFileSync(backupPath, currentContent); + console.log(`✅ Создан бэкап текущей версии: ${path.basename(backupPath)}`); + + // Извлекаем код для поддержки уведомлений из текущей версии + console.log('Извлекаем код для поддержки уведомлений...'); + + // Находим импорт NotificationHandlers + const notificationImportRegex = /import\s+{\s*NotificationHandlers\s*}\s*from\s*['"]\.\/notificationHandlers['"]\s*;/; + const notificationImport = currentContent.match(notificationImportRegex)?.[0] || ''; + + // Находим объявление поля notificationHandlers + const notificationFieldRegex = /private\s+notificationHandlers\?\s*:\s*NotificationHandlers\s*;/; + const notificationField = currentContent.match(notificationFieldRegex)?.[0] || ''; + + // Находим инициализацию notificationHandlers в конструкторе + const notificationInitRegex = /\/\/\s*Создаем экземпляр NotificationHandlers[\s\S]*?try\s*{[\s\S]*?this\.notificationHandlers\s*=\s*new\s*NotificationHandlers[\s\S]*?}\s*catch[\s\S]*?}/; + const notificationInit = currentContent.match(notificationInitRegex)?.[0] || ''; + + // Находим метод handleNotificationSettings + const notificationSettingsMethodRegex = /async\s+handleNotificationSettings[\s\S]*?}\s*}/; + const notificationSettingsMethod = currentContent.match(notificationSettingsMethodRegex)?.[0] || ''; + + // Находим обработку callback для notifications в handleCallback + const notificationCallbackRegex = /\/\/\s*Настройки уведомлений[\s\S]*?else\s+if\s*\(data\s*===\s*['"]notifications['"][\s\S]*?}\s*}/; + const notificationCallback = currentContent.match(notificationCallbackRegex)?.[0] || ''; + + // Получаем часть обработки коллбэков для уведомлений + const notificationToggleRegex = /\/\/\s*Обработка переключения настроек уведомлений[\s\S]*?else\s+if[\s\S]*?notif_[\s\S]*?}\s*}/; + const notificationToggle = currentContent.match(notificationToggleRegex)?.[0] || ''; + + console.log(`✅ Извлечены блоки кода для уведомлений`); + + // Интегрируем код уведомлений в оригинальную версию + console.log('Интегрируем код уведомлений в оригинальную версию...'); + + // 1. Добавляем импорт + let newContent = fullImplementationContent; + if (notificationImport) { + newContent = newContent.replace(/import\s*{[^}]*}\s*from\s*['"]\.\/messageHandlers['"]\s*;/, + match => match + '\n' + notificationImport); + } + + // 2. Добавляем объявление поля + if (notificationField) { + newContent = newContent.replace(/private\s+translationController\s*:\s*TranslationController\s*;/, + match => match + '\n ' + notificationField); + } + + // 3. Добавляем инициализацию в конструкторе + if (notificationInit) { + newContent = newContent.replace(/this\.translationController\s*=\s*new\s*TranslationController\(\);/, + match => match + '\n ' + notificationInit); + } + + // 4. Добавляем обработку коллбэков для уведомлений + if (notificationCallback) { + newContent = newContent.replace(/else\s+{\s*await\s+this\.bot\.answerCallbackQuery\(query\.id[\s\S]*?return;/, + match => notificationCallback + '\n ' + match); + } + + // 5. Добавляем обработку переключения настроек уведомлений + if (notificationToggle) { + newContent = newContent.replace(/else\s+{\s*await\s+this\.bot\.answerCallbackQuery\(query\.id[\s\S]*?return;/, + match => notificationToggle + '\n ' + match); + } + + // 6. Добавляем метод handleNotificationSettings в конец класса + if (notificationSettingsMethod) { + newContent = newContent.replace(/}(\s*)$/, notificationSettingsMethod + '\n}$1'); + } + + // Сохраняем обновленный файл + const outputPath = currentFilePath + '.fixed'; + fs.writeFileSync(outputPath, newContent); + console.log(`✅ Создана исправленная версия файла: ${path.basename(outputPath)}`); + + console.log('\nИнструкция по восстановлению:'); + console.log(`1. Проверьте файл ${path.basename(outputPath)}`); + console.log('2. Если все выглядит правильно, выполните команду:'); + console.log(` Move-Item -Force "${path.basename(outputPath)}" "${path.basename(currentFilePath)}"`); + console.log('3. Перезапустите бота'); + +} catch (error) { + console.error('Произошла ошибка:', error); +} diff --git a/scripts/fixDatabaseStructure.js b/scripts/fixDatabaseStructure.js new file mode 100644 index 0000000..9326b08 --- /dev/null +++ b/scripts/fixDatabaseStructure.js @@ -0,0 +1,170 @@ +// Скрипт для исправления проблемы с ботом +require('dotenv').config(); +const { Pool } = require('pg'); + +// Получаем данные подключения из .env +console.log('Параметры подключения к БД:'); +console.log('DB_USERNAME:', process.env.DB_USERNAME); +console.log('DB_HOST:', process.env.DB_HOST); +console.log('DB_NAME:', process.env.DB_NAME); +console.log('DB_PASSWORD:', process.env.DB_PASSWORD ? '[указан]' : '[не указан]'); +console.log('DB_PORT:', process.env.DB_PORT); + +// Создаем пул соединений +const pool = new Pool({ + user: process.env.DB_USERNAME, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: parseInt(process.env.DB_PORT || '5432') +}); + +async function fixDatabase() { + try { + console.log('Начинаем исправление базы данных...'); + + // Проверяем существование таблицы users + const tableResult = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'users' + ); + `); + + if (!tableResult.rows[0].exists) { + console.error('Таблица users не найдена!'); + return; + } + + console.log('✅ Таблица users существует'); + + // Проверяем и добавляем столбцы state и state_data, если они отсутствуют + console.log('Проверяем наличие столбцов state и state_data...'); + + const stateColumnResult = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'state' + ); + `); + + const stateDataColumnResult = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'state_data' + ); + `); + + if (!stateColumnResult.rows[0].exists) { + console.log('🔄 Добавляем столбец state...'); + await pool.query(`ALTER TABLE users ADD COLUMN state VARCHAR(255) NULL;`); + console.log('✅ Столбец state успешно добавлен'); + } else { + console.log('✅ Столбец state уже существует'); + } + + if (!stateDataColumnResult.rows[0].exists) { + console.log('🔄 Добавляем столбец state_data...'); + await pool.query(`ALTER TABLE users ADD COLUMN state_data JSONB DEFAULT '{}'::jsonb;`); + console.log('✅ Столбец state_data успешно добавлен'); + } else { + console.log('✅ Столбец state_data уже существует'); + } + + // Проверка наличия таблиц для уведомлений + console.log('Проверяем наличие таблиц для уведомлений...'); + + const tablesCheck = await Promise.all([ + checkTableExists('notifications'), + checkTableExists('notification_settings'), + checkTableExists('scheduled_notifications') + ]); + + // Создаем отсутствующие таблицы + if (!tablesCheck[0]) { + console.log('🔄 Создаем таблицу notifications...'); + await pool.query(` + CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + content JSONB NOT NULL DEFAULT '{}', + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + CREATE INDEX idx_notifications_user_id ON notifications(user_id); + CREATE INDEX idx_notifications_type ON notifications(type); + CREATE INDEX idx_notifications_created_at ON notifications(created_at); + `); + console.log('✅ Таблица notifications успешно создана'); + } + + if (!tablesCheck[1]) { + console.log('🔄 Создаем таблицу notification_settings...'); + await pool.query(` + CREATE TABLE notification_settings ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + new_matches BOOLEAN DEFAULT true, + new_messages BOOLEAN DEFAULT true, + new_likes BOOLEAN DEFAULT true, + reminders BOOLEAN DEFAULT true, + daily_summary BOOLEAN DEFAULT false, + time_preference VARCHAR(20) DEFAULT 'evening', + do_not_disturb BOOLEAN DEFAULT false, + do_not_disturb_start TIME, + do_not_disturb_end TIME, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + `); + console.log('✅ Таблица notification_settings успешно создана'); + } + + if (!tablesCheck[2]) { + console.log('🔄 Создаем таблицу scheduled_notifications...'); + await pool.query(` + CREATE TABLE scheduled_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + content JSONB NOT NULL DEFAULT '{}', + scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL, + processed BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ); + CREATE INDEX idx_scheduled_notifications_user_id ON scheduled_notifications(user_id); + CREATE INDEX idx_scheduled_notifications_scheduled_at ON scheduled_notifications(scheduled_at); + CREATE INDEX idx_scheduled_notifications_processed ON scheduled_notifications(processed); + `); + console.log('✅ Таблица scheduled_notifications успешно создана'); + } + + console.log('✅ Исправление базы данных завершено успешно'); + + } catch (error) { + console.error('Ошибка при исправлении базы данных:', error); + } finally { + await pool.end(); + } +} + +async function checkTableExists(tableName) { + const result = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, [tableName]); + + const exists = result.rows[0].exists; + console.log(`${exists ? '✅' : '❌'} Таблица ${tableName} ${exists ? 'существует' : 'отсутствует'}`); + + return exists; +} + +fixDatabase(); diff --git a/scripts/fix_all_notifications.js b/scripts/fix_all_notifications.js new file mode 100644 index 0000000..57d1a2e --- /dev/null +++ b/scripts/fix_all_notifications.js @@ -0,0 +1,48 @@ +/** + * Комплексный скрипт для исправления всех проблем с уведомлениями + * Запускает последовательно оба скрипта исправления + */ + +const { exec } = require('child_process'); +const path = require('path'); + +console.log('🔧 Запуск комплексного исправления проблем с уведомлениями...'); + +// Путь к скриптам +const fixNotificationCallbacksScript = path.join(__dirname, 'fix_notification_callbacks.js'); +const updateBotWithNotificationsScript = path.join(__dirname, 'update_bot_with_notifications.js'); + +// Запуск первого скрипта для исправления таблиц и колонок +console.log('\n📊 Шаг 1/2: Проверка и исправление таблиц базы данных...'); +exec(`node ${fixNotificationCallbacksScript}`, (error, stdout, stderr) => { + if (error) { + console.error(`❌ Ошибка при запуске скрипта исправления таблиц: ${error}`); + return; + } + + console.log(stdout); + + if (stderr) { + console.error(`❌ Ошибки при выполнении скрипта: ${stderr}`); + } + + // Запуск второго скрипта для обновления bot.ts + console.log('\n📝 Шаг 2/2: Обновление файла bot.ts для регистрации обработчиков уведомлений...'); + exec(`node ${updateBotWithNotificationsScript}`, (error2, stdout2, stderr2) => { + if (error2) { + console.error(`❌ Ошибка при запуске скрипта обновления bot.ts: ${error2}`); + return; + } + + console.log(stdout2); + + if (stderr2) { + console.error(`❌ Ошибки при выполнении скрипта: ${stderr2}`); + } + + console.log('\n✅ Все исправления успешно выполнены!'); + console.log('🔄 Пожалуйста, перезапустите бота для применения изменений:'); + console.log(' npm run start'); + console.log('\n💡 Уведомления должны теперь работать корректно!'); + }); +}); diff --git a/scripts/fix_notification_callbacks.js b/scripts/fix_notification_callbacks.js new file mode 100644 index 0000000..a501a58 --- /dev/null +++ b/scripts/fix_notification_callbacks.js @@ -0,0 +1,332 @@ +/** + * Скрипт для проверки и исправления проблем с обработчиками уведомлений в боте + */ + +const { Client } = require('pg'); +const fs = require('fs'); + +// Конфигурация базы данных +const dbConfig = { + host: 'localhost', + port: 5432, + database: 'telegram_tinder', + user: 'postgres', + password: 'postgres' +}; + +// Подключение к базе данных +const client = new Client(dbConfig); + +async function main() { + try { + console.log('Подключение к базе данных...'); + await client.connect(); + console.log('Успешно подключено к базе данных'); + + // Шаг 1: Проверка существования необходимых таблиц для уведомлений + console.log('\n=== Проверка таблиц для уведомлений ==='); + + // Проверяем таблицу notifications + let notificationsTableExists = await checkTableExists('notifications'); + if (!notificationsTableExists) { + console.log('Таблица notifications не найдена. Создаем...'); + await createNotificationsTable(); + console.log('Таблица notifications успешно создана'); + } else { + console.log('Таблица notifications уже существует'); + } + + // Проверяем таблицу scheduled_notifications + let scheduledNotificationsTableExists = await checkTableExists('scheduled_notifications'); + if (!scheduledNotificationsTableExists) { + console.log('Таблица scheduled_notifications не найдена. Создаем...'); + await createScheduledNotificationsTable(); + console.log('Таблица scheduled_notifications успешно создана'); + } else { + console.log('Таблица scheduled_notifications уже существует'); + } + + // Проверяем таблицу notification_templates + let notificationTemplatesTableExists = await checkTableExists('notification_templates'); + if (!notificationTemplatesTableExists) { + console.log('Таблица notification_templates не найдена. Создаем...'); + await createNotificationTemplatesTable(); + console.log('Таблица notification_templates успешно создана'); + console.log('Заполняем таблицу базовыми шаблонами...'); + await populateDefaultTemplates(); + console.log('Шаблоны успешно добавлены'); + } else { + console.log('Таблица notification_templates уже существует'); + } + + // Шаг 2: Проверка существования столбца notification_settings в таблице users + console.log('\n=== Проверка столбца notification_settings в таблице users ==='); + + const notificationSettingsColumnExists = await checkColumnExists('users', 'notification_settings'); + if (!notificationSettingsColumnExists) { + console.log('Столбец notification_settings не найден. Добавляем...'); + await addNotificationSettingsColumn(); + console.log('Столбец notification_settings успешно добавлен'); + } else { + console.log('Столбец notification_settings уже существует'); + } + + // Шаг 3: Проверка существования столбцов state и state_data в таблице users + console.log('\n=== Проверка столбцов state и state_data в таблице users ==='); + + const stateColumnExists = await checkColumnExists('users', 'state'); + if (!stateColumnExists) { + console.log('Столбец state не найден. Добавляем...'); + await addStateColumn(); + console.log('Столбец state успешно добавлен'); + } else { + console.log('Столбец state уже существует'); + } + + const stateDataColumnExists = await checkColumnExists('users', 'state_data'); + if (!stateDataColumnExists) { + console.log('Столбец state_data не найден. Добавляем...'); + await addStateDataColumn(); + console.log('Столбец state_data успешно добавлен'); + } else { + console.log('Столбец state_data уже существует'); + } + + console.log('\nВсе таблицы и столбцы успешно проверены и созданы при необходимости.'); + console.log('Механизм уведомлений должен работать корректно.'); + + console.log('\n=== Проверка регистрации обработчиков уведомлений ==='); + console.log('Подсказка: убедитесь, что в файле bot.ts создается экземпляр NotificationHandlers и регистрируются его обработчики:'); + console.log(` + // Настройка обработчиков уведомлений + const notificationHandlers = new NotificationHandlers(bot); + notificationHandlers.register(); + + // Запуск обработчика запланированных уведомлений + setInterval(() => { + const notificationService = new NotificationService(bot); + notificationService.processScheduledNotifications(); + }, 60000); // Проверяем каждую минуту + `); + + } catch (error) { + console.error('Ошибка выполнения скрипта:', error); + } finally { + await client.end(); + console.log('\nСоединение с базой данных закрыто.'); + } +} + +async function checkTableExists(tableName) { + const query = ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = $1 + ) as exists + `; + + const result = await client.query(query, [tableName]); + return result.rows[0].exists; +} + +async function checkColumnExists(tableName, columnName) { + const query = ` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2 + ) as exists + `; + + const result = await client.query(query, [tableName, columnName]); + return result.rows[0].exists; +} + +async function createNotificationsTable() { + await client.query(` + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + + CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); +} + +async function createScheduledNotificationsTable() { + await client.query(` + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + + CREATE TABLE scheduled_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + scheduled_at TIMESTAMP NOT NULL, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); +} + +async function createNotificationTemplatesTable() { + await client.query(` + CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + + CREATE TABLE notification_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(50) NOT NULL UNIQUE, + title TEXT NOT NULL, + message_template TEXT NOT NULL, + button_template JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); +} + +async function addNotificationSettingsColumn() { + await client.query(` + ALTER TABLE users + ADD COLUMN notification_settings JSONB DEFAULT '{ + "newMatches": true, + "newMessages": true, + "newLikes": true, + "reminders": true, + "dailySummary": true, + "timePreference": "evening", + "doNotDisturb": false + }'::jsonb + `); +} + +async function addStateColumn() { + await client.query(` + ALTER TABLE users ADD COLUMN state VARCHAR(255) NULL + `); +} + +async function addStateDataColumn() { + await client.query(` + ALTER TABLE users ADD COLUMN state_data JSONB DEFAULT '{}'::jsonb + `); +} + +async function populateDefaultTemplates() { + const templates = [ + { + type: 'new_like', + title: 'Новый лайк!', + message_template: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'super_like', + title: 'Супер-лайк!', + message_template: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'new_match', + title: 'Новый матч!', + message_template: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'new_message', + title: 'Новое сообщение!', + message_template: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + button_template: { + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'match_reminder', + title: 'Напоминание о матче', + message_template: '💕 У вас есть матч с *{{name}}*, но вы еще не начали общение!\n\nНе упустите шанс познакомиться поближе!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }] + ] + } + }, + { + type: 'inactive_matches', + title: 'Неактивные матчи', + message_template: '⏰ У вас {{count}} неактивных матчей!\n\nПродолжите общение, чтобы не упустить интересные знакомства!', + button_template: { + inline_keyboard: [ + [{ text: '📋 Открыть матчи', callback_data: 'view_matches' }], + [{ text: '💕 Смотреть новые анкеты', callback_data: 'start_browsing' }] + ] + } + }, + { + type: 'like_summary', + title: 'Сводка лайков', + message_template: '💖 У вас {{count}} новых лайков!\n\nПосмотрите, кто проявил к вам интерес сегодня!', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть лайки', callback_data: 'view_likes' }], + [{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }] + ] + } + } + ]; + + for (const template of templates) { + await client.query(` + INSERT INTO notification_templates (type, title, message_template, button_template) + VALUES ($1, $2, $3, $4) + ON CONFLICT (type) DO UPDATE + SET title = EXCLUDED.title, + message_template = EXCLUDED.message_template, + button_template = EXCLUDED.button_template, + updated_at = NOW() + `, [ + template.type, + template.title, + template.message_template, + JSON.stringify(template.button_template) + ]); + } +} + +// Запуск скрипта +main(); diff --git a/scripts/setPremiumStatus.js b/scripts/setPremiumStatus.js new file mode 100644 index 0000000..420ef4e --- /dev/null +++ b/scripts/setPremiumStatus.js @@ -0,0 +1,73 @@ +// Скрипт для установки премиум-статуса всем пользователям +require('dotenv').config(); +const { Pool } = require('pg'); + +// Проверяем и выводим параметры подключения +console.log('Параметры подключения к БД:'); +console.log('DB_USERNAME:', process.env.DB_USERNAME); +console.log('DB_HOST:', process.env.DB_HOST); +console.log('DB_NAME:', process.env.DB_NAME); +console.log('DB_PASSWORD:', process.env.DB_PASSWORD ? '[указан]' : '[не указан]'); +console.log('DB_PORT:', process.env.DB_PORT); + +// Создаем пул соединений +const pool = new Pool({ + user: process.env.DB_USERNAME, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: parseInt(process.env.DB_PORT || '5432') +}); + +async function setAllUsersToPremium() { + try { + console.log('Устанавливаем премиум-статус для всех пользователей...'); + + // Проверка соединения с БД + console.log('Проверка соединения с БД...'); + const testResult = await pool.query('SELECT NOW()'); + console.log('✅ Соединение успешно:', testResult.rows[0].now); + + // Проверка наличия столбца premium + console.log('Проверяем наличие столбца premium в таблице users...'); + + const checkResult = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'premium' + ); + `); + + if (!checkResult.rows[0].exists) { + console.log('🔄 Добавляем столбец premium...'); + await pool.query(`ALTER TABLE users ADD COLUMN premium BOOLEAN DEFAULT false;`); + console.log('✅ Столбец premium успешно добавлен'); + } else { + console.log('✅ Столбец premium уже существует'); + } + + // Устанавливаем premium=true для всех пользователей + const updateResult = await pool.query(` + UPDATE users + SET premium = true + WHERE true + RETURNING id, telegram_id, premium + `); + + console.log(`✅ Успешно установлен премиум-статус для ${updateResult.rows.length} пользователей:`); + updateResult.rows.forEach(row => { + console.log(`ID: ${row.id.substr(0, 8)}... | Telegram ID: ${row.telegram_id} | Premium: ${row.premium}`); + }); + + console.log('🎉 Все пользователи теперь имеют премиум-статус!'); + } catch (error) { + console.error('❌ Ошибка при установке премиум-статуса:', error); + } finally { + await pool.end(); + console.log('Соединение с базой данных закрыто'); + } +} + +setAllUsersToPremium(); diff --git a/scripts/testCallbacks.js b/scripts/testCallbacks.js new file mode 100644 index 0000000..dde2ed6 --- /dev/null +++ b/scripts/testCallbacks.js @@ -0,0 +1,85 @@ +// Скрипт для проверки работы callback-хэндлеров и уведомлений +require('dotenv').config(); +const TelegramBot = require('node-telegram-bot-api'); +const { Pool } = require('pg'); + +// Создаем пул соединений +const pool = new Pool({ + user: process.env.DB_USERNAME, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: parseInt(process.env.DB_PORT || '5432') +}); + +// Функция для имитации callback-запроса к боту +async function testCallback() { + try { + console.log('Начинаем тестирование callback-хэндлеров и уведомлений...'); + + // Используем последнего пользователя из базы данных + const userResult = await pool.query(` + SELECT * FROM users ORDER BY last_active_at DESC NULLS LAST LIMIT 1 + `); + + if (userResult.rows.length === 0) { + console.error('❌ Пользователи не найдены в базе данных'); + return; + } + + const user = userResult.rows[0]; + console.log(`Выбран тестовый пользователь: ${user.first_name || 'Без имени'} (ID: ${user.telegram_id})`); + + // Получаем токен бота из переменных окружения + const token = process.env.TELEGRAM_BOT_TOKEN; + if (!token) { + console.error('❌ Токен бота не найден в переменных окружения'); + return; + } + + // Создаем экземпляр бота + const bot = new TelegramBot(token); + + // Отправляем тестовое уведомление пользователю + console.log(`Отправляем тестовое уведомление пользователю ID: ${user.telegram_id}...`); + + try { + const result = await bot.sendMessage( + user.telegram_id, + `🔔 *Тестовое уведомление*\n\nЭто проверка работы уведомлений и callback-хэндлеров.\n\nВаш премиум-статус: ${user.premium ? '✅ Активен' : '❌ Не активен'}`, + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [ + { text: '🔔 Уведомления', callback_data: 'notification_settings' }, + { text: '❤️ Профиль', callback_data: 'view_profile' } + ], + [ + { text: '⚙️ Настройки', callback_data: 'settings' } + ] + ] + } + } + ); + + console.log('✅ Тестовое сообщение успешно отправлено!'); + console.log('Информация о сообщении:', JSON.stringify(result, null, 2)); + } catch (error) { + console.error('❌ Ошибка при отправке тестового сообщения:', error.message); + if (error.response && error.response.body) { + console.error('Детали ошибки:', JSON.stringify(error.response.body, null, 2)); + } + } + + } catch (error) { + console.error('❌ Ошибка при тестировании:', error); + } finally { + await pool.end(); + console.log('Соединение с базой данных закрыто'); + console.log('Тестирование завершено!'); + } +} + +// Запускаем тестирование +testCallback(); diff --git a/scripts/testVipMethod.js b/scripts/testVipMethod.js new file mode 100644 index 0000000..565288a --- /dev/null +++ b/scripts/testVipMethod.js @@ -0,0 +1,81 @@ +// Скрипт для тестирования метода checkPremiumStatus +require('dotenv').config(); +const { Pool } = require('pg'); + +// Создаем пул соединений +const pool = new Pool({ + user: process.env.DB_USERNAME, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: parseInt(process.env.DB_PORT || '5432') +}); + +async function testCheckPremiumMethod() { + try { + console.log('Тестирование метода checkPremiumStatus...'); + + // Получаем пользователя для тестирования + const userResult = await pool.query(` + SELECT id, telegram_id, first_name, username, premium + FROM users + ORDER BY last_active_at DESC NULLS LAST + LIMIT 1 + `); + + if (userResult.rows.length === 0) { + console.error('❌ Пользователи не найдены в базе данных'); + return; + } + + const user = userResult.rows[0]; + console.log(`Выбран тестовый пользователь: ${user.first_name || user.username || 'Без имени'} (Telegram ID: ${user.telegram_id})`); + console.log(`Текущий премиум-статус: ${user.premium ? '✅ Активен' : '❌ Не активен'}`); + + // Проверка работы метода checkPremiumStatus + console.log('\nЭмулируем вызов метода checkPremiumStatus из vipService:'); + const result = await pool.query(` + SELECT id, premium + FROM users + WHERE telegram_id = $1 + `, [user.telegram_id]); + + if (result.rows.length === 0) { + console.log('❌ Пользователь не найден'); + } else { + const isPremium = result.rows[0].premium || false; + console.log(`Результат метода: isPremium = ${isPremium ? '✅ true' : '❌ false'}`); + + if (!isPremium) { + console.log('\nПремиум-статус отсутствует. Устанавливаем премиум...'); + await pool.query(` + UPDATE users + SET premium = true + WHERE telegram_id = $1 + `, [user.telegram_id]); + + // Проверяем обновление + const updatedResult = await pool.query(` + SELECT premium + FROM users + WHERE telegram_id = $1 + `, [user.telegram_id]); + + const updatedPremium = updatedResult.rows[0].premium; + console.log(`Обновленный статус: isPremium = ${updatedPremium ? '✅ true' : '❌ false'}`); + } + } + + console.log('\n✅ Тестирование завершено'); + console.log('🔧 Теперь проверьте функциональность VIP поиска в боте'); + + } catch (error) { + console.error('❌ Ошибка при тестировании:', error); + } finally { + await pool.end(); + console.log('Соединение с базой данных закрыто'); + } +} + +// Запускаем тест +testCheckPremiumMethod(); diff --git a/scripts/testVipStatus.js b/scripts/testVipStatus.js new file mode 100644 index 0000000..16a7839 --- /dev/null +++ b/scripts/testVipStatus.js @@ -0,0 +1,75 @@ +// Скрипт для тестирования VIP функционала +require('dotenv').config(); +const { Pool } = require('pg'); + +// Создаем пул соединений +const pool = new Pool({ + user: process.env.DB_USERNAME, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: parseInt(process.env.DB_PORT || '5432') +}); + +async function testVipStatus() { + try { + console.log('Тестирование функционала VIP статуса...'); + + // Получаем список пользователей с информацией о premium статусе + const users = await pool.query(` + SELECT id, telegram_id, username, first_name, premium + FROM users + ORDER BY last_active_at DESC + LIMIT 5 + `); + + console.log('Список пользователей и их премиум статус:'); + users.rows.forEach(user => { + console.log(`ID: ${user.id.substr(0, 8)}... | Telegram ID: ${user.telegram_id} | Имя: ${user.first_name || user.username || 'Не указано'} | Premium: ${user.premium ? '✅' : '❌'}`); + }); + + // Если premium у всех false, устанавливаем premium = true + const nonPremiumUsers = users.rows.filter(user => !user.premium); + if (nonPremiumUsers.length > 0) { + console.log('\nОбнаружены пользователи без премиум статуса. Устанавливаем премиум...'); + + for (const user of nonPremiumUsers) { + await pool.query(` + UPDATE users + SET premium = true + WHERE id = $1 + RETURNING id, telegram_id, premium + `, [user.id]); + + console.log(`✅ Установлен премиум для пользователя ${user.first_name || user.username || user.telegram_id}`); + } + } else { + console.log('\nВсе пользователи уже имеют премиум-статус!'); + } + + // Проверяем результат + const updatedUsers = await pool.query(` + SELECT id, telegram_id, username, first_name, premium + FROM users + ORDER BY last_active_at DESC + LIMIT 5 + `); + + console.log('\nОбновленный список пользователей и их премиум статус:'); + updatedUsers.rows.forEach(user => { + console.log(`ID: ${user.id.substr(0, 8)}... | Telegram ID: ${user.telegram_id} | Имя: ${user.first_name || user.username || 'Не указано'} | Premium: ${user.premium ? '✅' : '❌'}`); + }); + + console.log('\n✅ Тестирование VIP функционала завершено'); + console.log('🔧 Проверьте доступность VIP поиска в боте через меню или команды'); + + } catch (error) { + console.error('❌ Ошибка при тестировании VIP статуса:', error); + } finally { + await pool.end(); + console.log('Соединение с базой данных закрыто'); + } +} + +// Запускаем тест +testVipStatus(); diff --git a/scripts/update_bot_with_notifications.js b/scripts/update_bot_with_notifications.js new file mode 100644 index 0000000..a4084de --- /dev/null +++ b/scripts/update_bot_with_notifications.js @@ -0,0 +1,104 @@ +/** + * Скрипт для проверки и исправления регистрации NotificationHandlers в bot.ts + */ + +const fs = require('fs'); +const path = require('path'); + +const botFilePath = path.join(__dirname, '../src/bot.ts'); + +// Проверка существования файла bot.ts +if (!fs.existsSync(botFilePath)) { + console.error(`❌ Файл ${botFilePath} не найден`); + process.exit(1); +} + +// Чтение содержимого файла bot.ts +let botContent = fs.readFileSync(botFilePath, 'utf8'); + +// Проверка импорта NotificationHandlers +if (!botContent.includes('import { NotificationHandlers }')) { + console.log('Добавляем импорт NotificationHandlers в bot.ts...'); + + // Находим последний импорт + const importRegex = /^import.*?;/gms; + const matches = [...botContent.matchAll(importRegex)]; + + if (matches.length > 0) { + const lastImport = matches[matches.length - 1][0]; + const lastImportIndex = botContent.lastIndexOf(lastImport) + lastImport.length; + + // Добавляем импорт NotificationHandlers + botContent = + botContent.slice(0, lastImportIndex) + + '\nimport { NotificationHandlers } from \'./handlers/notificationHandlers\';\n' + + botContent.slice(lastImportIndex); + + console.log('✅ Импорт NotificationHandlers добавлен'); + } else { + console.error('❌ Не удалось найти место для добавления импорта'); + } +} + +// Проверка объявления NotificationHandlers в классе +if (!botContent.includes('private notificationHandlers')) { + console.log('Добавляем объявление notificationHandlers в класс...'); + + const classPropertiesRegex = /class TelegramTinderBot {([^}]+?)constructor/s; + const classPropertiesMatch = botContent.match(classPropertiesRegex); + + if (classPropertiesMatch) { + const classProperties = classPropertiesMatch[1]; + const updatedProperties = classProperties + ' private notificationHandlers: NotificationHandlers;\n '; + + botContent = botContent.replace(classPropertiesRegex, `class TelegramTinderBot {${updatedProperties}constructor`); + + console.log('✅ Объявление notificationHandlers добавлено'); + } else { + console.error('❌ Не удалось найти место для добавления объявления notificationHandlers'); + } +} + +// Проверка создания экземпляра NotificationHandlers в конструкторе +if (!botContent.includes('this.notificationHandlers = new NotificationHandlers')) { + console.log('Добавляем инициализацию notificationHandlers в конструктор...'); + + const initializationRegex = /(this\.callbackHandlers = new CallbackHandlers[^;]+;)/; + const initializationMatch = botContent.match(initializationRegex); + + if (initializationMatch) { + const callbackHandlersInit = initializationMatch[1]; + const updatedInit = callbackHandlersInit + '\n this.notificationHandlers = new NotificationHandlers(this.bot);'; + + botContent = botContent.replace(initializationRegex, updatedInit); + + console.log('✅ Инициализация notificationHandlers добавлена'); + } else { + console.error('❌ Не удалось найти место для добавления инициализации notificationHandlers'); + } +} + +// Проверка регистрации notificationHandlers в методе registerHandlers +if (!botContent.includes('this.notificationHandlers.register()')) { + console.log('Добавляем регистрацию notificationHandlers...'); + + const registerHandlersRegex = /(private registerHandlers\(\): void {[^}]+?)}/s; + const registerHandlersMatch = botContent.match(registerHandlersRegex); + + if (registerHandlersMatch) { + const registerHandlersBody = registerHandlersMatch[1]; + const updatedBody = registerHandlersBody + '\n // Обработчики уведомлений\n this.notificationHandlers.register();\n }'; + + botContent = botContent.replace(registerHandlersRegex, updatedBody); + + console.log('✅ Регистрация notificationHandlers добавлена'); + } else { + console.error('❌ Не удалось найти место для добавления регистрации notificationHandlers'); + } +} + +// Запись обновленного содержимого в файл +fs.writeFileSync(botFilePath, botContent, 'utf8'); + +console.log('✅ Файл bot.ts успешно обновлен'); +console.log('🔔 Перезапустите бота для применения изменений'); diff --git a/src/bot.ts b/src/bot.ts index 68711be..efea339 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -8,6 +8,8 @@ import LocalizationService from './services/localizationService'; import { CommandHandlers } from './handlers/commandHandlers'; import { CallbackHandlers } from './handlers/callbackHandlers'; import { MessageHandlers } from './handlers/messageHandlers'; +import { NotificationHandlers } from './handlers/notificationHandlers'; + class TelegramTinderBot { private bot: TelegramBot; @@ -18,7 +20,7 @@ class TelegramTinderBot { private commandHandlers: CommandHandlers; private callbackHandlers: CallbackHandlers; private messageHandlers: MessageHandlers; - + private notificationHandlers: NotificationHandlers; constructor() { const token = process.env.TELEGRAM_BOT_TOKEN; if (!token) { @@ -34,6 +36,7 @@ class TelegramTinderBot { this.commandHandlers = new CommandHandlers(this.bot); this.messageHandlers = new MessageHandlers(this.bot); this.callbackHandlers = new CallbackHandlers(this.bot, this.messageHandlers); + this.notificationHandlers = new NotificationHandlers(this.bot); this.setupErrorHandling(); this.setupPeriodicTasks(); @@ -78,6 +81,7 @@ class TelegramTinderBot { { command: 'browse', description: '💕 Смотреть анкеты' }, { command: 'matches', description: '💖 Мои матчи' }, { command: 'settings', description: '⚙️ Настройки' }, + { command: 'notifications', description: '🔔 Настройки уведомлений' }, { command: 'help', description: '❓ Помощь' } ]; @@ -94,6 +98,9 @@ class TelegramTinderBot { // Сообщения this.messageHandlers.register(); + + // Обработчики уведомлений + this.notificationHandlers.register(); } // Обработка ошибок @@ -137,14 +144,31 @@ class TelegramTinderBot { } }, 5 * 60 * 1000); - // Очистка старых данных каждый день + // Планирование периодических уведомлений раз в день в 00:05 setInterval(async () => { try { - await this.cleanupOldData(); + const now = new Date(); + if (now.getHours() === 0 && now.getMinutes() >= 5 && now.getMinutes() < 10) { + console.log('🔔 Scheduling periodic notifications...'); + await this.notificationService.schedulePeriodicNotifications(); + } + } catch (error) { + console.error('Error scheduling periodic notifications:', error); + } + }, 5 * 60 * 1000); + + // Очистка старых данных каждый день в 03:00 + setInterval(async () => { + try { + const now = new Date(); + if (now.getHours() === 3 && now.getMinutes() < 5) { + console.log('🧹 Running scheduled cleanup...'); + await this.cleanupOldData(); + } } catch (error) { console.error('Error cleaning up old data:', error); } - }, 24 * 60 * 60 * 1000); + }, 5 * 60 * 1000); } // Очистка старых данных @@ -152,11 +176,18 @@ class TelegramTinderBot { console.log('🧹 Running cleanup tasks...'); try { - // Очистка старых уведомлений (старше 30 дней) - const notificationsResult = await query(` + // Очистка старых запланированных уведомлений (старше 30 дней или обработанных) + const scheduledNotificationsResult = await query(` DELETE FROM scheduled_notifications - WHERE processed = true - AND created_at < CURRENT_TIMESTAMP - INTERVAL '30 days' + WHERE (processed = true AND created_at < CURRENT_TIMESTAMP - INTERVAL '30 days') + OR (scheduled_at < CURRENT_TIMESTAMP - INTERVAL '7 days') + `); + console.log(`🗑️ Cleaned up ${scheduledNotificationsResult.rowCount} old scheduled notifications`); + + // Очистка старых уведомлений (старше 90 дней) + const notificationsResult = await query(` + DELETE FROM notifications + WHERE created_at < CURRENT_TIMESTAMP - INTERVAL '90 days' `); console.log(`🗑️ Cleaned up ${notificationsResult.rowCount} old notifications`); @@ -186,7 +217,7 @@ class TelegramTinderBot { console.log(`💬 Cleaned up ${messagesResult.rowCount} old messages`); // Обновление статистики таблиц после очистки - await query('VACUUM ANALYZE scheduled_notifications, profile_views, swipes, messages'); + await query('VACUUM ANALYZE notifications, scheduled_notifications, profile_views, swipes, messages'); console.log('✅ Cleanup completed successfully'); } catch (error) { @@ -229,4 +260,4 @@ if (require.main === module) { }); } -export { TelegramTinderBot }; \ No newline at end of file +export { TelegramTinderBot }; diff --git a/src/handlers/callbackHandlers.ts b/src/handlers/callbackHandlers.ts index 0df3dd6..09c37b8 100644 --- a/src/handlers/callbackHandlers.ts +++ b/src/handlers/callbackHandlers.ts @@ -4,6 +4,7 @@ import { MatchingService } from '../services/matchingService'; import { ChatService } from '../services/chatService'; import { Profile } from '../models/Profile'; import { MessageHandlers } from './messageHandlers'; +import { NotificationHandlers } from './notificationHandlers'; import { ProfileEditController } from '../controllers/profileEditController'; import { EnhancedChatHandlers } from './enhancedChatHandlers'; import { VipController } from '../controllers/vipController'; @@ -23,6 +24,7 @@ export class CallbackHandlers { private vipController: VipController; private vipService: VipService; private translationController: TranslationController; + private notificationHandlers?: NotificationHandlers; private likeBackHandler: LikeBackHandler; constructor(bot: TelegramBot, messageHandlers: MessageHandlers) { @@ -36,6 +38,12 @@ export class CallbackHandlers { this.vipController = new VipController(bot); this.vipService = new VipService(); this.translationController = new TranslationController(); + // Создаем экземпляр NotificationHandlers + try { + this.notificationHandlers = new NotificationHandlers(bot); + } catch (error) { + console.error('Failed to initialize NotificationHandlers:', error); + } this.likeBackHandler = new LikeBackHandler(bot); } @@ -272,6 +280,41 @@ export class CallbackHandlers { await this.handleSettings(chatId, telegramId); } + // Настройки уведомлений + else if (data === 'notifications') { + if (this.notificationHandlers) { + const userId = await this.profileService.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(query.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + const settings = await this.notificationHandlers.getNotificationService().getNotificationSettings(userId); + await this.notificationHandlers.sendNotificationSettings(chatId, settings); + } else { + await this.handleNotificationSettings(chatId, telegramId); + } + } + // Обработка переключения настроек уведомлений + else if (data.startsWith('notif_toggle:') || + data === 'notif_time' || + data.startsWith('notif_time_set:') || + data === 'notif_dnd' || + data.startsWith('notif_dnd_set:') || + data === 'notif_dnd_time' || + data.startsWith('notif_dnd_time_set:') || + data === 'notif_dnd_time_custom') { + // Делегируем обработку в NotificationHandlers, если он доступен + if (this.notificationHandlers) { + // Эти коллбэки уже обрабатываются в NotificationHandlers, поэтому здесь ничего не делаем + // NotificationHandlers уже зарегистрировал свои обработчики в register() + } else { + await this.bot.answerCallbackQuery(query.id, { + text: 'Функция настройки уведомлений недоступна.', + show_alert: true + }); + } + } else { await this.bot.answerCallbackQuery(query.id, { text: 'Функция в разработке!', @@ -870,13 +913,7 @@ export class CallbackHandlers { ); } - // Настройки уведомлений - async handleNotificationSettings(chatId: number, telegramId: string): Promise { - await this.bot.sendMessage( - chatId, - '🔔 Настройки уведомлений будут доступны в следующем обновлении!' - ); - } + // Настройки уведомлений - реализация перенесена в расширенную версию // Как это работает async handleHowItWorks(chatId: number): Promise { @@ -1578,7 +1615,7 @@ export class CallbackHandlers { try { // Проверяем VIP статус пользователя const user = await this.profileService.getUserByTelegramId(telegramId); - if (!user || !user.isPremium) { + if (!user || !user.premium) { // Изменено с isPremium на premium, чтобы соответствовать названию колонки в базе данных const keyboard = { inline_keyboard: [ [ @@ -2240,4 +2277,27 @@ export class CallbackHandlers { await this.bot.sendMessage(chatId, t('translation.error')); } } + + async handleNotificationSettings(chatId: number, telegramId: string): Promise { + try { + if (this.notificationHandlers) { + const userId = await this.profileService.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.sendMessage(chatId, '❌ Вы не зарегистрированы. Используйте команду /start для регистрации.'); + return; + } + + // Вызываем метод из notificationHandlers для получения настроек и отображения меню + const settings = await this.notificationHandlers.getNotificationService().getNotificationSettings(userId); + await this.notificationHandlers.sendNotificationSettings(chatId, settings); + } else { + // Если NotificationHandlers недоступен, показываем сообщение об ошибке + await this.bot.sendMessage(chatId, '⚙️ Настройки уведомлений временно недоступны. Попробуйте позже.'); + await this.handleSettings(chatId, telegramId); + } + } catch (error) { + console.error('Notification settings error:', error); + await this.bot.sendMessage(chatId, '❌ Произошла ошибка при загрузке настроек уведомлений. Попробуйте позже.'); + } + } } diff --git a/src/handlers/callbackHandlers.ts.backup-1758166633763 b/src/handlers/callbackHandlers.ts.backup-1758166633763 new file mode 100644 index 0000000..4d537fa --- /dev/null +++ b/src/handlers/callbackHandlers.ts.backup-1758166633763 @@ -0,0 +1,606 @@ +import TelegramBot, { CallbackQuery, InlineKeyboardMarkup } from 'node-telegram-bot-api'; +import { ProfileService } from '../services/profileService'; +import { MatchingService } from '../services/matchingService'; +import { ChatService } from '../services/chatService'; +import { Profile } from '../models/Profile'; +import { MessageHandlers } from './messageHandlers'; +import { ProfileEditController } from '../controllers/profileEditController'; +import { EnhancedChatHandlers } from './enhancedChatHandlers'; +import { VipController } from '../controllers/vipController'; +import { VipService } from '../services/vipService'; +import { TranslationController } from '../controllers/translationController'; +import { t } from '../services/localizationService'; +import { LikeBackHandler } from './likeBackHandler'; +import { NotificationHandlers } from './notificationHandlers'; + +export class CallbackHandlers { + private bot: TelegramBot; + private profileService: ProfileService; + private matchingService: MatchingService; + private chatService: ChatService; + private messageHandlers: MessageHandlers; + private profileEditController: ProfileEditController; + private enhancedChatHandlers: EnhancedChatHandlers; + private vipController: VipController; + private vipService: VipService; + private translationController: TranslationController; + private likeBackHandler: LikeBackHandler; + private notificationHandlers?: NotificationHandlers; + + constructor(bot: TelegramBot, messageHandlers: MessageHandlers) { + this.bot = bot; + this.profileService = new ProfileService(); + this.matchingService = new MatchingService(); + this.chatService = new ChatService(); + this.messageHandlers = messageHandlers; + this.profileEditController = new ProfileEditController(this.profileService); + this.enhancedChatHandlers = new EnhancedChatHandlers(bot); + this.vipController = new VipController(bot); + this.vipService = new VipService(); + this.translationController = new TranslationController(); + this.likeBackHandler = new LikeBackHandler(bot); + + // Создаем экземпляр NotificationHandlers + try { + this.notificationHandlers = new NotificationHandlers(bot); + } catch (error) { + console.error('Failed to initialize NotificationHandlers:', error); + } + } + + register(): void { + this.bot.on('callback_query', (query) => this.handleCallback(query)); + } + + async handleCallback(query: CallbackQuery): Promise { + if (!query.data || !query.from || !query.message) return; + + const telegramId = query.from.id.toString(); + const chatId = query.message.chat.id; + const data = query.data; + + try { + // Основные действия профиля + if (data === 'create_profile') { + await this.handleCreateProfile(chatId, telegramId); + } else if (data.startsWith('gender_')) { + const gender = data.replace('gender_', ''); + await this.handleGenderSelection(chatId, telegramId, gender); + } else if (data === 'view_my_profile') { + await this.handleViewMyProfile(chatId, telegramId); + } else if (data === 'edit_profile') { + await this.handleEditProfile(chatId, telegramId); + } else if (data === 'manage_photos') { + await this.handleManagePhotos(chatId, telegramId); + } else if (data === 'preview_profile') { + await this.handlePreviewProfile(chatId, telegramId); + } + + // Редактирование полей профиля + else if (data === 'edit_name') { + await this.handleEditName(chatId, telegramId); + } else if (data === 'edit_age') { + await this.handleEditAge(chatId, telegramId); + } else if (data === 'edit_bio') { + await this.handleEditBio(chatId, telegramId); + } else if (data === 'edit_hobbies') { + await this.handleEditHobbies(chatId, telegramId); + } else if (data === 'edit_city') { + await this.handleEditCity(chatId, telegramId); + } else if (data === 'edit_job') { + await this.handleEditJob(chatId, telegramId); + } else if (data === 'edit_education') { + await this.handleEditEducation(chatId, telegramId); + } else if (data === 'edit_height') { + await this.handleEditHeight(chatId, telegramId); + } else if (data === 'edit_religion') { + await this.handleEditReligion(chatId, telegramId); + } else if (data === 'edit_dating_goal') { + await this.handleEditDatingGoal(chatId, telegramId); + } else if (data === 'edit_lifestyle') { + await this.handleEditLifestyle(chatId, telegramId); + } else if (data === 'edit_search_preferences') { + await this.handleEditSearchPreferences(chatId, telegramId); + } + + // Управление фотографиями + else if (data === 'add_photo') { + await this.handleAddPhoto(chatId, telegramId); + } else if (data === 'delete_photo') { + await this.handleDeletePhoto(chatId, telegramId); + } else if (data === 'set_main_photo') { + await this.handleSetMainPhoto(chatId, telegramId); + } else if (data.startsWith('delete_photo_')) { + const photoIndex = parseInt(data.replace('delete_photo_', '')); + await this.handleDeletePhotoByIndex(chatId, telegramId, photoIndex); + } else if (data.startsWith('set_main_photo_')) { + const photoIndex = parseInt(data.replace('set_main_photo_', '')); + await this.handleSetMainPhotoByIndex(chatId, telegramId, photoIndex); + } + + // Цели знакомства + else if (data.startsWith('set_dating_goal_')) { + const goal = data.replace('set_dating_goal_', ''); + await this.handleSetDatingGoal(chatId, telegramId, goal); + } + + // Образ жизни + else if (data === 'edit_smoking') { + await this.handleEditSmoking(chatId, telegramId); + } else if (data === 'edit_drinking') { + await this.handleEditDrinking(chatId, telegramId); + } else if (data === 'edit_kids') { + await this.handleEditKids(chatId, telegramId); + } else if (data.startsWith('set_smoking_')) { + const value = data.replace('set_smoking_', ''); + await this.handleSetLifestyle(chatId, telegramId, 'smoking', value); + } else if (data.startsWith('set_drinking_')) { + const value = data.replace('set_drinking_', ''); + await this.handleSetLifestyle(chatId, telegramId, 'drinking', value); + } else if (data.startsWith('set_kids_')) { + const value = data.replace('set_kids_', ''); + await this.handleSetLifestyle(chatId, telegramId, 'kids', value); + } + + // Настройки поиска + else if (data === 'edit_age_range') { + await this.handleEditAgeRange(chatId, telegramId); + } else if (data === 'edit_distance') { + await this.handleEditDistance(chatId, telegramId); + } + + // Просмотр анкет и свайпы + else if (data === 'start_browsing') { + await this.handleStartBrowsing(chatId, telegramId, false); + } else if (data === 'start_browsing_first') { + // Показываем всех пользователей для нового пользователя + await this.handleStartBrowsing(chatId, telegramId, true); + } else if (data === 'vip_search') { + await this.handleVipSearch(chatId, telegramId); + } else if (data.startsWith('search_by_goal_')) { + const goal = data.replace('search_by_goal_', ''); + await this.handleSearchByGoal(chatId, telegramId, goal); + } else if (data === 'next_candidate') { + await this.handleNextCandidate(chatId, telegramId); + } else if (data.startsWith('like_')) { + const targetUserId = data.replace('like_', ''); + await this.handleLike(chatId, telegramId, targetUserId); + } else if (data.startsWith('dislike_')) { + const targetUserId = data.replace('dislike_', ''); + await this.handleDislike(chatId, telegramId, targetUserId); + } else if (data.startsWith('superlike_')) { + const targetUserId = data.replace('superlike_', ''); + await this.handleSuperlike(chatId, telegramId, targetUserId); + } else if (data.startsWith('view_profile_')) { + const targetUserId = data.replace('view_profile_', ''); + await this.handleViewProfile(chatId, telegramId, targetUserId); + } else if (data.startsWith('more_photos_')) { + const targetUserId = data.replace('more_photos_', ''); + await this.handleMorePhotos(chatId, telegramId, targetUserId); + } + + // Обработка лайков и ответных лайков из уведомлений + else if (data.startsWith('like_back:')) { + const targetUserId = data.replace('like_back:', ''); + await this.likeBackHandler.handleLikeBack(chatId, telegramId, targetUserId); + } + + // Матчи и чаты + else if (data === 'view_matches') { + await this.handleViewMatches(chatId, telegramId); + } else if (data === 'open_chats') { + await this.handleOpenChats(chatId, telegramId); + } else if (data === 'native_chats') { + await this.enhancedChatHandlers.showChatsNative(chatId, telegramId); + } else if (data.startsWith('open_native_chat_')) { + const matchId = data.replace('open_native_chat_', ''); + await this.enhancedChatHandlers.openNativeChat(chatId, telegramId, matchId); + } else if (data.startsWith('chat_history_')) { + const matchId = data.replace('chat_history_', ''); + await this.enhancedChatHandlers.showChatHistory(chatId, telegramId, matchId); + } else if (data.startsWith('chat_')) { + const matchId = data.replace('chat_', ''); + await this.handleOpenChat(chatId, telegramId, matchId); + } else if (data.startsWith('send_message_')) { + const matchId = data.replace('send_message_', ''); + await this.handleSendMessage(chatId, telegramId, matchId); + } else if (data.startsWith('view_chat_profile_')) { + const matchId = data.replace('view_chat_profile_', ''); + await this.handleViewChatProfile(chatId, telegramId, matchId); + } else if (data.startsWith('unmatch_')) { + const matchId = data.replace('unmatch_', ''); + await this.handleUnmatch(chatId, telegramId, matchId); + } else if (data.startsWith('confirm_unmatch_')) { + const matchId = data.replace('confirm_unmatch_', ''); + await this.handleConfirmUnmatch(chatId, telegramId, matchId); + } + + // Настройки + else if (data === 'settings') { + await this.handleSettings(chatId, telegramId); + } else if (data === 'search_settings') { + await this.handleSearchSettings(chatId, telegramId); + } else if (data === 'notification_settings') { + await this.handleNotificationSettings(chatId, telegramId); + } else if (data === 'view_stats') { + await this.handleViewStats(chatId, telegramId); + } else if (data === 'view_profile_viewers') { + await this.handleViewProfileViewers(chatId, telegramId); + } else if (data === 'hide_profile') { + await this.handleHideProfile(chatId, telegramId); + } else if (data === 'delete_profile') { + await this.handleDeleteProfile(chatId, telegramId); + } else if (data === 'main_menu') { + await this.handleMainMenu(chatId, telegramId); + } else if (data === 'confirm_delete_profile') { + await this.handleConfirmDeleteProfile(chatId, telegramId); + } + + // Информация + else if (data === 'how_it_works') { + await this.handleHowItWorks(chatId); + } else if (data === 'back_to_browsing') { + await this.handleStartBrowsing(chatId, telegramId); + } else if (data === 'get_vip') { + await this.vipController.showVipSearch(chatId, telegramId); + } + + // VIP функции + else if (data === 'vip_search') { + await this.vipController.showVipSearch(chatId, telegramId); + } else if (data === 'vip_quick_search') { + await this.vipController.performQuickVipSearch(chatId, telegramId); + } else if (data === 'vip_advanced_search') { + await this.vipController.startAdvancedSearch(chatId, telegramId); + } else if (data === 'vip_dating_goal_search') { + await this.vipController.showDatingGoalSearch(chatId, telegramId); + } else if (data.startsWith('vip_goal_')) { + const goal = data.replace('vip_goal_', ''); + await this.vipController.performDatingGoalSearch(chatId, telegramId, goal); + } else if (data.startsWith('vip_like_')) { + const targetTelegramId = data.replace('vip_like_', ''); + await this.handleVipLike(chatId, telegramId, targetTelegramId); + } else if (data.startsWith('vip_superlike_')) { + const targetTelegramId = data.replace('vip_superlike_', ''); + await this.handleVipSuperlike(chatId, telegramId, targetTelegramId); + } else if (data.startsWith('vip_dislike_')) { + const targetTelegramId = data.replace('vip_dislike_', ''); + await this.handleVipDislike(chatId, telegramId, targetTelegramId); + } + + // Настройки языка и переводы + else if (data === 'language_settings') { + await this.handleLanguageSettings(chatId, telegramId); + } else if (data.startsWith('set_language_')) { + const languageCode = data.replace('set_language_', ''); + await this.handleSetLanguage(chatId, telegramId, languageCode); + } else if (data.startsWith('translate_profile_')) { + const profileUserId = parseInt(data.replace('translate_profile_', '')); + await this.handleTranslateProfile(chatId, telegramId, profileUserId); + } else if (data === 'back_to_settings') { + await this.handleSettings(chatId, telegramId); + } + + // Настройки уведомлений + else if (data === 'notifications') { + if (this.notificationHandlers) { + const userId = await this.profileService.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(query.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + const settings = await this.notificationHandlers.getNotificationService().getNotificationSettings(userId); + await this.notificationHandlers.sendNotificationSettings(chatId, settings); + } else { + await this.handleNotificationSettings(chatId, telegramId); + } + } + // Обработка переключения настроек уведомлений + else if (data.startsWith('notif_toggle:') || + data === 'notif_time' || + data.startsWith('notif_time_set:') || + data === 'notif_dnd' || + data.startsWith('notif_dnd_set:') || + data === 'notif_dnd_time' || + data.startsWith('notif_dnd_time_set:') || + data === 'notif_dnd_time_custom') { + // Делегируем обработку в NotificationHandlers, если он доступен + if (this.notificationHandlers) { + // Эти коллбэки уже обрабатываются в NotificationHandlers, поэтому здесь ничего не делаем + // NotificationHandlers уже зарегистрировал свои обработчики в register() + } else { + await this.bot.answerCallbackQuery(query.id, { + text: 'Функция настройки уведомлений недоступна.', + show_alert: true + }); + } + } + else { + await this.bot.answerCallbackQuery(query.id, { + text: 'Функция в разработке!', + show_alert: false + }); + return; + } + + await this.bot.answerCallbackQuery(query.id); + + } catch (error) { + console.error('Callback handler error:', error); + await this.bot.answerCallbackQuery(query.id, { + text: 'Произошла ошибка. Попробуйте еще раз.', + show_alert: true + }); + } + } + + // Добавим все необходимые методы для обработки коллбэков + async handleCreateProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleGenderSelection(chatId: number, telegramId: string, gender: string): Promise { + // Заглушка метода + } + + async handleViewMyProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleManagePhotos(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handlePreviewProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditName(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditAge(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditBio(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditHobbies(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditCity(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditJob(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditEducation(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditHeight(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditReligion(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditDatingGoal(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditLifestyle(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditSearchPreferences(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleAddPhoto(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleDeletePhoto(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSetMainPhoto(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleDeletePhotoByIndex(chatId: number, telegramId: string, photoIndex: number): Promise { + // Заглушка метода + } + + async handleSetMainPhotoByIndex(chatId: number, telegramId: string, photoIndex: number): Promise { + // Заглушка метода + } + + async handleSetDatingGoal(chatId: number, telegramId: string, goal: string): Promise { + // Заглушка метода + } + + async handleEditSmoking(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditDrinking(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditKids(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSetLifestyle(chatId: number, telegramId: string, type: string, value: string): Promise { + // Заглушка метода + } + + async handleEditAgeRange(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditDistance(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleStartBrowsing(chatId: number, telegramId: string, showAll: boolean = false): Promise { + // Заглушка метода + } + + async handleVipSearch(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSearchByGoal(chatId: number, telegramId: string, goal: string): Promise { + // Заглушка метода + } + + async handleNextCandidate(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleLike(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleDislike(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleSuperlike(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleViewProfile(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleMorePhotos(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleViewMatches(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleOpenChats(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleOpenChat(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleSendMessage(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleViewChatProfile(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleUnmatch(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleConfirmUnmatch(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleSettings(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSearchSettings(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleViewStats(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleViewProfileViewers(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleHideProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleDeleteProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleMainMenu(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleConfirmDeleteProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleHowItWorks(chatId: number): Promise { + // Заглушка метода + } + + async handleVipLike(chatId: number, telegramId: string, targetTelegramId: string): Promise { + // Заглушка метода + } + + async handleVipSuperlike(chatId: number, telegramId: string, targetTelegramId: string): Promise { + // Заглушка метода + } + + async handleVipDislike(chatId: number, telegramId: string, targetTelegramId: string): Promise { + // Заглушка метода + } + + async handleLanguageSettings(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSetLanguage(chatId: number, telegramId: string, languageCode: string): Promise { + // Заглушка метода + } + + async handleTranslateProfile(chatId: number, telegramId: string, profileUserId: number): Promise { + // Заглушка метода + } + + // Добавим новый метод для настроек уведомлений (вызывает NotificationHandlers) + async handleNotificationSettings(chatId: number, telegramId: string): Promise { + try { + if (this.notificationHandlers) { + const userId = await this.profileService.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.sendMessage(chatId, '❌ Вы не зарегистрированы. Используйте команду /start для регистрации.'); + return; + } + + // Вызываем метод из notificationHandlers для получения настроек и отображения меню + const settings = await this.notificationHandlers.getNotificationService().getNotificationSettings(userId); + await this.notificationHandlers.sendNotificationSettings(chatId, settings); + } else { + // Если NotificationHandlers недоступен, показываем сообщение об ошибке + await this.bot.sendMessage(chatId, '⚙️ Настройки уведомлений временно недоступны. Попробуйте позже.'); + await this.handleSettings(chatId, telegramId); + } + } catch (error) { + console.error('Error handling notification settings:', error); + await this.bot.sendMessage(chatId, '❌ Произошла ошибка при загрузке настроек уведомлений.'); + } + } +} diff --git a/src/handlers/callbackHandlers.ts.original b/src/handlers/callbackHandlers.ts.original new file mode 100644 index 0000000000000000000000000000000000000000..a1c6abf60518e428e391acedd42d3d970f5e01aa GIT binary patch literal 204286 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 literal 0 HcmV?d00001 diff --git a/src/handlers/callbackHandlers.ts.stub b/src/handlers/callbackHandlers.ts.stub new file mode 100644 index 0000000..4d537fa --- /dev/null +++ b/src/handlers/callbackHandlers.ts.stub @@ -0,0 +1,606 @@ +import TelegramBot, { CallbackQuery, InlineKeyboardMarkup } from 'node-telegram-bot-api'; +import { ProfileService } from '../services/profileService'; +import { MatchingService } from '../services/matchingService'; +import { ChatService } from '../services/chatService'; +import { Profile } from '../models/Profile'; +import { MessageHandlers } from './messageHandlers'; +import { ProfileEditController } from '../controllers/profileEditController'; +import { EnhancedChatHandlers } from './enhancedChatHandlers'; +import { VipController } from '../controllers/vipController'; +import { VipService } from '../services/vipService'; +import { TranslationController } from '../controllers/translationController'; +import { t } from '../services/localizationService'; +import { LikeBackHandler } from './likeBackHandler'; +import { NotificationHandlers } from './notificationHandlers'; + +export class CallbackHandlers { + private bot: TelegramBot; + private profileService: ProfileService; + private matchingService: MatchingService; + private chatService: ChatService; + private messageHandlers: MessageHandlers; + private profileEditController: ProfileEditController; + private enhancedChatHandlers: EnhancedChatHandlers; + private vipController: VipController; + private vipService: VipService; + private translationController: TranslationController; + private likeBackHandler: LikeBackHandler; + private notificationHandlers?: NotificationHandlers; + + constructor(bot: TelegramBot, messageHandlers: MessageHandlers) { + this.bot = bot; + this.profileService = new ProfileService(); + this.matchingService = new MatchingService(); + this.chatService = new ChatService(); + this.messageHandlers = messageHandlers; + this.profileEditController = new ProfileEditController(this.profileService); + this.enhancedChatHandlers = new EnhancedChatHandlers(bot); + this.vipController = new VipController(bot); + this.vipService = new VipService(); + this.translationController = new TranslationController(); + this.likeBackHandler = new LikeBackHandler(bot); + + // Создаем экземпляр NotificationHandlers + try { + this.notificationHandlers = new NotificationHandlers(bot); + } catch (error) { + console.error('Failed to initialize NotificationHandlers:', error); + } + } + + register(): void { + this.bot.on('callback_query', (query) => this.handleCallback(query)); + } + + async handleCallback(query: CallbackQuery): Promise { + if (!query.data || !query.from || !query.message) return; + + const telegramId = query.from.id.toString(); + const chatId = query.message.chat.id; + const data = query.data; + + try { + // Основные действия профиля + if (data === 'create_profile') { + await this.handleCreateProfile(chatId, telegramId); + } else if (data.startsWith('gender_')) { + const gender = data.replace('gender_', ''); + await this.handleGenderSelection(chatId, telegramId, gender); + } else if (data === 'view_my_profile') { + await this.handleViewMyProfile(chatId, telegramId); + } else if (data === 'edit_profile') { + await this.handleEditProfile(chatId, telegramId); + } else if (data === 'manage_photos') { + await this.handleManagePhotos(chatId, telegramId); + } else if (data === 'preview_profile') { + await this.handlePreviewProfile(chatId, telegramId); + } + + // Редактирование полей профиля + else if (data === 'edit_name') { + await this.handleEditName(chatId, telegramId); + } else if (data === 'edit_age') { + await this.handleEditAge(chatId, telegramId); + } else if (data === 'edit_bio') { + await this.handleEditBio(chatId, telegramId); + } else if (data === 'edit_hobbies') { + await this.handleEditHobbies(chatId, telegramId); + } else if (data === 'edit_city') { + await this.handleEditCity(chatId, telegramId); + } else if (data === 'edit_job') { + await this.handleEditJob(chatId, telegramId); + } else if (data === 'edit_education') { + await this.handleEditEducation(chatId, telegramId); + } else if (data === 'edit_height') { + await this.handleEditHeight(chatId, telegramId); + } else if (data === 'edit_religion') { + await this.handleEditReligion(chatId, telegramId); + } else if (data === 'edit_dating_goal') { + await this.handleEditDatingGoal(chatId, telegramId); + } else if (data === 'edit_lifestyle') { + await this.handleEditLifestyle(chatId, telegramId); + } else if (data === 'edit_search_preferences') { + await this.handleEditSearchPreferences(chatId, telegramId); + } + + // Управление фотографиями + else if (data === 'add_photo') { + await this.handleAddPhoto(chatId, telegramId); + } else if (data === 'delete_photo') { + await this.handleDeletePhoto(chatId, telegramId); + } else if (data === 'set_main_photo') { + await this.handleSetMainPhoto(chatId, telegramId); + } else if (data.startsWith('delete_photo_')) { + const photoIndex = parseInt(data.replace('delete_photo_', '')); + await this.handleDeletePhotoByIndex(chatId, telegramId, photoIndex); + } else if (data.startsWith('set_main_photo_')) { + const photoIndex = parseInt(data.replace('set_main_photo_', '')); + await this.handleSetMainPhotoByIndex(chatId, telegramId, photoIndex); + } + + // Цели знакомства + else if (data.startsWith('set_dating_goal_')) { + const goal = data.replace('set_dating_goal_', ''); + await this.handleSetDatingGoal(chatId, telegramId, goal); + } + + // Образ жизни + else if (data === 'edit_smoking') { + await this.handleEditSmoking(chatId, telegramId); + } else if (data === 'edit_drinking') { + await this.handleEditDrinking(chatId, telegramId); + } else if (data === 'edit_kids') { + await this.handleEditKids(chatId, telegramId); + } else if (data.startsWith('set_smoking_')) { + const value = data.replace('set_smoking_', ''); + await this.handleSetLifestyle(chatId, telegramId, 'smoking', value); + } else if (data.startsWith('set_drinking_')) { + const value = data.replace('set_drinking_', ''); + await this.handleSetLifestyle(chatId, telegramId, 'drinking', value); + } else if (data.startsWith('set_kids_')) { + const value = data.replace('set_kids_', ''); + await this.handleSetLifestyle(chatId, telegramId, 'kids', value); + } + + // Настройки поиска + else if (data === 'edit_age_range') { + await this.handleEditAgeRange(chatId, telegramId); + } else if (data === 'edit_distance') { + await this.handleEditDistance(chatId, telegramId); + } + + // Просмотр анкет и свайпы + else if (data === 'start_browsing') { + await this.handleStartBrowsing(chatId, telegramId, false); + } else if (data === 'start_browsing_first') { + // Показываем всех пользователей для нового пользователя + await this.handleStartBrowsing(chatId, telegramId, true); + } else if (data === 'vip_search') { + await this.handleVipSearch(chatId, telegramId); + } else if (data.startsWith('search_by_goal_')) { + const goal = data.replace('search_by_goal_', ''); + await this.handleSearchByGoal(chatId, telegramId, goal); + } else if (data === 'next_candidate') { + await this.handleNextCandidate(chatId, telegramId); + } else if (data.startsWith('like_')) { + const targetUserId = data.replace('like_', ''); + await this.handleLike(chatId, telegramId, targetUserId); + } else if (data.startsWith('dislike_')) { + const targetUserId = data.replace('dislike_', ''); + await this.handleDislike(chatId, telegramId, targetUserId); + } else if (data.startsWith('superlike_')) { + const targetUserId = data.replace('superlike_', ''); + await this.handleSuperlike(chatId, telegramId, targetUserId); + } else if (data.startsWith('view_profile_')) { + const targetUserId = data.replace('view_profile_', ''); + await this.handleViewProfile(chatId, telegramId, targetUserId); + } else if (data.startsWith('more_photos_')) { + const targetUserId = data.replace('more_photos_', ''); + await this.handleMorePhotos(chatId, telegramId, targetUserId); + } + + // Обработка лайков и ответных лайков из уведомлений + else if (data.startsWith('like_back:')) { + const targetUserId = data.replace('like_back:', ''); + await this.likeBackHandler.handleLikeBack(chatId, telegramId, targetUserId); + } + + // Матчи и чаты + else if (data === 'view_matches') { + await this.handleViewMatches(chatId, telegramId); + } else if (data === 'open_chats') { + await this.handleOpenChats(chatId, telegramId); + } else if (data === 'native_chats') { + await this.enhancedChatHandlers.showChatsNative(chatId, telegramId); + } else if (data.startsWith('open_native_chat_')) { + const matchId = data.replace('open_native_chat_', ''); + await this.enhancedChatHandlers.openNativeChat(chatId, telegramId, matchId); + } else if (data.startsWith('chat_history_')) { + const matchId = data.replace('chat_history_', ''); + await this.enhancedChatHandlers.showChatHistory(chatId, telegramId, matchId); + } else if (data.startsWith('chat_')) { + const matchId = data.replace('chat_', ''); + await this.handleOpenChat(chatId, telegramId, matchId); + } else if (data.startsWith('send_message_')) { + const matchId = data.replace('send_message_', ''); + await this.handleSendMessage(chatId, telegramId, matchId); + } else if (data.startsWith('view_chat_profile_')) { + const matchId = data.replace('view_chat_profile_', ''); + await this.handleViewChatProfile(chatId, telegramId, matchId); + } else if (data.startsWith('unmatch_')) { + const matchId = data.replace('unmatch_', ''); + await this.handleUnmatch(chatId, telegramId, matchId); + } else if (data.startsWith('confirm_unmatch_')) { + const matchId = data.replace('confirm_unmatch_', ''); + await this.handleConfirmUnmatch(chatId, telegramId, matchId); + } + + // Настройки + else if (data === 'settings') { + await this.handleSettings(chatId, telegramId); + } else if (data === 'search_settings') { + await this.handleSearchSettings(chatId, telegramId); + } else if (data === 'notification_settings') { + await this.handleNotificationSettings(chatId, telegramId); + } else if (data === 'view_stats') { + await this.handleViewStats(chatId, telegramId); + } else if (data === 'view_profile_viewers') { + await this.handleViewProfileViewers(chatId, telegramId); + } else if (data === 'hide_profile') { + await this.handleHideProfile(chatId, telegramId); + } else if (data === 'delete_profile') { + await this.handleDeleteProfile(chatId, telegramId); + } else if (data === 'main_menu') { + await this.handleMainMenu(chatId, telegramId); + } else if (data === 'confirm_delete_profile') { + await this.handleConfirmDeleteProfile(chatId, telegramId); + } + + // Информация + else if (data === 'how_it_works') { + await this.handleHowItWorks(chatId); + } else if (data === 'back_to_browsing') { + await this.handleStartBrowsing(chatId, telegramId); + } else if (data === 'get_vip') { + await this.vipController.showVipSearch(chatId, telegramId); + } + + // VIP функции + else if (data === 'vip_search') { + await this.vipController.showVipSearch(chatId, telegramId); + } else if (data === 'vip_quick_search') { + await this.vipController.performQuickVipSearch(chatId, telegramId); + } else if (data === 'vip_advanced_search') { + await this.vipController.startAdvancedSearch(chatId, telegramId); + } else if (data === 'vip_dating_goal_search') { + await this.vipController.showDatingGoalSearch(chatId, telegramId); + } else if (data.startsWith('vip_goal_')) { + const goal = data.replace('vip_goal_', ''); + await this.vipController.performDatingGoalSearch(chatId, telegramId, goal); + } else if (data.startsWith('vip_like_')) { + const targetTelegramId = data.replace('vip_like_', ''); + await this.handleVipLike(chatId, telegramId, targetTelegramId); + } else if (data.startsWith('vip_superlike_')) { + const targetTelegramId = data.replace('vip_superlike_', ''); + await this.handleVipSuperlike(chatId, telegramId, targetTelegramId); + } else if (data.startsWith('vip_dislike_')) { + const targetTelegramId = data.replace('vip_dislike_', ''); + await this.handleVipDislike(chatId, telegramId, targetTelegramId); + } + + // Настройки языка и переводы + else if (data === 'language_settings') { + await this.handleLanguageSettings(chatId, telegramId); + } else if (data.startsWith('set_language_')) { + const languageCode = data.replace('set_language_', ''); + await this.handleSetLanguage(chatId, telegramId, languageCode); + } else if (data.startsWith('translate_profile_')) { + const profileUserId = parseInt(data.replace('translate_profile_', '')); + await this.handleTranslateProfile(chatId, telegramId, profileUserId); + } else if (data === 'back_to_settings') { + await this.handleSettings(chatId, telegramId); + } + + // Настройки уведомлений + else if (data === 'notifications') { + if (this.notificationHandlers) { + const userId = await this.profileService.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(query.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + const settings = await this.notificationHandlers.getNotificationService().getNotificationSettings(userId); + await this.notificationHandlers.sendNotificationSettings(chatId, settings); + } else { + await this.handleNotificationSettings(chatId, telegramId); + } + } + // Обработка переключения настроек уведомлений + else if (data.startsWith('notif_toggle:') || + data === 'notif_time' || + data.startsWith('notif_time_set:') || + data === 'notif_dnd' || + data.startsWith('notif_dnd_set:') || + data === 'notif_dnd_time' || + data.startsWith('notif_dnd_time_set:') || + data === 'notif_dnd_time_custom') { + // Делегируем обработку в NotificationHandlers, если он доступен + if (this.notificationHandlers) { + // Эти коллбэки уже обрабатываются в NotificationHandlers, поэтому здесь ничего не делаем + // NotificationHandlers уже зарегистрировал свои обработчики в register() + } else { + await this.bot.answerCallbackQuery(query.id, { + text: 'Функция настройки уведомлений недоступна.', + show_alert: true + }); + } + } + else { + await this.bot.answerCallbackQuery(query.id, { + text: 'Функция в разработке!', + show_alert: false + }); + return; + } + + await this.bot.answerCallbackQuery(query.id); + + } catch (error) { + console.error('Callback handler error:', error); + await this.bot.answerCallbackQuery(query.id, { + text: 'Произошла ошибка. Попробуйте еще раз.', + show_alert: true + }); + } + } + + // Добавим все необходимые методы для обработки коллбэков + async handleCreateProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleGenderSelection(chatId: number, telegramId: string, gender: string): Promise { + // Заглушка метода + } + + async handleViewMyProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleManagePhotos(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handlePreviewProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditName(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditAge(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditBio(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditHobbies(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditCity(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditJob(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditEducation(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditHeight(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditReligion(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditDatingGoal(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditLifestyle(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditSearchPreferences(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleAddPhoto(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleDeletePhoto(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSetMainPhoto(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleDeletePhotoByIndex(chatId: number, telegramId: string, photoIndex: number): Promise { + // Заглушка метода + } + + async handleSetMainPhotoByIndex(chatId: number, telegramId: string, photoIndex: number): Promise { + // Заглушка метода + } + + async handleSetDatingGoal(chatId: number, telegramId: string, goal: string): Promise { + // Заглушка метода + } + + async handleEditSmoking(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditDrinking(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditKids(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSetLifestyle(chatId: number, telegramId: string, type: string, value: string): Promise { + // Заглушка метода + } + + async handleEditAgeRange(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleEditDistance(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleStartBrowsing(chatId: number, telegramId: string, showAll: boolean = false): Promise { + // Заглушка метода + } + + async handleVipSearch(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSearchByGoal(chatId: number, telegramId: string, goal: string): Promise { + // Заглушка метода + } + + async handleNextCandidate(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleLike(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleDislike(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleSuperlike(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleViewProfile(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleMorePhotos(chatId: number, telegramId: string, targetUserId: string): Promise { + // Заглушка метода + } + + async handleViewMatches(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleOpenChats(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleOpenChat(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleSendMessage(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleViewChatProfile(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleUnmatch(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleConfirmUnmatch(chatId: number, telegramId: string, matchId: string): Promise { + // Заглушка метода + } + + async handleSettings(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSearchSettings(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleViewStats(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleViewProfileViewers(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleHideProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleDeleteProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleMainMenu(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleConfirmDeleteProfile(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleHowItWorks(chatId: number): Promise { + // Заглушка метода + } + + async handleVipLike(chatId: number, telegramId: string, targetTelegramId: string): Promise { + // Заглушка метода + } + + async handleVipSuperlike(chatId: number, telegramId: string, targetTelegramId: string): Promise { + // Заглушка метода + } + + async handleVipDislike(chatId: number, telegramId: string, targetTelegramId: string): Promise { + // Заглушка метода + } + + async handleLanguageSettings(chatId: number, telegramId: string): Promise { + // Заглушка метода + } + + async handleSetLanguage(chatId: number, telegramId: string, languageCode: string): Promise { + // Заглушка метода + } + + async handleTranslateProfile(chatId: number, telegramId: string, profileUserId: number): Promise { + // Заглушка метода + } + + // Добавим новый метод для настроек уведомлений (вызывает NotificationHandlers) + async handleNotificationSettings(chatId: number, telegramId: string): Promise { + try { + if (this.notificationHandlers) { + const userId = await this.profileService.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.sendMessage(chatId, '❌ Вы не зарегистрированы. Используйте команду /start для регистрации.'); + return; + } + + // Вызываем метод из notificationHandlers для получения настроек и отображения меню + const settings = await this.notificationHandlers.getNotificationService().getNotificationSettings(userId); + await this.notificationHandlers.sendNotificationSettings(chatId, settings); + } else { + // Если NotificationHandlers недоступен, показываем сообщение об ошибке + await this.bot.sendMessage(chatId, '⚙️ Настройки уведомлений временно недоступны. Попробуйте позже.'); + await this.handleSettings(chatId, telegramId); + } + } catch (error) { + console.error('Error handling notification settings:', error); + await this.bot.sendMessage(chatId, '❌ Произошла ошибка при загрузке настроек уведомлений.'); + } + } +} diff --git a/src/handlers/commandHandlers.ts b/src/handlers/commandHandlers.ts index c86032f..77d2ad3 100644 --- a/src/handlers/commandHandlers.ts +++ b/src/handlers/commandHandlers.ts @@ -3,16 +3,19 @@ import { ProfileService } from '../services/profileService'; import { MatchingService } from '../services/matchingService'; import { Profile } from '../models/Profile'; import { getUserTranslation } from '../services/localizationService'; +import { NotificationHandlers } from './notificationHandlers'; export class CommandHandlers { private bot: TelegramBot; private profileService: ProfileService; private matchingService: MatchingService; + private notificationHandlers: NotificationHandlers; constructor(bot: TelegramBot) { this.bot = bot; this.profileService = new ProfileService(); this.matchingService = new MatchingService(); + this.notificationHandlers = new NotificationHandlers(bot); } register(): void { @@ -23,6 +26,12 @@ export class CommandHandlers { this.bot.onText(/\/matches/, (msg: Message) => this.handleMatches(msg)); this.bot.onText(/\/settings/, (msg: Message) => this.handleSettings(msg)); this.bot.onText(/\/create_profile/, (msg: Message) => this.handleCreateProfile(msg)); + + // Регистрация обработчика настроек уведомлений + this.bot.onText(/\/notifications/, (msg: Message) => this.notificationHandlers.handleNotificationsCommand(msg)); + + // Регистрируем обработчики для уведомлений + this.notificationHandlers.register(); } async handleStart(msg: Message): Promise { @@ -44,7 +53,8 @@ export class CommandHandlers { { text: '⭐ VIP поиск', callback_data: 'vip_search' } ], [ - { text: '⚙️ Настройки', callback_data: 'settings' } + { text: '⚙️ Настройки', callback_data: 'settings' }, + { text: '🔔 Уведомления', callback_data: 'notifications' } ] ] }; @@ -84,6 +94,7 @@ export class CommandHandlers { /browse - Просмотр анкет /matches - Ваши матчи /settings - Настройки +/notifications - Настройки уведомлений /help - Эта справка � Как использовать: @@ -191,7 +202,7 @@ export class CommandHandlers { inline_keyboard: [ [ { text: '🔍 Настройки поиска', callback_data: 'search_settings' }, - { text: '🔔 Уведомления', callback_data: 'notification_settings' } + { text: '🔔 Уведомления', callback_data: 'notifications' } ], [ { text: '🚫 Скрыть профиль', callback_data: 'hide_profile' }, @@ -242,7 +253,10 @@ export class CommandHandlers { { text: '✏️ Редактировать', callback_data: 'edit_profile' }, { text: '📸 Фото', callback_data: 'manage_photos' } ], - [{ text: '🔍 Начать поиск', callback_data: 'start_browsing' }] + [ + { text: '🔍 Начать поиск', callback_data: 'start_browsing' }, + { text: '🔔 Уведомления', callback_data: 'notifications' } + ] ] } : { inline_keyboard: [ diff --git a/src/handlers/notificationHandlers.ts b/src/handlers/notificationHandlers.ts new file mode 100644 index 0000000..3c6866e --- /dev/null +++ b/src/handlers/notificationHandlers.ts @@ -0,0 +1,644 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { v4 as uuidv4 } from 'uuid'; +import { query } from '../database/connection'; +import { NotificationService } from '../services/notificationService'; + +interface NotificationSettings { + newMatches: boolean; + newMessages: boolean; + newLikes: boolean; + reminders: boolean; + dailySummary: boolean; + timePreference: 'morning' | 'afternoon' | 'evening' | 'night'; + doNotDisturb: boolean; + doNotDisturbStart?: string; + doNotDisturbEnd?: string; +} + +export class NotificationHandlers { + private bot: TelegramBot; + private notificationService: NotificationService; + + constructor(bot: TelegramBot) { + this.bot = bot; + this.notificationService = new NotificationService(bot); + } + + // Метод для получения экземпляра сервиса уведомлений + getNotificationService(): NotificationService { + return this.notificationService; + } + + // Обработка команды /notifications + async handleNotificationsCommand(msg: TelegramBot.Message): Promise { + const telegramId = msg.from?.id.toString(); + if (!telegramId) return; + + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.sendMessage(msg.chat.id, '❌ Вы не зарегистрированы. Используйте команду /start для регистрации.'); + return; + } + + const settings = await this.notificationService.getNotificationSettings(userId); + await this.sendNotificationSettings(msg.chat.id, settings as NotificationSettings); + } catch (error) { + console.error('Error handling notifications command:', error); + await this.bot.sendMessage(msg.chat.id, '❌ Произошла ошибка при загрузке настроек уведомлений.'); + } + } + + // Отправка меню настроек уведомлений + async sendNotificationSettings(chatId: number, settings: NotificationSettings): Promise { + const message = ` +🔔 *Настройки уведомлений* + +Выберите, какие уведомления вы хотите получать: + +${settings.newMatches ? '✅' : '❌'} Новые матчи +${settings.newMessages ? '✅' : '❌'} Новые сообщения +${settings.newLikes ? '✅' : '❌'} Новые лайки +${settings.reminders ? '✅' : '❌'} Напоминания +${settings.dailySummary ? '✅' : '❌'} Ежедневные сводки + +⏰ Предпочтительное время: ${this.getTimePreferenceText(settings.timePreference)} + +${settings.doNotDisturb ? '🔕' : '🔔'} Режим "Не беспокоить": ${settings.doNotDisturb ? 'Включен' : 'Выключен'} +${settings.doNotDisturb && settings.doNotDisturbStart && settings.doNotDisturbEnd ? + `с ${settings.doNotDisturbStart} до ${settings.doNotDisturbEnd}` : ''} + +Нажмите на кнопку, чтобы изменить настройку: +`; + + await this.bot.sendMessage(chatId, message, { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [ + { text: `${settings.newMatches ? '✅' : '❌'} Новые матчи`, callback_data: 'notif_toggle:newMatches' }, + { text: `${settings.newMessages ? '✅' : '❌'} Новые сообщения`, callback_data: 'notif_toggle:newMessages' } + ], + [ + { text: `${settings.newLikes ? '✅' : '❌'} Новые лайки`, callback_data: 'notif_toggle:newLikes' }, + { text: `${settings.reminders ? '✅' : '❌'} Напоминания`, callback_data: 'notif_toggle:reminders' } + ], + [ + { text: `${settings.dailySummary ? '✅' : '❌'} Ежедневные сводки`, callback_data: 'notif_toggle:dailySummary' } + ], + [ + { text: `⏰ Время: ${this.getTimePreferenceText(settings.timePreference)}`, callback_data: 'notif_time' } + ], + [ + { text: `${settings.doNotDisturb ? '🔕' : '🔔'} Режим "Не беспокоить"`, callback_data: 'notif_dnd' } + ], + [ + { text: '↩️ Назад', callback_data: 'settings' } + ] + ] + } + }); + } + + // Обработка переключения настройки уведомления + async handleNotificationToggle(callbackQuery: TelegramBot.CallbackQuery): Promise { + const telegramId = callbackQuery.from?.id.toString(); + if (!telegramId || !callbackQuery.message) return; + + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + // notif_toggle:settingName + const settingName = callbackQuery.data?.split(':')[1]; + if (!settingName) return; + + const settings = await this.notificationService.getNotificationSettings(userId); + let updatedSettings: Partial = { ...settings }; + + // Инвертируем значение настройки + if (settingName in updatedSettings) { + switch(settingName) { + case 'newMatches': + updatedSettings.newMatches = !updatedSettings.newMatches; + break; + case 'newMessages': + updatedSettings.newMessages = !updatedSettings.newMessages; + break; + case 'newLikes': + updatedSettings.newLikes = !updatedSettings.newLikes; + break; + case 'reminders': + updatedSettings.reminders = !updatedSettings.reminders; + break; + case 'dailySummary': + updatedSettings.dailySummary = !updatedSettings.dailySummary; + break; + } + } + + // Обновляем настройки + await this.notificationService.updateNotificationSettings(userId, updatedSettings); + + // Отправляем обновленные настройки + await this.bot.answerCallbackQuery(callbackQuery.id, { + text: `✅ Настройка "${this.getSettingName(settingName)}" ${updatedSettings[settingName as keyof NotificationSettings] ? 'включена' : 'отключена'}` + }); + + await this.sendNotificationSettings(callbackQuery.message.chat.id, updatedSettings as NotificationSettings); + } catch (error) { + console.error('Error handling notification toggle:', error); + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Произошла ошибка при обновлении настроек.' }); + } + } + + // Обработка выбора времени для уведомлений + async handleTimePreference(callbackQuery: TelegramBot.CallbackQuery): Promise { + if (!callbackQuery.message) return; + + await this.bot.editMessageText('⏰ *Выберите предпочтительное время для уведомлений:*', { + chat_id: callbackQuery.message.chat.id, + message_id: callbackQuery.message.message_id, + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [ + { text: '🌅 Утро (9:00)', callback_data: 'notif_time_set:morning' }, + { text: '☀️ День (13:00)', callback_data: 'notif_time_set:afternoon' } + ], + [ + { text: '🌆 Вечер (19:00)', callback_data: 'notif_time_set:evening' }, + { text: '🌙 Ночь (22:00)', callback_data: 'notif_time_set:night' } + ], + [ + { text: '↩️ Назад', callback_data: 'notifications' } + ] + ] + } + }); + + await this.bot.answerCallbackQuery(callbackQuery.id); + } + + // Обработка установки времени для уведомлений + async handleTimePreferenceSet(callbackQuery: TelegramBot.CallbackQuery): Promise { + const telegramId = callbackQuery.from?.id.toString(); + if (!telegramId || !callbackQuery.message) return; + + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + // notif_time_set:timePreference + const timePreference = callbackQuery.data?.split(':')[1] as 'morning' | 'afternoon' | 'evening' | 'night'; + if (!timePreference) return; + + const settings = await this.notificationService.getNotificationSettings(userId); + // Копируем существующие настройки и обновляем нужные поля + const existingSettings = settings as NotificationSettings; + const updatedSettings: NotificationSettings = { + ...existingSettings, + timePreference + }; + + // Обновляем настройки + await this.notificationService.updateNotificationSettings(userId, updatedSettings); + + // Отправляем обновленные настройки + await this.bot.answerCallbackQuery(callbackQuery.id, { + text: `✅ Время уведомлений установлено на ${this.getTimePreferenceText(timePreference)}` + }); + + await this.sendNotificationSettings(callbackQuery.message.chat.id, updatedSettings); + } catch (error) { + console.error('Error handling time preference set:', error); + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Произошла ошибка при обновлении времени уведомлений.' }); + } + } + + // Обработка режима "Не беспокоить" + async handleDndMode(callbackQuery: TelegramBot.CallbackQuery): Promise { + if (!callbackQuery.message) return; + + await this.bot.editMessageText('🔕 *Режим "Не беспокоить"*\n\nВыберите действие:', { + chat_id: callbackQuery.message.chat.id, + message_id: callbackQuery.message.message_id, + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [ + { text: '✅ Включить', callback_data: 'notif_dnd_set:on' }, + { text: '❌ Выключить', callback_data: 'notif_dnd_set:off' } + ], + [ + { text: '⏰ Настроить время', callback_data: 'notif_dnd_time' } + ], + [ + { text: '↩️ Назад', callback_data: 'notifications' } + ] + ] + } + }); + + await this.bot.answerCallbackQuery(callbackQuery.id); + } + + // Обработка установки режима "Не беспокоить" + async handleDndModeSet(callbackQuery: TelegramBot.CallbackQuery): Promise { + const telegramId = callbackQuery.from?.id.toString(); + if (!telegramId || !callbackQuery.message) return; + + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + // notif_dnd_set:on/off + const mode = callbackQuery.data?.split(':')[1]; + if (!mode) return; + + const settings = await this.notificationService.getNotificationSettings(userId); + // Копируем существующие настройки и обновляем нужное поле + const existingSettings = settings as NotificationSettings; + let updatedSettings: NotificationSettings = { + ...existingSettings, + doNotDisturb: mode === 'on' + }; + + // Если включаем режим "Не беспокоить", но не задано время, ставим дефолтные значения + if (mode === 'on' && (!updatedSettings.doNotDisturbStart || !updatedSettings.doNotDisturbEnd)) { + updatedSettings.doNotDisturbStart = '23:00'; + updatedSettings.doNotDisturbEnd = '08:00'; + } + + // Обновляем настройки + await this.notificationService.updateNotificationSettings(userId, updatedSettings); + + // Отправляем обновленные настройки + await this.bot.answerCallbackQuery(callbackQuery.id, { + text: `✅ Режим "Не беспокоить" ${mode === 'on' ? 'включен' : 'выключен'}` + }); + + await this.sendNotificationSettings(callbackQuery.message.chat.id, updatedSettings); + } catch (error) { + console.error('Error handling DND mode set:', error); + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Произошла ошибка при обновлении режима "Не беспокоить".' }); + } + } + + // Настройка времени для режима "Не беспокоить" + async handleDndTimeSetup(callbackQuery: TelegramBot.CallbackQuery): Promise { + if (!callbackQuery.message) return; + + await this.bot.editMessageText('⏰ *Настройка времени для режима "Не беспокоить"*\n\nВыберите один из предустановленных вариантов или введите свой:', { + chat_id: callbackQuery.message.chat.id, + message_id: callbackQuery.message.message_id, + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [ + { text: '🌙 23:00 - 08:00', callback_data: 'notif_dnd_time_set:23:00:08:00' } + ], + [ + { text: '🌙 22:00 - 07:00', callback_data: 'notif_dnd_time_set:22:00:07:00' } + ], + [ + { text: '🌙 00:00 - 09:00', callback_data: 'notif_dnd_time_set:00:00:09:00' } + ], + [ + { text: '✏️ Ввести свой вариант', callback_data: 'notif_dnd_time_custom' } + ], + [ + { text: '↩️ Назад', callback_data: 'notif_dnd' } + ] + ] + } + }); + + await this.bot.answerCallbackQuery(callbackQuery.id); + } + + // Установка предустановленного времени для режима "Не беспокоить" + async handleDndTimeSet(callbackQuery: TelegramBot.CallbackQuery): Promise { + const telegramId = callbackQuery.from?.id.toString(); + if (!telegramId || !callbackQuery.message) return; + + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + // notif_dnd_time_set:startTime:endTime + const parts = callbackQuery.data?.split(':'); + if (parts && parts.length >= 4) { + const startTime = `${parts[2]}:${parts[3]}`; + const endTime = `${parts[4]}:${parts[5]}`; + + const settings = await this.notificationService.getNotificationSettings(userId); + // Копируем существующие настройки и обновляем нужные поля + const existingSettings = settings as NotificationSettings; + const updatedSettings: NotificationSettings = { + ...existingSettings, + doNotDisturb: true, + doNotDisturbStart: startTime, + doNotDisturbEnd: endTime + }; + + // Обновляем настройки + await this.notificationService.updateNotificationSettings(userId, updatedSettings); + + // Отправляем обновленные настройки + await this.bot.answerCallbackQuery(callbackQuery.id, { + text: `✅ Время "Не беспокоить" установлено с ${startTime} до ${endTime}` + }); + + await this.sendNotificationSettings(callbackQuery.message.chat.id, updatedSettings); + } + } catch (error) { + console.error('Error handling DND time set:', error); + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Произошла ошибка при настройке времени "Не беспокоить".' }); + } + } + + // Запрос пользовательского времени для режима "Не беспокоить" + async handleDndTimeCustom(callbackQuery: TelegramBot.CallbackQuery): Promise { + if (!callbackQuery.message) return; + + // Устанавливаем ожидание пользовательского ввода + const userId = callbackQuery.from?.id.toString(); + if (userId) { + await this.setUserState(userId, 'waiting_dnd_time'); + } + + await this.bot.editMessageText('⏰ *Введите время для режима "Не беспокоить"*\n\nУкажите время в формате:\n`с [ЧЧ:ММ] до [ЧЧ:ММ]`\n\nНапример: `с 23:30 до 07:00`', { + chat_id: callbackQuery.message.chat.id, + message_id: callbackQuery.message.message_id, + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: [ + [ + { text: '↩️ Отмена', callback_data: 'notif_dnd_time' } + ] + ] + } + }); + + await this.bot.answerCallbackQuery(callbackQuery.id); + } + + // Обработка пользовательского ввода времени для режима "Не беспокоить" + async handleDndTimeInput(msg: TelegramBot.Message): Promise { + const telegramId = msg.from?.id.toString(); + if (!telegramId) return; + + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.sendMessage(msg.chat.id, '❌ Вы не зарегистрированы. Используйте команду /start для регистрации.'); + return; + } + + // Очищаем состояние ожидания + await this.clearUserState(telegramId); + + // Парсим введенное время + const timeRegex = /с\s+(\d{1,2}[:\.]\d{2})\s+до\s+(\d{1,2}[:\.]\d{2})/i; + const match = msg.text?.match(timeRegex); + + if (match && match.length >= 3) { + let startTime = match[1].replace('.', ':'); + let endTime = match[2].replace('.', ':'); + + // Проверяем и форматируем время + if (this.isValidTime(startTime) && this.isValidTime(endTime)) { + startTime = this.formatTime(startTime); + endTime = this.formatTime(endTime); + + const settings = await this.notificationService.getNotificationSettings(userId); + // Копируем существующие настройки и обновляем нужные поля + const existingSettings = settings as NotificationSettings; + const updatedSettings: NotificationSettings = { + ...existingSettings, + doNotDisturb: true, + doNotDisturbStart: startTime, + doNotDisturbEnd: endTime + }; + + // Обновляем настройки + await this.notificationService.updateNotificationSettings(userId, updatedSettings); + + await this.bot.sendMessage(msg.chat.id, `✅ Время "Не беспокоить" установлено с ${startTime} до ${endTime}`); + await this.sendNotificationSettings(msg.chat.id, updatedSettings); + } else { + await this.bot.sendMessage(msg.chat.id, '❌ Неверный формат времени. Пожалуйста, используйте формат ЧЧ:ММ (например, 23:30).'); + } + } else { + await this.bot.sendMessage(msg.chat.id, '❌ Неверный формат ввода. Пожалуйста, введите время в формате "с [ЧЧ:ММ] до [ЧЧ:ММ]" (например, "с 23:30 до 07:00").'); + } + } catch (error) { + console.error('Error handling DND time input:', error); + await this.bot.sendMessage(msg.chat.id, '❌ Произошла ошибка при настройке времени "Не беспокоить".'); + } + } + + // Проверка валидности времени + private isValidTime(time: string): boolean { + const regex = /^(\d{1,2}):(\d{2})$/; + const match = time.match(regex); + + if (match) { + const hours = parseInt(match[1]); + const minutes = parseInt(match[2]); + return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59; + } + + return false; + } + + // Форматирование времени в формат ЧЧ:ММ + private formatTime(time: string): string { + const [hours, minutes] = time.split(':').map(Number); + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; + } + + // Получение текстового представления времени + private getTimePreferenceText(preference: string): string { + switch (preference) { + case 'morning': return 'Утро (9:00)'; + case 'afternoon': return 'День (13:00)'; + case 'evening': return 'Вечер (19:00)'; + case 'night': return 'Ночь (22:00)'; + default: return 'Вечер (19:00)'; + } + } + + // Получение названия настройки + private getSettingName(setting: string): string { + switch (setting) { + case 'newMatches': return 'Новые матчи'; + case 'newMessages': return 'Новые сообщения'; + case 'newLikes': return 'Новые лайки'; + case 'reminders': return 'Напоминания'; + case 'dailySummary': return 'Ежедневные сводки'; + default: return setting; + } + } + + // Получение ID пользователя по Telegram ID + private async getUserIdByTelegramId(telegramId: string): Promise { + try { + const result = await query( + 'SELECT id FROM users WHERE telegram_id = $1', + [parseInt(telegramId)] + ); + return result.rows.length > 0 ? result.rows[0].id : null; + } catch (error) { + console.error('Error getting user by telegram ID:', error); + return null; + } + } + + // Установка состояния ожидания пользователя + private async setUserState(telegramId: string, state: string): Promise { + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) return; + + // Сначала проверяем, существуют ли столбцы state и state_data + const checkColumnResult = await query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'state' + `); + + if (checkColumnResult.rows.length === 0) { + console.log('Adding state and state_data columns to users table...'); + // Добавляем столбцы, если их нет + await query(` + ALTER TABLE users ADD COLUMN IF NOT EXISTS state VARCHAR(255) NULL; + ALTER TABLE users ADD COLUMN IF NOT EXISTS state_data JSONB DEFAULT '{}'::jsonb; + `); + } + + // Теперь устанавливаем состояние + await query( + `UPDATE users + SET state = $1, + state_data = jsonb_set(COALESCE(state_data, '{}'::jsonb), '{timestamp}', to_jsonb(NOW())) + WHERE telegram_id = $2`, + [state, parseInt(telegramId)] + ); + } catch (error) { + console.error('Error setting user state:', error); + } + } + + // Очистка состояния ожидания пользователя + private async clearUserState(telegramId: string): Promise { + try { + await query( + 'UPDATE users SET state = NULL WHERE telegram_id = $1', + [parseInt(telegramId)] + ); + } catch (error) { + console.error('Error clearing user state:', error); + } + } + + // Регистрация обработчиков уведомлений + register(): void { + // Команда настройки уведомлений + this.bot.onText(/\/notifications/, this.handleNotificationsCommand.bind(this)); + + // Обработчик для кнопки настроек уведомлений в меню настроек + this.bot.on('callback_query', async (callbackQuery) => { + if (callbackQuery.data === 'notifications') { + const telegramId = callbackQuery.from?.id.toString(); + if (!telegramId || !callbackQuery.message) return; + + try { + const userId = await this.getUserIdByTelegramId(telegramId); + if (!userId) { + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Вы не зарегистрированы.' }); + return; + } + + const settings = await this.notificationService.getNotificationSettings(userId); + await this.sendNotificationSettings(callbackQuery.message.chat.id, settings as NotificationSettings); + await this.bot.answerCallbackQuery(callbackQuery.id); + } catch (error) { + console.error('Error handling notifications callback:', error); + await this.bot.answerCallbackQuery(callbackQuery.id, { text: '❌ Произошла ошибка при загрузке настроек уведомлений.' }); + } + } + else if (callbackQuery.data?.startsWith('notif_toggle:')) { + await this.handleNotificationToggle(callbackQuery); + } + else if (callbackQuery.data === 'notif_time') { + await this.handleTimePreference(callbackQuery); + } + else if (callbackQuery.data?.startsWith('notif_time_set:')) { + await this.handleTimePreferenceSet(callbackQuery); + } + else if (callbackQuery.data === 'notif_dnd') { + await this.handleDndMode(callbackQuery); + } + else if (callbackQuery.data?.startsWith('notif_dnd_set:')) { + await this.handleDndModeSet(callbackQuery); + } + else if (callbackQuery.data === 'notif_dnd_time') { + await this.handleDndTimeSetup(callbackQuery); + } + else if (callbackQuery.data?.startsWith('notif_dnd_time_set:')) { + await this.handleDndTimeSet(callbackQuery); + } + else if (callbackQuery.data === 'notif_dnd_time_custom') { + await this.handleDndTimeCustom(callbackQuery); + } + }); + + // Обработчик пользовательского ввода для времени "Не беспокоить" + this.bot.on('message', async (msg) => { + if (!msg.text) return; + + const telegramId = msg.from?.id.toString(); + if (!telegramId) return; + + try { + // Сначала проверяем, существует ли столбец state + const checkColumnResult = await query(` + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'state' + `); + + if (checkColumnResult.rows.length === 0) { + console.log('State column does not exist in users table. Skipping state check.'); + return; + } + + // Теперь проверяем состояние пользователя + const result = await query( + 'SELECT state FROM users WHERE telegram_id = $1', + [parseInt(telegramId)] + ); + + if (result.rows.length > 0 && result.rows[0].state === 'waiting_dnd_time') { + await this.handleDndTimeInput(msg); + } + } catch (error) { + console.error('Error checking user state:', error); + } + }); + } +} diff --git a/src/scripts/setPremiumForAll.ts b/src/scripts/setPremiumForAll.ts index 208a2da..e502286 100644 --- a/src/scripts/setPremiumForAll.ts +++ b/src/scripts/setPremiumForAll.ts @@ -1,9 +1,33 @@ -import { query } from '../database/connection'; +import { query, testConnection } from '../database/connection'; async function setAllUsersToPremium() { try { console.log('Setting premium status for all users...'); + // Проверка соединения с базой данных + console.log('Testing database connection...'); + const dbConnected = await testConnection(); + if (!dbConnected) { + throw new Error('Failed to connect to database. Please check your database settings.'); + } + console.log('Database connection successful!'); + + // Проверка наличия столбца premium + const checkResult = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'premium' + ); + `); + + if (!checkResult.rows[0].exists) { + console.log('Adding premium column to users table...'); + await query(`ALTER TABLE users ADD COLUMN premium BOOLEAN DEFAULT false;`); + console.log('Premium column added successfully'); + } + const result = await query(` UPDATE users SET premium = true diff --git a/src/services/matchingService.ts b/src/services/matchingService.ts index 6d54a21..ff570d7 100644 --- a/src/services/matchingService.ts +++ b/src/services/matchingService.ts @@ -564,7 +564,7 @@ export class MatchingService { FROM profiles p JOIN users u ON p.user_id = u.id WHERE p.is_visible = true - AND p.is_active = true + AND u.is_active = true AND p.gender = $1 AND p.dating_goal = $2 AND p.user_id NOT IN (${swipedUserIds.map((_: any, i: number) => `$${i + 3}`).join(', ')}) diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index a34e59a..833c86c 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -1,14 +1,27 @@ import TelegramBot from 'node-telegram-bot-api'; -import { query } from '../database/connection'; +import { query, transaction } from '../database/connection'; import { ProfileService } from './profileService'; +import { v4 as uuidv4 } from 'uuid'; export interface NotificationData { userId: string; - type: 'new_match' | 'new_message' | 'new_like' | 'super_like'; + type: 'new_match' | 'new_message' | 'new_like' | 'super_like' | 'match_reminder' | 'inactive_matches' | 'like_summary'; data: Record; scheduledAt?: Date; } +export interface NotificationSettings { + newMatches: boolean; + newMessages: boolean; + newLikes: boolean; + reminders: boolean; + dailySummary: boolean; + timePreference: 'morning' | 'afternoon' | 'evening' | 'night'; + doNotDisturb: boolean; + doNotDisturbStart?: string; // Формат: "HH:MM" + doNotDisturbEnd?: string; // Формат: "HH:MM" +} + export class NotificationService { private bot?: TelegramBot; private profileService: ProfileService; @@ -16,6 +29,235 @@ export class NotificationService { constructor(bot?: TelegramBot) { this.bot = bot; this.profileService = new ProfileService(); + + // Создаем таблицу уведомлений, если её еще нет + this.ensureNotificationTablesExist().catch(err => + console.error('Failed to create notification tables:', err) + ); + } + + // Проверка и создание таблиц для уведомлений + private async ensureNotificationTablesExist(): Promise { + try { + // Проверяем существование таблицы notifications + const notificationsExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'notifications' + ) as exists + `); + + if (!notificationsExists.rows[0].exists) { + await query(` + CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('Created notifications table'); + } + + // Проверяем существование таблицы scheduled_notifications + const scheduledExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'scheduled_notifications' + ) as exists + `); + + if (!scheduledExists.rows[0].exists) { + await query(` + CREATE TABLE scheduled_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + scheduled_at TIMESTAMP NOT NULL, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('Created scheduled_notifications table'); + } + + // Проверяем существование таблицы notification_templates + const templatesExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'notification_templates' + ) as exists + `); + + if (!templatesExists.rows[0].exists) { + await query(` + CREATE TABLE notification_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(50) NOT NULL UNIQUE, + title TEXT NOT NULL, + message_template TEXT NOT NULL, + button_template JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('Created notification_templates table'); + + // Заполняем таблицу базовыми шаблонами + await this.populateDefaultTemplates(); + } + + // Проверяем, есть ли колонка notification_settings в таблице users + const settingsColumnExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'notification_settings' + ) as exists + `); + + if (!settingsColumnExists.rows[0].exists) { + await query(` + ALTER TABLE users + ADD COLUMN notification_settings JSONB DEFAULT '{ + "newMatches": true, + "newMessages": true, + "newLikes": true, + "reminders": true, + "dailySummary": true, + "timePreference": "evening", + "doNotDisturb": false + }'::jsonb + `); + console.log('Added notification_settings column to users table'); + } + + } catch (error) { + console.error('Error ensuring notification tables exist:', error); + throw error; + } + } + + // Заполнение таблицы шаблонов уведомлений + private async populateDefaultTemplates(): Promise { + try { + const templates = [ + { + type: 'new_like', + title: 'Новый лайк!', + message_template: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'super_like', + title: 'Супер-лайк!', + message_template: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'new_match', + title: 'Новый матч!', + message_template: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'new_message', + title: 'Новое сообщение!', + message_template: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + button_template: { + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'match_reminder', + title: 'Напоминание о матче', + message_template: '💕 У вас есть матч с *{{name}}*, но вы еще не начали общение!\n\nНе упустите шанс познакомиться поближе!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }] + ] + } + }, + { + type: 'inactive_matches', + title: 'Неактивные матчи', + message_template: '⏰ У вас {{count}} неактивных матчей!\n\nПродолжите общение, чтобы не упустить интересные знакомства!', + button_template: { + inline_keyboard: [ + [{ text: '📋 Открыть матчи', callback_data: 'view_matches' }], + [{ text: '💕 Смотреть новые анкеты', callback_data: 'start_browsing' }] + ] + } + }, + { + type: 'like_summary', + title: 'Сводка лайков', + message_template: '💖 У вас {{count}} новых лайков!\n\nПосмотрите, кто проявил к вам интерес сегодня!', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть лайки', callback_data: 'view_likes' }], + [{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }] + ] + } + } + ]; + + for (const template of templates) { + await query(` + INSERT INTO notification_templates (type, title, message_template, button_template) + VALUES ($1, $2, $3, $4) + ON CONFLICT (type) DO UPDATE + SET title = EXCLUDED.title, + message_template = EXCLUDED.message_template, + button_template = EXCLUDED.button_template, + updated_at = NOW() + `, [ + template.type, + template.title, + template.message_template, + JSON.stringify(template.button_template) + ]); + } + + console.log('Populated notification templates'); + } catch (error) { + console.error('Error populating default templates:', error); + } } // Получить шаблон уведомления из базы данных или использовать встроенный @@ -78,10 +320,10 @@ export class NotificationService { messageTemplate: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', buttonTemplate: { inline_keyboard: [ - [{ text: '💬 Начать общение', callback_data: 'open_native_chat_{{matchId}}' }], + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], [ { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, - { text: '📋 Все матчи', callback_data: 'native_chats' } + { text: '📋 Все матчи', callback_data: 'view_matches' } ] ] } @@ -91,13 +333,23 @@ export class NotificationService { messageTemplate: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', buttonTemplate: { inline_keyboard: [ - [{ text: '📩 Ответить', callback_data: 'open_native_chat_{{matchId}}' }], + [{ text: '📩 Ответить', callback_data: 'open_chat:{{matchId}}' }], [ { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, - { text: '📋 Все чаты', callback_data: 'native_chats' } + { text: '📋 Все чаты', callback_data: 'view_matches' } ] ] } + }, + 'match_reminder': { + title: 'Напоминание о матче', + messageTemplate: '💕 У вас есть матч с *{{name}}*, но вы еще не начали общение!\n\nНе упустите шанс познакомиться поближе!', + buttonTemplate: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }] + ] + } } }; @@ -138,6 +390,44 @@ export class NotificationService { return replaceInObject(result); } + // Проверка режима "Не беспокоить" + private async shouldSendNotification(userId: string): Promise { + try { + const settings = await this.getNotificationSettings(userId); + + if (!settings.doNotDisturb) { + return true; // Режим "Не беспокоить" выключен + } + + // Если нет указанного времени, по умолчанию: не беспокоить с 23:00 до 8:00 + const startTime = settings.doNotDisturbStart || '23:00'; + const endTime = settings.doNotDisturbEnd || '08:00'; + + const now = new Date(); + const currentHour = now.getHours(); + const currentMinute = now.getMinutes(); + + const [startHour, startMinute] = startTime.split(':').map(Number); + const [endHour, endMinute] = endTime.split(':').map(Number); + + const currentMinutes = currentHour * 60 + currentMinute; + const startMinutes = startHour * 60 + startMinute; + const endMinutes = endHour * 60 + endMinute; + + // Если время окончания меньше времени начала, значит период охватывает полночь + if (endMinutes < startMinutes) { + // Не отправлять, если текущее время между начальным и конечным временем (например, 23:30-07:00) + return !(currentMinutes >= startMinutes || currentMinutes <= endMinutes); + } else { + // Не отправлять, если текущее время между начальным и конечным временем (например, 13:00-15:00) + return !(currentMinutes >= startMinutes && currentMinutes <= endMinutes); + } + } catch (error) { + console.error('Error checking notification settings:', error); + return true; // В случае ошибки отправляем уведомление + } + } + // Отправить уведомление о новом лайке async sendLikeNotification(targetTelegramId: string, likerTelegramId: string, isSuperLike: boolean = false): Promise { try { @@ -146,7 +436,51 @@ export class NotificationService { this.profileService.getProfileByTelegramId(likerTelegramId) ]); - if (!targetUser || !likerProfile || !this.bot) { + if (!targetUser || !likerProfile) { + console.log(`Couldn't send like notification: user or profile not found`); + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(targetUser.id); + if (!settings.newLikes) { + console.log(`Like notifications disabled for user ${targetUser.id}`); + + // Логируем уведомление для истории + await this.logNotification({ + userId: targetUser.id, + type: isSuperLike ? 'super_like' : 'new_like', + data: { + likerUserId: likerProfile.userId, + likerName: likerProfile.name, + age: likerProfile.age, + city: likerProfile.city + } + }); + + // Запланируем отправку сводки лайков позже + await this.scheduleLikesSummary(targetUser.id); + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(targetUser.id))) { + console.log(`Do not disturb mode active for user ${targetUser.id}`); + + // Логируем уведомление и запланируем отправку позже + await this.logNotification({ + userId: targetUser.id, + type: isSuperLike ? 'super_like' : 'new_like', + data: { + likerUserId: likerProfile.userId, + likerName: likerProfile.name, + age: likerProfile.age, + city: likerProfile.city + } + }); + + // Запланируем отправку сводки лайков позже + await this.scheduleLikesSummary(targetUser.id); return; } @@ -169,10 +503,13 @@ export class NotificationService { const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); // Отправляем уведомление - await this.bot.sendMessage(targetUser.telegram_id, message, { - parse_mode: 'Markdown', - reply_markup: keyboard - }); + if (this.bot) { + await this.bot.sendMessage(targetUser.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + console.log(`Sent ${templateType} notification to user ${targetUser.id}`); + } // Логируем уведомление await this.logNotification({ @@ -198,7 +535,48 @@ export class NotificationService { this.profileService.getProfileByUserId(matchedUserId) ]); - if (!user || !matchedProfile || !this.bot) { + if (!user || !matchedProfile) { + console.log(`Couldn't send match notification: user or profile not found`); + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.newMatches) { + console.log(`Match notifications disabled for user ${userId}`); + + // Логируем уведомление для истории + await this.logNotification({ + userId, + type: 'new_match', + data: { + matchedUserId, + matchedName: matchedProfile.name, + age: matchedProfile.age, + city: matchedProfile.city + } + }); + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { + console.log(`Do not disturb mode active for user ${userId}`); + + // Логируем уведомление + await this.logNotification({ + userId, + type: 'new_match', + data: { + matchedUserId, + matchedName: matchedProfile.name, + age: matchedProfile.age, + city: matchedProfile.city + } + }); + + // Запланируем отправку напоминания о матче позже + await this.scheduleMatchReminder(userId, matchedUserId); return; } @@ -230,10 +608,13 @@ export class NotificationService { const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); // Отправляем уведомление - await this.bot.sendMessage(user.telegram_id, message, { - parse_mode: 'Markdown', - reply_markup: keyboard - }); + if (this.bot) { + await this.bot.sendMessage(user.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + console.log(`Sent new_match notification to user ${userId}`); + } // Логируем уведомление await this.logNotification({ @@ -247,6 +628,22 @@ export class NotificationService { matchId } }); + + // Если это новый матч, запланируем напоминание через 24 часа, если пользователь не начнет общение + if (matchId) { + const reminderDate = new Date(); + reminderDate.setHours(reminderDate.getHours() + 24); + + await this.scheduleNotification({ + userId, + type: 'match_reminder', + data: { + matchedUserId, + matchId + }, + scheduledAt: reminderDate + }); + } } catch (error) { console.error('Error sending match notification:', error); } @@ -260,15 +657,30 @@ export class NotificationService { this.profileService.getProfileByUserId(senderId) ]); - if (!receiver || !senderProfile || !this.bot) { + if (!receiver || !senderProfile) { + console.log(`Couldn't send message notification: user or profile not found`); + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(receiverId); + if (!settings.newMessages) { + console.log(`Message notifications disabled for user ${receiverId}`); return; } // Проверяем, не в чате ли пользователь сейчас const isUserActive = await this.isUserActiveInChat(receiverId, senderId); if (isUserActive) { + console.log(`User ${receiverId} is active in chat with ${senderId}, skipping notification`); return; // Не отправляем уведомление, если пользователь активен в чате } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(receiverId))) { + console.log(`Do not disturb mode active for user ${receiverId}`); + return; + } // Если matchId не передан, пытаемся его получить let actualMatchId = matchId; @@ -304,10 +716,13 @@ export class NotificationService { const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); // Отправляем уведомление - await this.bot.sendMessage(receiver.telegram_id, message, { - parse_mode: 'Markdown', - reply_markup: keyboard - }); + if (this.bot) { + await this.bot.sendMessage(receiver.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + console.log(`Sent new_message notification to user ${receiverId}`); + } // Логируем уведомление await this.logNotification({ @@ -325,66 +740,330 @@ export class NotificationService { } } - // Отправить напоминание о неактивности - async sendInactivityReminder(userId: string): Promise { + // Отправить напоминание о матче без сообщений + async sendMatchReminder(userId: string, matchedUserId: string, matchId: string): Promise { try { + const [user, matchedProfile] = await Promise.all([ + this.getUserByUserId(userId), + this.profileService.getProfileByUserId(matchedUserId) + ]); + + if (!user || !matchedProfile || !this.bot) { + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.reminders) { + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { + return; + } + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('match_reminder'); + + // Подготовка данных для шаблона + const templateData = { + name: matchedProfile.name, + userId: matchedProfile.userId, + matchId + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + await this.bot.sendMessage(user.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + + console.log(`Sent match reminder to user ${userId} about match with ${matchedUserId}`); + } catch (error) { + console.error('Error sending match reminder:', error); + } + } + + // Отправить сводку по неактивным матчам + async sendInactiveMatchesSummary(userId: string, count: number): Promise { + try { + const user = await this.getUserByUserId(userId); + if (!user || !this.bot || count === 0) { + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.reminders) { + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { + return; + } + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('inactive_matches'); + + // Подготовка данных для шаблона + const templateData = { + count: count.toString() + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + await this.bot.sendMessage(user.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + + console.log(`Sent inactive matches summary to user ${userId}, count: ${count}`); + } catch (error) { + console.error('Error sending inactive matches summary:', error); + } + } + + // Отправить сводку полученных лайков + async sendLikesSummary(userId: string): Promise { + try { + // Получаем количество непрочитанных лайков + const result = await query(` + SELECT COUNT(*) as count + FROM notifications + WHERE user_id = $1 AND is_read = false AND type IN ('new_like', 'super_like') + AND created_at > NOW() - INTERVAL '24 hours' + `, [userId]); + + const count = parseInt(result.rows[0].count); + if (count === 0) { + return; // Нет новых лайков для отображения + } + const user = await this.getUserByUserId(userId); if (!user || !this.bot) { return; } - const message = `👋 Давно не виделись!\n\nВозможно, ваш идеальный матч уже ждет. Давайте найдем кого-то особенного?`; - - await this.bot.sendMessage(user.telegram_id, message, { - reply_markup: { - inline_keyboard: [[ - { text: '💕 Начать знакомиться', callback_data: 'start_browsing' }, - { text: '⚙️ Настройки', callback_data: 'settings' } - ]] - } - }); - } catch (error) { - console.error('Error sending inactivity reminder:', error); - } - } - - // Отправить уведомление о новых лайках (сводка) - async sendLikesSummary(userId: string, likesCount: number): Promise { - try { - const user = await this.getUserByUserId(userId); - if (!user || !this.bot || likesCount === 0) { + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.dailySummary) { + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { return; } - const message = likesCount === 1 - ? `💖 У вас 1 новый лайк! Посмотрите, кто это может быть.` - : `💖 У вас ${likesCount} новых лайков! Посмотрите, кто проявил к вам интерес.`; - + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('like_summary'); + + // Подготовка данных для шаблона + const templateData = { + count: count.toString() + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление await this.bot.sendMessage(user.telegram_id, message, { - reply_markup: { - inline_keyboard: [[ - { text: '👀 Посмотреть лайки', callback_data: 'view_likes' }, - { text: '💕 Начать знакомиться', callback_data: 'start_browsing' } - ]] - } + parse_mode: 'Markdown', + reply_markup: keyboard }); + + // Отмечаем уведомления как прочитанные + await query(` + UPDATE notifications + SET is_read = true + WHERE user_id = $1 AND is_read = false AND type IN ('new_like', 'super_like') + AND created_at > NOW() - INTERVAL '24 hours' + `, [userId]); + + console.log(`Sent likes summary to user ${userId}, count: ${count}`); } catch (error) { console.error('Error sending likes summary:', error); } } + // Запланировать отправку сводки лайков + async scheduleLikesSummary(userId: string): Promise { + try { + // Определить предпочтительное время для отправки + const settings = await this.getNotificationSettings(userId); + const scheduledAt = this.getPreferredScheduleTime(settings.timePreference); + + // Проверяем, есть ли уже запланированная сводка лайков + const existingResult = await query(` + SELECT id FROM scheduled_notifications + WHERE user_id = $1 AND type = 'like_summary' AND processed = false + AND scheduled_at > NOW() + `, [userId]); + + if (existingResult.rows.length > 0) { + console.log(`Like summary already scheduled for user ${userId}`); + return; // Уже есть запланированная сводка + } + + // Запланировать отправку сводки + await query(` + INSERT INTO scheduled_notifications (id, user_id, type, scheduled_at) + VALUES ($1, $2, $3, $4) + `, [uuidv4(), userId, 'like_summary', scheduledAt]); + + console.log(`Scheduled likes summary for user ${userId} at ${scheduledAt}`); + } catch (error) { + console.error('Error scheduling likes summary:', error); + } + } + + // Запланировать напоминание о матче + async scheduleMatchReminder(userId: string, matchedUserId: string): Promise { + try { + // Получаем матч-ID для перехода в чат + const matchResult = await query(` + SELECT id FROM matches + WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1) + AND is_active = true + `, [userId, matchedUserId]); + + const matchId = matchResult.rows[0]?.id; + if (!matchId) { + console.log(`No active match found between users ${userId} and ${matchedUserId}`); + return; + } + + // Определить предпочтительное время для отправки + const settings = await this.getNotificationSettings(userId); + const scheduledAt = this.getPreferredScheduleTime(settings.timePreference); + + // Запланировать напоминание + await query(` + INSERT INTO scheduled_notifications (id, user_id, type, data, scheduled_at) + VALUES ($1, $2, $3, $4, $5) + `, [ + uuidv4(), + userId, + 'match_reminder', + JSON.stringify({ matchId, matchedUserId }), + scheduledAt + ]); + + console.log(`Scheduled match reminder for user ${userId} about match with ${matchedUserId} at ${scheduledAt}`); + } catch (error) { + console.error('Error scheduling match reminder:', error); + } + } + + // Получить предпочтительное время для отправки уведомлений + private getPreferredScheduleTime(preference: string): Date { + const now = new Date(); + const scheduledAt = new Date(); + + // Если текущее время после полуночи, планируем на сегодня + // Иначе планируем на следующий день + if (now.getHours() < 12) { + // Сегодня + switch (preference) { + case 'morning': + scheduledAt.setHours(9, 0, 0, 0); + break; + case 'afternoon': + scheduledAt.setHours(13, 0, 0, 0); + break; + case 'evening': + scheduledAt.setHours(19, 0, 0, 0); + break; + case 'night': + scheduledAt.setHours(22, 0, 0, 0); + break; + default: + scheduledAt.setHours(19, 0, 0, 0); // По умолчанию вечер + } + } else { + // Завтра + scheduledAt.setDate(scheduledAt.getDate() + 1); + switch (preference) { + case 'morning': + scheduledAt.setHours(9, 0, 0, 0); + break; + case 'afternoon': + scheduledAt.setHours(13, 0, 0, 0); + break; + case 'evening': + scheduledAt.setHours(19, 0, 0, 0); + break; + case 'night': + scheduledAt.setHours(22, 0, 0, 0); + break; + default: + scheduledAt.setHours(9, 0, 0, 0); // По умолчанию утро + } + } + + // Если запланированное время уже прошло, добавляем еще один день + if (scheduledAt <= now) { + scheduledAt.setDate(scheduledAt.getDate() + 1); + } + + return scheduledAt; + } + + // Запланировать уведомление + async scheduleNotification(notificationData: NotificationData): Promise { + try { + if (!notificationData.scheduledAt) { + notificationData.scheduledAt = new Date(); + } + + await query(` + INSERT INTO scheduled_notifications (id, user_id, type, data, scheduled_at) + VALUES ($1, $2, $3, $4, $5) + `, [ + uuidv4(), + notificationData.userId, + notificationData.type, + JSON.stringify(notificationData.data), + notificationData.scheduledAt + ]); + + console.log(`Scheduled ${notificationData.type} notification for user ${notificationData.userId} at ${notificationData.scheduledAt}`); + } catch (error) { + console.error('Error scheduling notification:', error); + } + } + // Логирование уведомлений private async logNotification(notificationData: NotificationData): Promise { try { await query(` - INSERT INTO notifications (user_id, type, data, created_at) - VALUES ($1, $2, $3, $4) + INSERT INTO notifications (id, user_id, type, data, created_at) + VALUES ($1, $2, $3, $4, $5) `, [ + uuidv4(), notificationData.userId, notificationData.type, JSON.stringify(notificationData.data), new Date() ]); + + console.log(`Logged ${notificationData.type} notification for user ${notificationData.userId}`); } catch (error) { console.error('Error logging notification:', error); } @@ -441,53 +1120,16 @@ export class NotificationService { const now = new Date(); const hoursSinceLastMessage = (now.getTime() - lastMessageTime.getTime()) / (1000 * 60 * 60); - // Считаем активным если последнее сообщение было менее 24 часов назад - return hoursSinceLastMessage < 24; + // Считаем активным если последнее сообщение было менее 10 минут назад + return hoursSinceLastMessage < (10 / 60); } catch (error) { console.error('Error checking user activity:', error); return false; } } - // Отправить пуш-уведомление (для будущего использования) - async sendPushNotification(userId: string, title: string, body: string, data?: any): Promise { - try { - // Логируем уведомление - console.log(`📱 Push notification prepared for user ${userId}:`); - console.log(`📋 Title: ${title}`); - console.log(`💬 Body: ${body}`); - if (data) { - console.log(`📊 Data:`, JSON.stringify(data, null, 2)); - } - - // В будущем здесь будет интеграция с Firebase Cloud Messaging - // или другим сервисом пуш-уведомлений: - /* - const message = { - notification: { - title, - body - }, - data: data ? JSON.stringify(data) : undefined, - token: await this.getUserPushToken(userId) - }; - - await admin.messaging().send(message); - console.log(`✅ Push notification sent to user ${userId}`); - */ - - } catch (error) { - console.error(`❌ Error preparing push notification for user ${userId}:`, error); - } - } - // Получить настройки уведомлений пользователя - async getNotificationSettings(userId: string): Promise<{ - newMatches: boolean; - newMessages: boolean; - newLikes: boolean; - reminders: boolean; - }> { + async getNotificationSettings(userId: string): Promise { try { const result = await query( 'SELECT notification_settings FROM users WHERE id = $1', @@ -499,15 +1141,26 @@ export class NotificationService { newMatches: true, newMessages: true, newLikes: true, - reminders: true + reminders: true, + dailySummary: true, + timePreference: 'evening', + doNotDisturb: false }; } - return result.rows[0].notification_settings || { - newMatches: true, - newMessages: true, - newLikes: true, - reminders: true + const settings = result.rows[0].notification_settings || {}; + + // Возвращаем настройки с дефолтными значениями для отсутствующих свойств + return { + newMatches: settings.newMatches !== undefined ? settings.newMatches : true, + newMessages: settings.newMessages !== undefined ? settings.newMessages : true, + newLikes: settings.newLikes !== undefined ? settings.newLikes : true, + reminders: settings.reminders !== undefined ? settings.reminders : true, + dailySummary: settings.dailySummary !== undefined ? settings.dailySummary : true, + timePreference: settings.timePreference || 'evening', + doNotDisturb: settings.doNotDisturb || false, + doNotDisturbStart: settings.doNotDisturbStart, + doNotDisturbEnd: settings.doNotDisturbEnd }; } catch (error) { console.error('Error getting notification settings:', error); @@ -515,23 +1168,32 @@ export class NotificationService { newMatches: true, newMessages: true, newLikes: true, - reminders: true + reminders: true, + dailySummary: true, + timePreference: 'evening', + doNotDisturb: false }; } } // Обновить настройки уведомлений - async updateNotificationSettings(userId: string, settings: { - newMatches?: boolean; - newMessages?: boolean; - newLikes?: boolean; - reminders?: boolean; - }): Promise { + async updateNotificationSettings(userId: string, settings: Partial): Promise { try { + // Получаем текущие настройки + const currentSettings = await this.getNotificationSettings(userId); + + // Обновляем только переданные поля + const updatedSettings = { + ...currentSettings, + ...settings + }; + await query( 'UPDATE users SET notification_settings = $1 WHERE id = $2', - [JSON.stringify(settings), userId] + [JSON.stringify(updatedSettings), userId] ); + + console.log(`Updated notification settings for user ${userId}`); } catch (error) { console.error('Error updating notification settings:', error); } @@ -540,29 +1202,6 @@ export class NotificationService { // Планировщик уведомлений (вызывается периодически) async processScheduledNotifications(): Promise { try { - // Проверим, существует ли таблица scheduled_notifications - const tableCheck = await query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_name = 'scheduled_notifications' - ) as exists - `); - - if (!tableCheck.rows[0].exists) { - // Если таблицы нет, создаем её - await query(` - CREATE TABLE IF NOT EXISTS 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() - ) - `); - } - // Получаем запланированные уведомления const result = await query(` SELECT * FROM scheduled_notifications @@ -571,17 +1210,49 @@ export class NotificationService { LIMIT 100 `, [new Date()]); + console.log(`Processing ${result.rows.length} scheduled notifications`); + for (const notification of result.rows) { try { + const data = notification.data || {}; + switch (notification.type) { - case 'inactivity_reminder': - await this.sendInactivityReminder(notification.user_id); + case 'match_reminder': + if (data.matchId && data.matchedUserId) { + // Проверяем, что матч всё еще активен и нет сообщений + const matchCheck = await query(` + SELECT m.id + FROM matches m + LEFT JOIN messages msg ON msg.match_id = m.id + WHERE m.id = $1 AND m.is_active = true + AND msg.id IS NULL + `, [data.matchId]); + + if (matchCheck.rows.length > 0) { + await this.sendMatchReminder(notification.user_id, data.matchedUserId, data.matchId); + } + } break; - case 'likes_summary': - const likesCount = notification.data?.likesCount || 0; - await this.sendLikesSummary(notification.user_id, likesCount); + case 'like_summary': + await this.sendLikesSummary(notification.user_id); + break; + case 'inactive_matches': + // Получаем количество неактивных матчей + const matchesResult = await query(` + SELECT COUNT(*) as count + FROM matches m + LEFT JOIN messages msg ON msg.match_id = m.id + WHERE (m.user_id_1 = $1 OR m.user_id_2 = $1) + AND m.is_active = true + AND msg.id IS NULL + AND m.created_at < NOW() - INTERVAL '3 days' + `, [notification.user_id]); + + const count = parseInt(matchesResult.rows[0].count); + if (count > 0) { + await this.sendInactiveMatchesSummary(notification.user_id, count); + } break; - // Добавить другие типы уведомлений } // Отмечаем как обработанное @@ -597,4 +1268,49 @@ export class NotificationService { console.error('Error processing scheduled notifications:', error); } } + + // Планирование периодических уведомлений для всех пользователей + async schedulePeriodicNotifications(): Promise { + try { + // Получаем список активных пользователей + const usersResult = await query(` + SELECT u.id, u.notification_settings + FROM users u + JOIN profiles p ON u.id = p.user_id + WHERE p.is_visible = true + AND u.created_at < NOW() - INTERVAL '1 day' + `); + + for (const user of usersResult.rows) { + try { + const settings = user.notification_settings || {}; + + // Проверяем настройку ежедневных сводок + if (settings.dailySummary !== false) { + // Планируем сводку лайков + await this.scheduleLikesSummary(user.id); + + // Планируем проверку неактивных матчей раз в неделю + if (settings.reminders !== false) { + const dayOfWeek = new Date().getDay(); + if (dayOfWeek === 1) { // Понедельник + const scheduledAt = this.getPreferredScheduleTime(settings.timePreference || 'evening'); + + await this.scheduleNotification({ + userId: user.id, + type: 'inactive_matches', + data: {}, + scheduledAt + }); + } + } + } + } catch (error) { + console.error(`Error scheduling periodic notifications for user ${user.id}:`, error); + } + } + } catch (error) { + console.error('Error scheduling periodic notifications:', error); + } + } } diff --git a/src/services/notificationService.ts.new b/src/services/notificationService.ts.new new file mode 100644 index 0000000..833c86c --- /dev/null +++ b/src/services/notificationService.ts.new @@ -0,0 +1,1316 @@ +import TelegramBot from 'node-telegram-bot-api'; +import { query, transaction } from '../database/connection'; +import { ProfileService } from './profileService'; +import { v4 as uuidv4 } from 'uuid'; + +export interface NotificationData { + userId: string; + type: 'new_match' | 'new_message' | 'new_like' | 'super_like' | 'match_reminder' | 'inactive_matches' | 'like_summary'; + data: Record; + scheduledAt?: Date; +} + +export interface NotificationSettings { + newMatches: boolean; + newMessages: boolean; + newLikes: boolean; + reminders: boolean; + dailySummary: boolean; + timePreference: 'morning' | 'afternoon' | 'evening' | 'night'; + doNotDisturb: boolean; + doNotDisturbStart?: string; // Формат: "HH:MM" + doNotDisturbEnd?: string; // Формат: "HH:MM" +} + +export class NotificationService { + private bot?: TelegramBot; + private profileService: ProfileService; + + constructor(bot?: TelegramBot) { + this.bot = bot; + this.profileService = new ProfileService(); + + // Создаем таблицу уведомлений, если её еще нет + this.ensureNotificationTablesExist().catch(err => + console.error('Failed to create notification tables:', err) + ); + } + + // Проверка и создание таблиц для уведомлений + private async ensureNotificationTablesExist(): Promise { + try { + // Проверяем существование таблицы notifications + const notificationsExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'notifications' + ) as exists + `); + + if (!notificationsExists.rows[0].exists) { + await query(` + CREATE TABLE notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('Created notifications table'); + } + + // Проверяем существование таблицы scheduled_notifications + const scheduledExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'scheduled_notifications' + ) as exists + `); + + if (!scheduledExists.rows[0].exists) { + await query(` + CREATE TABLE scheduled_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + data JSONB, + scheduled_at TIMESTAMP NOT NULL, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('Created scheduled_notifications table'); + } + + // Проверяем существование таблицы notification_templates + const templatesExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'notification_templates' + ) as exists + `); + + if (!templatesExists.rows[0].exists) { + await query(` + CREATE TABLE notification_templates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + type VARCHAR(50) NOT NULL UNIQUE, + title TEXT NOT NULL, + message_template TEXT NOT NULL, + button_template JSONB, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + `); + console.log('Created notification_templates table'); + + // Заполняем таблицу базовыми шаблонами + await this.populateDefaultTemplates(); + } + + // Проверяем, есть ли колонка notification_settings в таблице users + const settingsColumnExists = await query(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'notification_settings' + ) as exists + `); + + if (!settingsColumnExists.rows[0].exists) { + await query(` + ALTER TABLE users + ADD COLUMN notification_settings JSONB DEFAULT '{ + "newMatches": true, + "newMessages": true, + "newLikes": true, + "reminders": true, + "dailySummary": true, + "timePreference": "evening", + "doNotDisturb": false + }'::jsonb + `); + console.log('Added notification_settings column to users table'); + } + + } catch (error) { + console.error('Error ensuring notification tables exist:', error); + throw error; + } + } + + // Заполнение таблицы шаблонов уведомлений + private async populateDefaultTemplates(): Promise { + try { + const templates = [ + { + type: 'new_like', + title: 'Новый лайк!', + message_template: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'super_like', + title: 'Супер-лайк!', + message_template: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + { + type: 'new_match', + title: 'Новый матч!', + message_template: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'new_message', + title: 'Новое сообщение!', + message_template: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + button_template: { + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'view_matches' } + ] + ] + } + }, + { + type: 'match_reminder', + title: 'Напоминание о матче', + message_template: '💕 У вас есть матч с *{{name}}*, но вы еще не начали общение!\n\nНе упустите шанс познакомиться поближе!', + button_template: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }] + ] + } + }, + { + type: 'inactive_matches', + title: 'Неактивные матчи', + message_template: '⏰ У вас {{count}} неактивных матчей!\n\nПродолжите общение, чтобы не упустить интересные знакомства!', + button_template: { + inline_keyboard: [ + [{ text: '📋 Открыть матчи', callback_data: 'view_matches' }], + [{ text: '💕 Смотреть новые анкеты', callback_data: 'start_browsing' }] + ] + } + }, + { + type: 'like_summary', + title: 'Сводка лайков', + message_template: '💖 У вас {{count}} новых лайков!\n\nПосмотрите, кто проявил к вам интерес сегодня!', + button_template: { + inline_keyboard: [ + [{ text: '👀 Посмотреть лайки', callback_data: 'view_likes' }], + [{ text: '💕 Начать знакомиться', callback_data: 'start_browsing' }] + ] + } + } + ]; + + for (const template of templates) { + await query(` + INSERT INTO notification_templates (type, title, message_template, button_template) + VALUES ($1, $2, $3, $4) + ON CONFLICT (type) DO UPDATE + SET title = EXCLUDED.title, + message_template = EXCLUDED.message_template, + button_template = EXCLUDED.button_template, + updated_at = NOW() + `, [ + template.type, + template.title, + template.message_template, + JSON.stringify(template.button_template) + ]); + } + + console.log('Populated notification templates'); + } catch (error) { + console.error('Error populating default templates:', error); + } + } + + // Получить шаблон уведомления из базы данных или использовать встроенный + private async getNotificationTemplate(type: string): Promise<{ + title: string; + messageTemplate: string; + buttonTemplate: any; + }> { + try { + // Попытка получить шаблон из базы данных + const result = await query(` + SELECT title, message_template, button_template + FROM notification_templates + WHERE type = $1 + `, [type]); + + if (result.rows.length > 0) { + return { + title: result.rows[0].title, + messageTemplate: result.rows[0].message_template, + buttonTemplate: result.rows[0].button_template + }; + } + } catch (error: any) { + console.log('Using default template as database is not available:', error.message); + } + + // Если не удалось получить из базы или произошла ошибка, используем встроенные шаблоны + const defaultTemplates: Record = { + 'new_like': { + title: 'Новый лайк!', + messageTemplate: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', + buttonTemplate: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + 'super_like': { + title: 'Супер-лайк!', + messageTemplate: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', + buttonTemplate: { + inline_keyboard: [ + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], + [ + { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, + { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } + ], + [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] + ] + } + }, + 'new_match': { + title: 'Новый матч!', + messageTemplate: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', + buttonTemplate: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все матчи', callback_data: 'view_matches' } + ] + ] + } + }, + 'new_message': { + title: 'Новое сообщение!', + messageTemplate: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', + buttonTemplate: { + inline_keyboard: [ + [{ text: '📩 Ответить', callback_data: 'open_chat:{{matchId}}' }], + [ + { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, + { text: '📋 Все чаты', callback_data: 'view_matches' } + ] + ] + } + }, + 'match_reminder': { + title: 'Напоминание о матче', + messageTemplate: '💕 У вас есть матч с *{{name}}*, но вы еще не начали общение!\n\nНе упустите шанс познакомиться поближе!', + buttonTemplate: { + inline_keyboard: [ + [{ text: '💬 Начать общение', callback_data: 'open_chat:{{matchId}}' }], + [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }] + ] + } + } + }; + + return defaultTemplates[type] || { + title: 'Уведомление', + messageTemplate: 'Новое уведомление', + buttonTemplate: { inline_keyboard: [] } + }; + } + + // Применить данные к шаблону + private applyTemplateData(template: string, data: Record): string { + return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => { + return data[key] !== undefined ? data[key] : ''; + }); + } + + // Применить данные к шаблону кнопок + private applyTemplateDataToButtons(buttonTemplate: any, data: Record): any { + const result = JSON.parse(JSON.stringify(buttonTemplate)); // глубокая копия + + // Рекурсивная функция для замены в любой вложенности + const replaceInObject = (obj: any): any => { + if (typeof obj === 'string') { + return this.applyTemplateData(obj, data); + } else if (Array.isArray(obj)) { + return obj.map(item => replaceInObject(item)); + } else if (obj !== null && typeof obj === 'object') { + const newObj: Record = {}; + for (const key in obj) { + newObj[key] = replaceInObject(obj[key]); + } + return newObj; + } + return obj; + }; + + return replaceInObject(result); + } + + // Проверка режима "Не беспокоить" + private async shouldSendNotification(userId: string): Promise { + try { + const settings = await this.getNotificationSettings(userId); + + if (!settings.doNotDisturb) { + return true; // Режим "Не беспокоить" выключен + } + + // Если нет указанного времени, по умолчанию: не беспокоить с 23:00 до 8:00 + const startTime = settings.doNotDisturbStart || '23:00'; + const endTime = settings.doNotDisturbEnd || '08:00'; + + const now = new Date(); + const currentHour = now.getHours(); + const currentMinute = now.getMinutes(); + + const [startHour, startMinute] = startTime.split(':').map(Number); + const [endHour, endMinute] = endTime.split(':').map(Number); + + const currentMinutes = currentHour * 60 + currentMinute; + const startMinutes = startHour * 60 + startMinute; + const endMinutes = endHour * 60 + endMinute; + + // Если время окончания меньше времени начала, значит период охватывает полночь + if (endMinutes < startMinutes) { + // Не отправлять, если текущее время между начальным и конечным временем (например, 23:30-07:00) + return !(currentMinutes >= startMinutes || currentMinutes <= endMinutes); + } else { + // Не отправлять, если текущее время между начальным и конечным временем (например, 13:00-15:00) + return !(currentMinutes >= startMinutes && currentMinutes <= endMinutes); + } + } catch (error) { + console.error('Error checking notification settings:', error); + return true; // В случае ошибки отправляем уведомление + } + } + + // Отправить уведомление о новом лайке + async sendLikeNotification(targetTelegramId: string, likerTelegramId: string, isSuperLike: boolean = false): Promise { + try { + const [targetUser, likerProfile] = await Promise.all([ + this.getUserByTelegramId(targetTelegramId), + this.profileService.getProfileByTelegramId(likerTelegramId) + ]); + + if (!targetUser || !likerProfile) { + console.log(`Couldn't send like notification: user or profile not found`); + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(targetUser.id); + if (!settings.newLikes) { + console.log(`Like notifications disabled for user ${targetUser.id}`); + + // Логируем уведомление для истории + await this.logNotification({ + userId: targetUser.id, + type: isSuperLike ? 'super_like' : 'new_like', + data: { + likerUserId: likerProfile.userId, + likerName: likerProfile.name, + age: likerProfile.age, + city: likerProfile.city + } + }); + + // Запланируем отправку сводки лайков позже + await this.scheduleLikesSummary(targetUser.id); + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(targetUser.id))) { + console.log(`Do not disturb mode active for user ${targetUser.id}`); + + // Логируем уведомление и запланируем отправку позже + await this.logNotification({ + userId: targetUser.id, + type: isSuperLike ? 'super_like' : 'new_like', + data: { + likerUserId: likerProfile.userId, + likerName: likerProfile.name, + age: likerProfile.age, + city: likerProfile.city + } + }); + + // Запланируем отправку сводки лайков позже + await this.scheduleLikesSummary(targetUser.id); + return; + } + + // Получаем шаблон уведомления + const templateType = isSuperLike ? 'super_like' : 'new_like'; + const template = await this.getNotificationTemplate(templateType); + + // Подготовка данных для шаблона + const templateData = { + name: likerProfile.name, + age: likerProfile.age.toString(), + city: likerProfile.city || '', + userId: likerProfile.userId + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + if (this.bot) { + await this.bot.sendMessage(targetUser.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + console.log(`Sent ${templateType} notification to user ${targetUser.id}`); + } + + // Логируем уведомление + await this.logNotification({ + userId: targetUser.id, + type: templateType, + data: { + likerUserId: likerProfile.userId, + likerName: likerProfile.name, + age: likerProfile.age, + city: likerProfile.city + } + }); + } catch (error) { + console.error('Error sending like notification:', error); + } + } + + // Отправить уведомление о новом матче + async sendMatchNotification(userId: string, matchedUserId: string): Promise { + try { + const [user, matchedProfile] = await Promise.all([ + this.getUserByUserId(userId), + this.profileService.getProfileByUserId(matchedUserId) + ]); + + if (!user || !matchedProfile) { + console.log(`Couldn't send match notification: user or profile not found`); + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.newMatches) { + console.log(`Match notifications disabled for user ${userId}`); + + // Логируем уведомление для истории + await this.logNotification({ + userId, + type: 'new_match', + data: { + matchedUserId, + matchedName: matchedProfile.name, + age: matchedProfile.age, + city: matchedProfile.city + } + }); + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { + console.log(`Do not disturb mode active for user ${userId}`); + + // Логируем уведомление + await this.logNotification({ + userId, + type: 'new_match', + data: { + matchedUserId, + matchedName: matchedProfile.name, + age: matchedProfile.age, + city: matchedProfile.city + } + }); + + // Запланируем отправку напоминания о матче позже + await this.scheduleMatchReminder(userId, matchedUserId); + return; + } + + // Получаем матч-ID для перехода в чат + const matchResult = await query(` + SELECT id FROM matches + WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1) + AND is_active = true + `, [userId, matchedUserId]); + + const matchId = matchResult.rows[0]?.id; + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('new_match'); + + // Подготовка данных для шаблона + const templateData = { + name: matchedProfile.name, + age: matchedProfile.age.toString(), + city: matchedProfile.city || '', + userId: matchedProfile.userId, + matchId: matchId || '' + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + if (this.bot) { + await this.bot.sendMessage(user.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + console.log(`Sent new_match notification to user ${userId}`); + } + + // Логируем уведомление + await this.logNotification({ + userId, + type: 'new_match', + data: { + matchedUserId, + matchedName: matchedProfile.name, + age: matchedProfile.age, + city: matchedProfile.city, + matchId + } + }); + + // Если это новый матч, запланируем напоминание через 24 часа, если пользователь не начнет общение + if (matchId) { + const reminderDate = new Date(); + reminderDate.setHours(reminderDate.getHours() + 24); + + await this.scheduleNotification({ + userId, + type: 'match_reminder', + data: { + matchedUserId, + matchId + }, + scheduledAt: reminderDate + }); + } + } catch (error) { + console.error('Error sending match notification:', error); + } + } + + // Отправить уведомление о новом сообщении + async sendMessageNotification(receiverId: string, senderId: string, messageContent: string, matchId?: string): Promise { + try { + const [receiver, senderProfile] = await Promise.all([ + this.getUserByUserId(receiverId), + this.profileService.getProfileByUserId(senderId) + ]); + + if (!receiver || !senderProfile) { + console.log(`Couldn't send message notification: user or profile not found`); + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(receiverId); + if (!settings.newMessages) { + console.log(`Message notifications disabled for user ${receiverId}`); + return; + } + + // Проверяем, не в чате ли пользователь сейчас + const isUserActive = await this.isUserActiveInChat(receiverId, senderId); + if (isUserActive) { + console.log(`User ${receiverId} is active in chat with ${senderId}, skipping notification`); + return; // Не отправляем уведомление, если пользователь активен в чате + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(receiverId))) { + console.log(`Do not disturb mode active for user ${receiverId}`); + return; + } + + // Если matchId не передан, пытаемся его получить + let actualMatchId = matchId; + if (!actualMatchId) { + const matchResult = await query(` + SELECT id FROM matches + WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1) + AND is_active = true + `, [receiverId, senderId]); + + actualMatchId = matchResult.rows[0]?.id; + } + + const truncatedMessage = messageContent.length > 50 + ? messageContent.substring(0, 50) + '...' + : messageContent; + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('new_message'); + + // Подготовка данных для шаблона + const templateData = { + name: senderProfile.name, + message: truncatedMessage, + userId: senderProfile.userId, + matchId: actualMatchId || '' + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + if (this.bot) { + await this.bot.sendMessage(receiver.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + console.log(`Sent new_message notification to user ${receiverId}`); + } + + // Логируем уведомление + await this.logNotification({ + userId: receiverId, + type: 'new_message', + data: { + senderId, + senderName: senderProfile.name, + messageContent: truncatedMessage, + matchId: actualMatchId + } + }); + } catch (error) { + console.error('Error sending message notification:', error); + } + } + + // Отправить напоминание о матче без сообщений + async sendMatchReminder(userId: string, matchedUserId: string, matchId: string): Promise { + try { + const [user, matchedProfile] = await Promise.all([ + this.getUserByUserId(userId), + this.profileService.getProfileByUserId(matchedUserId) + ]); + + if (!user || !matchedProfile || !this.bot) { + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.reminders) { + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { + return; + } + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('match_reminder'); + + // Подготовка данных для шаблона + const templateData = { + name: matchedProfile.name, + userId: matchedProfile.userId, + matchId + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + await this.bot.sendMessage(user.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + + console.log(`Sent match reminder to user ${userId} about match with ${matchedUserId}`); + } catch (error) { + console.error('Error sending match reminder:', error); + } + } + + // Отправить сводку по неактивным матчам + async sendInactiveMatchesSummary(userId: string, count: number): Promise { + try { + const user = await this.getUserByUserId(userId); + if (!user || !this.bot || count === 0) { + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.reminders) { + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { + return; + } + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('inactive_matches'); + + // Подготовка данных для шаблона + const templateData = { + count: count.toString() + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + await this.bot.sendMessage(user.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + + console.log(`Sent inactive matches summary to user ${userId}, count: ${count}`); + } catch (error) { + console.error('Error sending inactive matches summary:', error); + } + } + + // Отправить сводку полученных лайков + async sendLikesSummary(userId: string): Promise { + try { + // Получаем количество непрочитанных лайков + const result = await query(` + SELECT COUNT(*) as count + FROM notifications + WHERE user_id = $1 AND is_read = false AND type IN ('new_like', 'super_like') + AND created_at > NOW() - INTERVAL '24 hours' + `, [userId]); + + const count = parseInt(result.rows[0].count); + if (count === 0) { + return; // Нет новых лайков для отображения + } + + const user = await this.getUserByUserId(userId); + if (!user || !this.bot) { + return; + } + + // Проверяем настройки уведомлений пользователя + const settings = await this.getNotificationSettings(userId); + if (!settings.dailySummary) { + return; + } + + // Проверяем режим "Не беспокоить" + if (!(await this.shouldSendNotification(userId))) { + return; + } + + // Получаем шаблон уведомления + const template = await this.getNotificationTemplate('like_summary'); + + // Подготовка данных для шаблона + const templateData = { + count: count.toString() + }; + + // Применяем данные к шаблону сообщения + const message = this.applyTemplateData(template.messageTemplate, templateData); + + // Применяем данные к шаблону кнопок + const keyboard = this.applyTemplateDataToButtons(template.buttonTemplate, templateData); + + // Отправляем уведомление + await this.bot.sendMessage(user.telegram_id, message, { + parse_mode: 'Markdown', + reply_markup: keyboard + }); + + // Отмечаем уведомления как прочитанные + await query(` + UPDATE notifications + SET is_read = true + WHERE user_id = $1 AND is_read = false AND type IN ('new_like', 'super_like') + AND created_at > NOW() - INTERVAL '24 hours' + `, [userId]); + + console.log(`Sent likes summary to user ${userId}, count: ${count}`); + } catch (error) { + console.error('Error sending likes summary:', error); + } + } + + // Запланировать отправку сводки лайков + async scheduleLikesSummary(userId: string): Promise { + try { + // Определить предпочтительное время для отправки + const settings = await this.getNotificationSettings(userId); + const scheduledAt = this.getPreferredScheduleTime(settings.timePreference); + + // Проверяем, есть ли уже запланированная сводка лайков + const existingResult = await query(` + SELECT id FROM scheduled_notifications + WHERE user_id = $1 AND type = 'like_summary' AND processed = false + AND scheduled_at > NOW() + `, [userId]); + + if (existingResult.rows.length > 0) { + console.log(`Like summary already scheduled for user ${userId}`); + return; // Уже есть запланированная сводка + } + + // Запланировать отправку сводки + await query(` + INSERT INTO scheduled_notifications (id, user_id, type, scheduled_at) + VALUES ($1, $2, $3, $4) + `, [uuidv4(), userId, 'like_summary', scheduledAt]); + + console.log(`Scheduled likes summary for user ${userId} at ${scheduledAt}`); + } catch (error) { + console.error('Error scheduling likes summary:', error); + } + } + + // Запланировать напоминание о матче + async scheduleMatchReminder(userId: string, matchedUserId: string): Promise { + try { + // Получаем матч-ID для перехода в чат + const matchResult = await query(` + SELECT id FROM matches + WHERE (user_id_1 = $1 AND user_id_2 = $2) OR (user_id_1 = $2 AND user_id_2 = $1) + AND is_active = true + `, [userId, matchedUserId]); + + const matchId = matchResult.rows[0]?.id; + if (!matchId) { + console.log(`No active match found between users ${userId} and ${matchedUserId}`); + return; + } + + // Определить предпочтительное время для отправки + const settings = await this.getNotificationSettings(userId); + const scheduledAt = this.getPreferredScheduleTime(settings.timePreference); + + // Запланировать напоминание + await query(` + INSERT INTO scheduled_notifications (id, user_id, type, data, scheduled_at) + VALUES ($1, $2, $3, $4, $5) + `, [ + uuidv4(), + userId, + 'match_reminder', + JSON.stringify({ matchId, matchedUserId }), + scheduledAt + ]); + + console.log(`Scheduled match reminder for user ${userId} about match with ${matchedUserId} at ${scheduledAt}`); + } catch (error) { + console.error('Error scheduling match reminder:', error); + } + } + + // Получить предпочтительное время для отправки уведомлений + private getPreferredScheduleTime(preference: string): Date { + const now = new Date(); + const scheduledAt = new Date(); + + // Если текущее время после полуночи, планируем на сегодня + // Иначе планируем на следующий день + if (now.getHours() < 12) { + // Сегодня + switch (preference) { + case 'morning': + scheduledAt.setHours(9, 0, 0, 0); + break; + case 'afternoon': + scheduledAt.setHours(13, 0, 0, 0); + break; + case 'evening': + scheduledAt.setHours(19, 0, 0, 0); + break; + case 'night': + scheduledAt.setHours(22, 0, 0, 0); + break; + default: + scheduledAt.setHours(19, 0, 0, 0); // По умолчанию вечер + } + } else { + // Завтра + scheduledAt.setDate(scheduledAt.getDate() + 1); + switch (preference) { + case 'morning': + scheduledAt.setHours(9, 0, 0, 0); + break; + case 'afternoon': + scheduledAt.setHours(13, 0, 0, 0); + break; + case 'evening': + scheduledAt.setHours(19, 0, 0, 0); + break; + case 'night': + scheduledAt.setHours(22, 0, 0, 0); + break; + default: + scheduledAt.setHours(9, 0, 0, 0); // По умолчанию утро + } + } + + // Если запланированное время уже прошло, добавляем еще один день + if (scheduledAt <= now) { + scheduledAt.setDate(scheduledAt.getDate() + 1); + } + + return scheduledAt; + } + + // Запланировать уведомление + async scheduleNotification(notificationData: NotificationData): Promise { + try { + if (!notificationData.scheduledAt) { + notificationData.scheduledAt = new Date(); + } + + await query(` + INSERT INTO scheduled_notifications (id, user_id, type, data, scheduled_at) + VALUES ($1, $2, $3, $4, $5) + `, [ + uuidv4(), + notificationData.userId, + notificationData.type, + JSON.stringify(notificationData.data), + notificationData.scheduledAt + ]); + + console.log(`Scheduled ${notificationData.type} notification for user ${notificationData.userId} at ${notificationData.scheduledAt}`); + } catch (error) { + console.error('Error scheduling notification:', error); + } + } + + // Логирование уведомлений + private async logNotification(notificationData: NotificationData): Promise { + try { + await query(` + INSERT INTO notifications (id, user_id, type, data, created_at) + VALUES ($1, $2, $3, $4, $5) + `, [ + uuidv4(), + notificationData.userId, + notificationData.type, + JSON.stringify(notificationData.data), + new Date() + ]); + + console.log(`Logged ${notificationData.type} notification for user ${notificationData.userId}`); + } catch (error) { + console.error('Error logging notification:', error); + } + } + + // Получить пользователя по ID + private async getUserByUserId(userId: string): Promise { + try { + const result = await query( + 'SELECT * FROM users WHERE id = $1', + [userId] + ); + return result.rows[0] || null; + } catch (error) { + console.error('Error getting user:', error); + return null; + } + } + + // Получить пользователя по Telegram ID + private async getUserByTelegramId(telegramId: string): Promise { + try { + const result = await query( + 'SELECT * FROM users WHERE telegram_id = $1', + [parseInt(telegramId)] + ); + return result.rows[0] || null; + } catch (error) { + console.error('Error getting user by telegram ID:', error); + return null; + } + } + + // Проверить, активен ли пользователь в чате + private async isUserActiveInChat(userId: string, chatWithUserId: string): Promise { + try { + // Проверяем последнее сообщение пользователя в чате + const result = await query(` + SELECT m.created_at + FROM messages m + JOIN matches mt ON m.match_id = mt.id + WHERE (mt.user_id_1 = $1 OR mt.user_id_2 = $1) + AND (mt.user_id_1 = $2 OR mt.user_id_2 = $2) + AND m.sender_id = $1 + ORDER BY m.created_at DESC + LIMIT 1 + `, [userId, chatWithUserId]); + + if (result.rows.length === 0) { + return false; // Нет сообщений - не активен + } + + const lastMessageTime = new Date(result.rows[0].created_at); + const now = new Date(); + const hoursSinceLastMessage = (now.getTime() - lastMessageTime.getTime()) / (1000 * 60 * 60); + + // Считаем активным если последнее сообщение было менее 10 минут назад + return hoursSinceLastMessage < (10 / 60); + } catch (error) { + console.error('Error checking user activity:', error); + return false; + } + } + + // Получить настройки уведомлений пользователя + async getNotificationSettings(userId: string): Promise { + try { + const result = await query( + 'SELECT notification_settings FROM users WHERE id = $1', + [userId] + ); + + if (result.rows.length === 0) { + return { + newMatches: true, + newMessages: true, + newLikes: true, + reminders: true, + dailySummary: true, + timePreference: 'evening', + doNotDisturb: false + }; + } + + const settings = result.rows[0].notification_settings || {}; + + // Возвращаем настройки с дефолтными значениями для отсутствующих свойств + return { + newMatches: settings.newMatches !== undefined ? settings.newMatches : true, + newMessages: settings.newMessages !== undefined ? settings.newMessages : true, + newLikes: settings.newLikes !== undefined ? settings.newLikes : true, + reminders: settings.reminders !== undefined ? settings.reminders : true, + dailySummary: settings.dailySummary !== undefined ? settings.dailySummary : true, + timePreference: settings.timePreference || 'evening', + doNotDisturb: settings.doNotDisturb || false, + doNotDisturbStart: settings.doNotDisturbStart, + doNotDisturbEnd: settings.doNotDisturbEnd + }; + } catch (error) { + console.error('Error getting notification settings:', error); + return { + newMatches: true, + newMessages: true, + newLikes: true, + reminders: true, + dailySummary: true, + timePreference: 'evening', + doNotDisturb: false + }; + } + } + + // Обновить настройки уведомлений + async updateNotificationSettings(userId: string, settings: Partial): Promise { + try { + // Получаем текущие настройки + const currentSettings = await this.getNotificationSettings(userId); + + // Обновляем только переданные поля + const updatedSettings = { + ...currentSettings, + ...settings + }; + + await query( + 'UPDATE users SET notification_settings = $1 WHERE id = $2', + [JSON.stringify(updatedSettings), userId] + ); + + console.log(`Updated notification settings for user ${userId}`); + } catch (error) { + console.error('Error updating notification settings:', error); + } + } + + // Планировщик уведомлений (вызывается периодически) + async processScheduledNotifications(): Promise { + try { + // Получаем запланированные уведомления + const result = await query(` + SELECT * FROM scheduled_notifications + WHERE scheduled_at <= $1 AND processed = false + ORDER BY scheduled_at ASC + LIMIT 100 + `, [new Date()]); + + console.log(`Processing ${result.rows.length} scheduled notifications`); + + for (const notification of result.rows) { + try { + const data = notification.data || {}; + + switch (notification.type) { + case 'match_reminder': + if (data.matchId && data.matchedUserId) { + // Проверяем, что матч всё еще активен и нет сообщений + const matchCheck = await query(` + SELECT m.id + FROM matches m + LEFT JOIN messages msg ON msg.match_id = m.id + WHERE m.id = $1 AND m.is_active = true + AND msg.id IS NULL + `, [data.matchId]); + + if (matchCheck.rows.length > 0) { + await this.sendMatchReminder(notification.user_id, data.matchedUserId, data.matchId); + } + } + break; + case 'like_summary': + await this.sendLikesSummary(notification.user_id); + break; + case 'inactive_matches': + // Получаем количество неактивных матчей + const matchesResult = await query(` + SELECT COUNT(*) as count + FROM matches m + LEFT JOIN messages msg ON msg.match_id = m.id + WHERE (m.user_id_1 = $1 OR m.user_id_2 = $1) + AND m.is_active = true + AND msg.id IS NULL + AND m.created_at < NOW() - INTERVAL '3 days' + `, [notification.user_id]); + + const count = parseInt(matchesResult.rows[0].count); + if (count > 0) { + await this.sendInactiveMatchesSummary(notification.user_id, count); + } + break; + } + + // Отмечаем как обработанное + await query( + 'UPDATE scheduled_notifications SET processed = true WHERE id = $1', + [notification.id] + ); + } catch (error) { + console.error(`Error processing notification ${notification.id}:`, error); + } + } + } catch (error) { + console.error('Error processing scheduled notifications:', error); + } + } + + // Планирование периодических уведомлений для всех пользователей + async schedulePeriodicNotifications(): Promise { + try { + // Получаем список активных пользователей + const usersResult = await query(` + SELECT u.id, u.notification_settings + FROM users u + JOIN profiles p ON u.id = p.user_id + WHERE p.is_visible = true + AND u.created_at < NOW() - INTERVAL '1 day' + `); + + for (const user of usersResult.rows) { + try { + const settings = user.notification_settings || {}; + + // Проверяем настройку ежедневных сводок + if (settings.dailySummary !== false) { + // Планируем сводку лайков + await this.scheduleLikesSummary(user.id); + + // Планируем проверку неактивных матчей раз в неделю + if (settings.reminders !== false) { + const dayOfWeek = new Date().getDay(); + if (dayOfWeek === 1) { // Понедельник + const scheduledAt = this.getPreferredScheduleTime(settings.timePreference || 'evening'); + + await this.scheduleNotification({ + userId: user.id, + type: 'inactive_matches', + data: {}, + scheduledAt + }); + } + } + } + } catch (error) { + console.error(`Error scheduling periodic notifications for user ${user.id}:`, error); + } + } + } catch (error) { + console.error('Error scheduling periodic notifications:', error); + } + } +} diff --git a/src/services/vipService.ts b/src/services/vipService.ts index 397723e..3d209a4 100644 --- a/src/services/vipService.ts +++ b/src/services/vipService.ts @@ -24,9 +24,9 @@ export class VipService { // Проверить премиум статус пользователя async checkPremiumStatus(telegramId: string): Promise { try { - // Проверяем существование пользователя + // Проверяем существование пользователя и получаем его премиум статус const result = await query(` - SELECT id + SELECT id, premium FROM users WHERE telegram_id = $1 `, [telegramId]); @@ -35,12 +35,13 @@ export class VipService { throw new BotError('User not found', 'USER_NOT_FOUND', 404); } - // Временно возвращаем false для всех пользователей, так как колонки premium нет - // В будущем, когда колонки будут добавлены, этот код нужно будет заменить обратно + // Получаем актуальное значение премиум статуса из базы данных + const isPremium = result.rows[0].premium || false; + return { - isPremium: false, - expiresAt: undefined, - daysLeft: undefined + isPremium: isPremium, + expiresAt: undefined, // Пока не используем дату истечения + daysLeft: undefined // Пока не используем количество дней }; } catch (error) { console.error('Error checking premium status:', error); @@ -51,9 +52,17 @@ export class VipService { // Добавить премиум статус async addPremium(telegramId: string, durationDays: number = 30): Promise { try { - // Временно заглушка, так как колонок premium и premium_expires_at нет - console.log(`[VIP] Попытка добавить премиум для ${telegramId} на ${durationDays} дней`); - // TODO: Добавить колонки premium и premium_expires_at в таблицу users + console.log(`[VIP] Добавление премиум для ${telegramId} на ${durationDays} дней`); + + // Обновляем статус premium в базе данных + await query(` + UPDATE users + SET premium = true + WHERE telegram_id = $1 + RETURNING id, telegram_id, premium + `, [telegramId]); + + console.log(`[VIP] Премиум успешно добавлен для пользователя ${telegramId}`); } catch (error) { console.error('Error adding premium:', error); throw error; @@ -63,9 +72,17 @@ export class VipService { // Удалить премиум статус async removePremium(telegramId: string): Promise { try { - // Временно заглушка, так как колонок premium и premium_expires_at нет - console.log(`[VIP] Попытка удалить премиум для ${telegramId}`); - // TODO: Добавить колонки premium и premium_expires_at в таблицу users + console.log(`[VIP] Удаление премиум для ${telegramId}`); + + // Обновляем статус premium в базе данных + await query(` + UPDATE users + SET premium = false + WHERE telegram_id = $1 + RETURNING id, telegram_id, premium + `, [telegramId]); + + console.log(`[VIP] Премиум успешно удален для пользователя ${telegramId}`); } catch (error) { console.error('Error removing premium:', error); throw error; From 713eadc64305ff11158b0732322215e723f52e87 Mon Sep 17 00:00:00 2001 From: "Choi A.K." Date: Thu, 18 Sep 2025 14:19:49 +0900 Subject: [PATCH 4/4] pre-deploy commit --- .env.example | 50 ++- .env.production | 68 +++ Dockerfile | 25 +- README.md | 43 +- bin/backup_db.sh | 54 +++ bin/create_release.sh | 72 ++++ bin/install_docker.sh | 58 +++ check_schema.ts | 14 - deploy.sh | 55 +++ docker-compose.override.yml.example | 69 +++ docker-compose.yml | 29 +- docs/PRODUCTION_DEPLOYMENT.md | 264 ++++++++++++ fixes.md | 90 ---- init-notifications-db.js | 174 -------- profile_views_patch.ts | 153 ------- scripts/README.md | 93 ++-- scripts/{ => legacy}/checkCallbackHandlers.js | 0 scripts/{ => legacy}/checkDatabase.js | 0 scripts/{ => legacy}/checkProfileViews.js | 0 scripts/{ => legacy}/checkUserTable.js | 0 scripts/{ => legacy}/cleanDatabase.js | 0 scripts/{ => legacy}/clearDatabase.js | 0 scripts/{ => legacy}/clearDatabase.mjs | 0 scripts/{ => legacy}/clear_database.sql | 0 .../{ => legacy}/createProfileViewsTable.ts | 0 scripts/{ => legacy}/fixCallbackHandlers.js | 0 scripts/{ => legacy}/fixDatabaseStructure.js | 0 scripts/{ => legacy}/fix_all_notifications.js | 0 .../fix_notification_callbacks.js | 0 scripts/{ => legacy}/testCallbacks.js | 0 scripts/{ => legacy}/testMatching.js | 0 scripts/{ => legacy}/testProfileViews.js | 0 scripts/{ => legacy}/testVipMethod.js | 0 scripts/{ => legacy}/testVipStatus.js | 0 scripts/startup.sh | 16 + set-premium.js | Bin 1060 -> 0 bytes sql/consolidated.sql | 404 ++++++++++++++++++ src/database/migrateOnStartup.ts | 108 +++++ src/database/migrations/consolidated.sql | 182 ++++++++ src/premium/README.md | 62 +++ .../premium}/add-premium-columns-direct.js | 0 .../premium}/add-premium-columns.js | 0 .../premium}/add-premium-columns.ts | 0 {scripts => src/premium}/addPremiumColumn.js | 0 {scripts => src/premium}/setPremiumStatus.js | 0 start.bat | 241 +++++++++++ start.ps1 | 229 ++++++++++ start.sh | 217 ++++++++++ test-connection.js | 37 -- test-db.js | 0 50 files changed, 2238 insertions(+), 569 deletions(-) create mode 100644 .env.production create mode 100644 bin/backup_db.sh create mode 100644 bin/create_release.sh create mode 100644 bin/install_docker.sh delete mode 100644 check_schema.ts create mode 100644 deploy.sh create mode 100644 docker-compose.override.yml.example create mode 100644 docs/PRODUCTION_DEPLOYMENT.md delete mode 100644 fixes.md delete mode 100644 init-notifications-db.js delete mode 100644 profile_views_patch.ts rename scripts/{ => legacy}/checkCallbackHandlers.js (100%) rename scripts/{ => legacy}/checkDatabase.js (100%) rename scripts/{ => legacy}/checkProfileViews.js (100%) rename scripts/{ => legacy}/checkUserTable.js (100%) rename scripts/{ => legacy}/cleanDatabase.js (100%) rename scripts/{ => legacy}/clearDatabase.js (100%) rename scripts/{ => legacy}/clearDatabase.mjs (100%) rename scripts/{ => legacy}/clear_database.sql (100%) rename scripts/{ => legacy}/createProfileViewsTable.ts (100%) rename scripts/{ => legacy}/fixCallbackHandlers.js (100%) rename scripts/{ => legacy}/fixDatabaseStructure.js (100%) rename scripts/{ => legacy}/fix_all_notifications.js (100%) rename scripts/{ => legacy}/fix_notification_callbacks.js (100%) rename scripts/{ => legacy}/testCallbacks.js (100%) rename scripts/{ => legacy}/testMatching.js (100%) rename scripts/{ => legacy}/testProfileViews.js (100%) rename scripts/{ => legacy}/testVipMethod.js (100%) rename scripts/{ => legacy}/testVipStatus.js (100%) create mode 100644 scripts/startup.sh delete mode 100644 set-premium.js create mode 100644 sql/consolidated.sql create mode 100644 src/database/migrateOnStartup.ts create mode 100644 src/database/migrations/consolidated.sql create mode 100644 src/premium/README.md rename {scripts => src/premium}/add-premium-columns-direct.js (100%) rename {scripts => src/premium}/add-premium-columns.js (100%) rename {scripts => src/premium}/add-premium-columns.ts (100%) rename {scripts => src/premium}/addPremiumColumn.js (100%) rename {scripts => src/premium}/setPremiumStatus.js (100%) create mode 100644 start.bat create mode 100644 start.ps1 create mode 100755 start.sh delete mode 100644 test-connection.js delete mode 100644 test-db.js diff --git a/.env.example b/.env.example index ef9a793..069ba3f 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,58 @@ -# Telegram Bot Configuration +# Telegram Tinder Bot Configuration +# Rename this file to .env before starting the application + +# === REQUIRED SETTINGS === + +# Telegram Bot Token (Get from @BotFather) TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here # Database Configuration +# For local development (when running the bot directly) DB_HOST=localhost DB_PORT=5432 DB_NAME=telegram_tinder_bot DB_USERNAME=postgres DB_PASSWORD=your_password_here -# Application Settings +# === APPLICATION SETTINGS === + +# Environment (development, production) NODE_ENV=development + +# Port for health checks PORT=3000 -# Optional: Redis for caching (if using) -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= +# === FILE UPLOAD SETTINGS === -# Optional: File upload settings +# Path for storing uploaded files UPLOAD_PATH=./uploads + +# Maximum file size for uploads (in bytes, default: 5MB) MAX_FILE_SIZE=5242880 -# Optional: External services -GOOGLE_MAPS_API_KEY=your_google_maps_key -CLOUDINARY_URL=your_cloudinary_url +# === LOGGING === -# Security +# Log level (error, warn, info, debug) +LOG_LEVEL=info + +# Path for storing log files +LOG_PATH=./logs + +# === SECURITY === + +# Secret key for JWT tokens JWT_SECRET=your_jwt_secret_here + +# Encryption key for sensitive data ENCRYPTION_KEY=your_encryption_key_here + +# === ADVANCED SETTINGS === + +# Notification check interval in milliseconds (default: 60000 - 1 minute) +NOTIFICATION_CHECK_INTERVAL=60000 + +# Number of matches to show per page +MATCHES_PER_PAGE=10 + +# Number of profiles to load at once +PROFILES_BATCH_SIZE=5 diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..879c206 --- /dev/null +++ b/.env.production @@ -0,0 +1,68 @@ +# Конфигурация Telegram Tinder Bot для Production + +# === НЕОБХОДИМЫЕ НАСТРОЙКИ === + +# Токен Telegram бота (получить у @BotFather) +TELEGRAM_BOT_TOKEN=your_bot_token_here + +# Настройки базы данных PostgreSQL +DB_HOST=db +DB_PORT=5432 +DB_NAME=telegram_tinder_bot +DB_USERNAME=postgres +DB_PASSWORD=your_secure_password_here + +# === НАСТРОЙКИ ПРИЛОЖЕНИЯ === + +# Окружение +NODE_ENV=production + +# Порт для проверок работоспособности +PORT=3000 + +# === НАСТРОЙКИ ЗАГРУЗКИ ФАЙЛОВ === + +# Путь для хранения загруженных файлов +UPLOAD_PATH=./uploads + +# Максимальный размер загружаемого файла (в байтах, по умолчанию: 5MB) +MAX_FILE_SIZE=5242880 + +# Разрешенные типы файлов +ALLOWED_FILE_TYPES=image/jpeg,image/png,image/gif + +# === ЛОГИРОВАНИЕ === + +# Уровень логирования (error, warn, info, debug) +LOG_LEVEL=info + +# Путь для хранения лог-файлов +LOG_PATH=./logs + +# === БЕЗОПАСНОСТЬ === + +# Секретный ключ для JWT токенов +JWT_SECRET=your_jwt_secret_here + +# Ключ шифрования для чувствительных данных +ENCRYPTION_KEY=your_encryption_key_here + +# === РАСШИРЕННЫЕ НАСТРОЙКИ === + +# Интервал проверки уведомлений в миллисекундах (по умолчанию: 60000 - 1 минута) +NOTIFICATION_CHECK_INTERVAL=60000 + +# Количество матчей для отображения на странице +MATCHES_PER_PAGE=10 + +# Количество профилей для загрузки за один раз +PROFILES_BATCH_SIZE=5 + +# === НАСТРОЙКИ DOCKER === + +# Имя хоста для доступа извне +EXTERNAL_HOSTNAME=your_domain.com + +# Настройки кеширования (Redis, если используется) +CACHE_HOST=redis +CACHE_PORT=6379 diff --git a/Dockerfile b/Dockerfile index dbb2620..05edabe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,12 @@ WORKDIR /app COPY package*.json ./ COPY tsconfig.json ./ -# Install dependencies -RUN npm ci --only=production && npm cache clean --force +# Install all dependencies (including devDependencies for build) +RUN npm ci && npm cache clean --force # Copy source code COPY src/ ./src/ +COPY .env.example ./ # Build the application RUN npm run build @@ -31,11 +32,19 @@ RUN npm ci --only=production && npm cache clean --force # Copy built application from builder stage COPY --from=builder /app/dist ./dist +COPY --from=builder /app/.env.example ./.env.example -# Copy configuration files -COPY config/ ./config/ +# Copy database migrations +COPY src/database/migrations/ ./dist/database/migrations/ -# Create uploads directory +# Copy locales +COPY src/locales/ ./dist/locales/ + +# Copy scripts +COPY scripts/startup.sh ./startup.sh +RUN chmod +x ./startup.sh + +# Create directories RUN mkdir -p uploads logs # Create non-root user for security @@ -53,7 +62,7 @@ EXPOSE 3000 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })" || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 -# Start the application -CMD ["node", "dist/bot.js"] +# Start the application with migration script +CMD ["./startup.sh"] diff --git a/README.md b/README.md index edc7413..f465430 100644 --- a/README.md +++ b/README.md @@ -268,8 +268,32 @@ npm run dev - Node.js 16+ - PostgreSQL 12+ - Telegram Bot Token (получить у [@BotFather](https://t.me/BotFather)) +- Docker и Docker Compose (опционально) -### 2. Установка +### 2. Установка и запуск + +#### С использованием стартовых скриптов (рекомендуется) + +```bash +# Клонировать репозиторий +git clone +cd telegram-tinder-bot + +# На Windows: +.\start.bat + +# На Linux/macOS: +chmod +x start.sh +./start.sh +``` + +Скрипт автоматически: +- Проверит наличие файла .env и создаст его из шаблона при необходимости +- Предложит выбор между запуском с локальной БД или подключением к внешней +- Настроит все необходимые параметры окружения +- Запустит контейнеры Docker + +#### Без Docker ```bash # Клонировать репозиторий @@ -279,24 +303,17 @@ cd telegram-tinder-bot # Установить зависимости npm install -# Скомпилировать TypeScript -npm run build -``` +# Скопировать файл конфигурации +cp .env.example .env +# Отредактируйте файл .env и укажите свой TELEGRAM_BOT_TOKEN -### 3. Настройка базы данных - -```bash # Создать базу данных PostgreSQL createdb telegram_tinder_bot # Запустить миграции -psql -d telegram_tinder_bot -f src/database/migrations/init.sql -``` +npm run migrate:up -### 4. Запуск бота - -```bash -# Компиляция TypeScript +# Скомпилировать TypeScript npm run build # Запуск бота diff --git a/bin/backup_db.sh b/bin/backup_db.sh new file mode 100644 index 0000000..117d106 --- /dev/null +++ b/bin/backup_db.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# backup_db.sh - Script for backing up the PostgreSQL database + +echo "📦 Backing up PostgreSQL database..." + +# Default backup directory +BACKUP_DIR="${BACKUP_DIR:-/var/backups/tg_tinder_bot}" +BACKUP_FILENAME="tg_tinder_bot_$(date +%Y%m%d_%H%M%S).sql" +BACKUP_PATH="$BACKUP_DIR/$BACKUP_FILENAME" + +# Create backup directory if it doesn't exist +mkdir -p "$BACKUP_DIR" + +# Check if running in docker-compose environment +if [ -f /.dockerenv ] || [ -f /proc/self/cgroup ] && grep -q docker /proc/self/cgroup; then + echo "🐳 Running in Docker environment, using docker-compose exec..." + docker-compose exec -T db pg_dump -U postgres telegram_tinder_bot > "$BACKUP_PATH" +else + # Check if PGPASSWORD is set in environment + if [ -z "$PGPASSWORD" ]; then + # If .env file exists, try to get password from there + if [ -f .env ]; then + DB_PASSWORD=$(grep DB_PASSWORD .env | cut -d '=' -f2) + export PGPASSWORD="$DB_PASSWORD" + else + echo "⚠️ No DB_PASSWORD found in environment or .env file." + echo "Please enter PostgreSQL password:" + read -s PGPASSWORD + export PGPASSWORD + fi + fi + + echo "💾 Backing up database to $BACKUP_PATH..." + pg_dump -h localhost -U postgres -d telegram_tinder_bot > "$BACKUP_PATH" +fi + +# Check if backup was successful +if [ $? -eq 0 ]; then + echo "✅ Backup completed successfully: $BACKUP_PATH" + echo "📊 Backup size: $(du -h $BACKUP_PATH | cut -f1)" + + # Compress the backup + gzip -f "$BACKUP_PATH" + echo "🗜️ Compressed backup: $BACKUP_PATH.gz" + + # Keep only the last 7 backups + echo "🧹 Cleaning up old backups..." + find "$BACKUP_DIR" -name "tg_tinder_bot_*.sql.gz" -type f -mtime +7 -delete + + echo "🎉 Backup process completed!" +else + echo "❌ Backup failed!" + exit 1 +fi diff --git a/bin/create_release.sh b/bin/create_release.sh new file mode 100644 index 0000000..83397d1 --- /dev/null +++ b/bin/create_release.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Скрипт для создания релиза Telegram Tinder Bot + +# Получение версии из package.json +VERSION=$(grep -m1 "version" package.json | cut -d'"' -f4) +RELEASE_NAME="tg-tinder-bot-v$VERSION" +RELEASE_DIR="bin/releases/$RELEASE_NAME" + +echo "🚀 Создание релиза $RELEASE_NAME" + +# Создание директории релиза +mkdir -p "$RELEASE_DIR" + +# Очистка временных файлов +echo "🧹 Очистка временных файлов..." +rm -rf dist node_modules + +# Установка зависимостей +echo "📦 Установка зависимостей production..." +npm ci --only=production + +# Сборка проекта +echo "🔧 Сборка проекта..." +npm run build + +# Копирование файлов релиза +echo "📋 Копирование файлов..." +cp -r dist "$RELEASE_DIR/" +cp -r src/locales "$RELEASE_DIR/dist/" +cp package.json package-lock.json .env.example "$RELEASE_DIR/" +cp -r bin/start_bot.* bin/install_ubuntu.sh "$RELEASE_DIR/" +cp README.md LICENSE "$RELEASE_DIR/" 2>/dev/null || echo "Файлы документации не найдены" +cp sql/consolidated.sql "$RELEASE_DIR/" +cp docker-compose.yml Dockerfile "$RELEASE_DIR/" +cp deploy.sh "$RELEASE_DIR/" && chmod +x "$RELEASE_DIR/deploy.sh" + +# Создание README для релиза +cat > "$RELEASE_DIR/RELEASE.md" << EOL +# Telegram Tinder Bot v$VERSION + +Эта папка содержит релиз Telegram Tinder Bot версии $VERSION. + +## Содержимое + +- \`dist/\` - Скомпилированный код +- \`package.json\` - Зависимости и скрипты +- \`.env.example\` - Пример конфигурации +- \`docker-compose.yml\` и \`Dockerfile\` - Для запуска через Docker +- \`consolidated.sql\` - SQL-скрипт для инициализации базы данных +- \`deploy.sh\` - Скрипт для простого деплоя + +## Быстрый старт + +1. Создайте файл \`.env\` на основе \`.env.example\` +2. Запустите бота одним из способов: + - Через Docker: \`./deploy.sh\` + - Через Node.js: \`node dist/bot.js\` + +## Дата релиза + +$(date "+%d.%m.%Y %H:%M") +EOL + +# Архивирование релиза +echo "📦 Создание архива..." +cd bin/releases +zip -r "$RELEASE_NAME.zip" "$RELEASE_NAME" +cd ../.. + +echo "✅ Релиз создан успешно!" +echo "📂 Релиз доступен в: bin/releases/$RELEASE_NAME" +echo "📦 Архив релиза: bin/releases/$RELEASE_NAME.zip" diff --git a/bin/install_docker.sh b/bin/install_docker.sh new file mode 100644 index 0000000..3033222 --- /dev/null +++ b/bin/install_docker.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# install_docker.sh - Script for installing Docker and Docker Compose + +echo "🚀 Installing Docker and Docker Compose..." + +# Check if script is run as root +if [ "$(id -u)" -ne 0 ]; then + echo "❌ This script must be run as root. Please run with sudo." + exit 1 +fi + +# Update package lists +echo "📦 Updating package lists..." +apt update + +# Install required packages +echo "📦 Installing required packages..." +apt install -y apt-transport-https ca-certificates curl software-properties-common + +# Add Docker GPG key +echo "🔑 Adding Docker GPG key..." +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - + +# Add Docker repository +echo "📁 Adding Docker repository..." +add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" + +# Update package lists again +apt update + +# Install Docker +echo "🐳 Installing Docker..." +apt install -y docker-ce docker-ce-cli containerd.io + +# Enable and start Docker service +systemctl enable docker +systemctl start docker + +# Install Docker Compose +echo "🐳 Installing Docker Compose..." +curl -L "https://github.com/docker/compose/releases/download/v2.24.6/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +chmod +x /usr/local/bin/docker-compose + +# Check versions +echo "✅ Installation complete!" +echo "Docker version:" +docker --version +echo "Docker Compose version:" +docker-compose --version + +# Add current user to docker group if not root +if [ -n "$SUDO_USER" ]; then + echo "👤 Adding user $SUDO_USER to docker group..." + usermod -aG docker $SUDO_USER + echo "⚠️ Please log out and log back in for group changes to take effect." +fi + +echo "🎉 Docker installation completed successfully!" diff --git a/check_schema.ts b/check_schema.ts deleted file mode 100644 index 3cc78f7..0000000 --- a/check_schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { query } from './src/database/connection'; - -async function checkSchema() { - try { - const result = await query('SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1', ['messages']); - console.log(result.rows); - process.exit(0); - } catch (error) { - console.error(error); - process.exit(1); - } -} - -checkSchema(); diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..7091bb8 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# deploy.sh - Скрипт для деплоя Telegram Tinder Bot + +echo "🚀 Деплой Telegram Tinder Bot..." + +# Проверяем наличие Docker +if ! command -v docker &> /dev/null || ! command -v docker-compose &> /dev/null; then + echo "❌ Docker и Docker Compose должны быть установлены!" + echo "Для установки на Ubuntu выполните:" + echo "sudo apt update && sudo apt install -y docker.io docker-compose" + exit 1 +fi + +# Определяем рабочую директорию +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" +cd "$SCRIPT_DIR" + +# Получаем последние изменения +echo "📥 Получение последних изменений..." +git pull origin main + +# Проверяем наличие .env файла +if [ ! -f .env ]; then + echo "📝 Создание .env файла из .env.production..." + cp .env.production .env + echo "⚠️ Пожалуйста, отредактируйте файл .env и укажите свои настройки!" + exit 1 +fi + +# Запускаем Docker Compose +echo "🐳 Сборка и запуск контейнеров Docker..." +docker-compose down +docker-compose build +docker-compose up -d + +# Проверяем статус контейнеров +echo "🔍 Проверка статуса контейнеров..." +docker-compose ps + +echo "✅ Деплой успешно завершен! Бот должен быть доступен через Telegram." +echo "" +echo "📊 Полезные команды:" +echo "- Просмотр логов: docker-compose logs -f" +echo "- Перезапуск сервисов: docker-compose restart" +echo "- Остановка всех сервисов: docker-compose down" +echo "- Доступ к базе данных: docker-compose exec db psql -U postgres -d telegram_tinder_bot" +echo "- Проверка состояния бота: curl http://localhost:3000/health" +echo "" +echo "🌟 Для администрирования базы данных:" +echo "Adminer доступен по адресу: http://ваш_сервер:8080" +echo " - Система: PostgreSQL" +echo " - Сервер: db" +echo " - Пользователь: postgres" +echo " - Пароль: (из переменной DB_PASSWORD в .env)" +echo " - База данных: telegram_tinder_bot" diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example new file mode 100644 index 0000000..7ebf603 --- /dev/null +++ b/docker-compose.override.yml.example @@ -0,0 +1,69 @@ +# Используем версию Docker Compose для локальной разработки + +version: '3.8' + +services: + bot: + build: + context: . + dockerfile: Dockerfile + args: + - NODE_ENV=development + environment: + - NODE_ENV=development + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=telegram_tinder_bot + - DB_USERNAME=postgres + - DB_PASSWORD=dev_password + volumes: + # Монтируем исходный код для горячей перезагрузки + - ./src:/app/src + - ./dist:/app/dist + - ./.env:/app/.env + ports: + # Открываем порт для отладки + - "9229:9229" + command: npm run dev + networks: + - bot-network + depends_on: + - db + + db: + # Используем последнюю версию PostgreSQL для разработки + image: postgres:16-alpine + environment: + - POSTGRES_DB=telegram_tinder_bot + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=dev_password + volumes: + # Хранение данных локально для быстрого сброса + - postgres_data_dev:/var/lib/postgresql/data + # Монтируем скрипты инициализации + - ./sql:/docker-entrypoint-initdb.d + ports: + # Открываем порт для доступа к БД напрямую + - "5433:5432" + networks: + - bot-network + + adminer: + image: adminer:latest + ports: + - "8080:8080" + networks: + - bot-network + depends_on: + - db + environment: + - ADMINER_DEFAULT_SERVER=db + - ADMINER_DEFAULT_USER=postgres + - ADMINER_DEFAULT_PASSWORD=dev_password + +volumes: + postgres_data_dev: + +networks: + bot-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 760e31c..272f4bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,19 +6,26 @@ services: container_name: telegram-tinder-bot restart: unless-stopped depends_on: - - db + db: + condition: service_healthy + env_file: .env environment: - NODE_ENV=production - - DB_HOST={DB_HOST} - - DB_PORT={DB_PORT} - - DB_NAME={DB_NAME} - - DB_USERNAME={DB_USERNAME} - - DB_PASSWORD={DB_PASSWORD} - - TELEGRAM_BOT_TOKEN={TELEGRAM_BOT_TOKEN} + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=telegram_tinder_bot + - DB_USERNAME=postgres volumes: - ./uploads:/app/uploads + - ./logs:/app/logs networks: - bot-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s db: image: postgres:15-alpine @@ -27,14 +34,18 @@ services: environment: - POSTGRES_DB=telegram_tinder_bot - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=password123 + - POSTGRES_PASSWORD=${DB_PASSWORD:-password123} volumes: - postgres_data:/var/lib/postgresql/data - - ./src/database/migrations/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - "5433:5432" networks: - bot-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 adminer: image: adminer:latest diff --git a/docs/PRODUCTION_DEPLOYMENT.md b/docs/PRODUCTION_DEPLOYMENT.md new file mode 100644 index 0000000..ac43818 --- /dev/null +++ b/docs/PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,264 @@ +# Инструкция по развертыванию Telegram Tinder Bot в Production + +Это подробное руководство по развертыванию Telegram Tinder Bot в production-окружении с использованием Docker и Docker Compose. + +## 📋 Требования + +- **Операционная система**: Ubuntu 20.04 или выше (рекомендуется) / Windows Server с Docker +- **Программное обеспечение**: + - Docker (последняя версия) + - Docker Compose (последняя версия) + - Git + +## 🚀 Быстрое развертывание + +### 1. Клонирование репозитория + +```bash +git clone https://github.com/your-username/telegram-tinder-bot.git +cd telegram-tinder-bot +``` + +### 2. Настройка конфигурации + +```bash +# Создание файла конфигурации из шаблона +cp .env.production .env + +# Редактирование конфигурационного файла +nano .env +``` + +Важно указать следующие параметры: +- `TELEGRAM_BOT_TOKEN`: токен от @BotFather +- `DB_PASSWORD`: надежный пароль для базы данных +- `JWT_SECRET`: случайная строка для JWT +- `ENCRYPTION_KEY`: случайная строка для шифрования + +### 3. Запуск деплоя + +```bash +# Сделайте скрипт исполняемым +chmod +x deploy.sh + +# Запустите деплой +./deploy.sh +``` + +## 🔧 Подробное руководство по установке + +### Подготовка сервера Ubuntu + +```bash +# Обновление системы +sudo apt update && sudo apt upgrade -y + +# Установка необходимых пакетов +sudo apt install -y apt-transport-https ca-certificates curl software-properties-common git + +# Установка Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sudo sh get-docker.sh + +# Добавление текущего пользователя в группу docker +sudo usermod -aG docker ${USER} + +# Установка Docker Compose +sudo apt install -y docker-compose +``` + +### Клонирование и настройка проекта + +```bash +# Создание директории для проекта +mkdir -p /opt/telegram-tinder +cd /opt/telegram-tinder + +# Клонирование репозитория +git clone https://github.com/your-username/telegram-tinder-bot.git . + +# Настройка .env файла +cp .env.production .env +nano .env + +# Создание директорий для данных и логов +mkdir -p uploads logs +chmod 777 uploads logs +``` + +### Запуск проекта + +```bash +# Запуск в фоновом режиме +docker-compose up -d + +# Проверка статуса контейнеров +docker-compose ps + +# Просмотр логов +docker-compose logs -f +``` + +## 🔄 Обновление бота + +Для обновления бота выполните: + +```bash +cd /путь/к/telegram-tinder-bot +./deploy.sh +``` + +Скрипт автоматически выполнит: +1. Получение последних изменений из репозитория +2. Перезапуск контейнеров с новой версией кода +3. Применение миграций базы данных + +## 🛡️ Обеспечение безопасности + +### Настройка файрвола + +```bash +# Разрешение только необходимых портов +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable +``` + +### Настройка HTTPS с Let's Encrypt (опционально) + +Для использования HTTPS с Let's Encrypt и Nginx: + +```bash +# Установка Certbot +sudo apt install -y certbot python3-certbot-nginx + +# Получение SSL-сертификата +sudo certbot --nginx -d your-domain.com +``` + +## 📊 Мониторинг и управление + +### Просмотр логов + +```bash +# Логи всех контейнеров +docker-compose logs -f + +# Логи конкретного контейнера (например, бота) +docker-compose logs -f bot + +# Последние 100 строк логов +docker-compose logs --tail=100 bot +``` + +### Управление сервисами + +```bash +# Остановка всех контейнеров +docker-compose down + +# Перезапуск всех контейнеров +docker-compose restart + +# Перезапуск только бота +docker-compose restart bot +``` + +### Доступ к базе данных + +```bash +# Вход в консоль PostgreSQL +docker-compose exec db psql -U postgres -d telegram_tinder_bot + +# Резервное копирование базы данных +docker-compose exec db pg_dump -U postgres telegram_tinder_bot > backup_$(date +%Y%m%d).sql + +# Восстановление базы из резервной копии +cat backup.sql | docker-compose exec -T db psql -U postgres -d telegram_tinder_bot +``` + +## 🔍 Устранение неполадок + +### Проверка работоспособности + +```bash +# Проверка API бота +curl http://localhost:3000/health + +# Проверка подключения к базе данных +docker-compose exec bot node -e "const { Client } = require('pg'); const client = new Client({ host: 'db', port: 5432, database: 'telegram_tinder_bot', user: 'postgres', password: process.env.DB_PASSWORD }); client.connect().then(() => { console.log('Connected to DB!'); client.end(); }).catch(e => console.error(e));" +``` + +### Общие проблемы и решения + +**Проблема**: Бот не отвечает в Telegram +**Решение**: +- Проверьте валидность токена бота +- Проверьте логи на наличие ошибок: `docker-compose logs -f bot` + +**Проблема**: Ошибки подключения к базе данных +**Решение**: +- Проверьте настройки подключения в `.env` +- Убедитесь, что контейнер с базой данных запущен: `docker-compose ps` +- Проверьте логи базы данных: `docker-compose logs db` + +**Проблема**: Недостаточно свободного места на диске +**Решение**: +- Очистите неиспользуемые Docker образы: `docker image prune -a` +- Очистите неиспользуемые Docker тома: `docker volume prune` + +## 🔁 Настройка автоматического обновления + +### Настройка автообновления через Cron + +```bash +# Редактирование crontab +crontab -e + +# Добавление задачи (обновление каждую ночь в 3:00) +0 3 * * * cd /путь/к/telegram-tinder-bot && ./deploy.sh > /tmp/tg-tinder-update.log 2>&1 +``` + +## 📝 Рекомендации по обслуживанию + +1. **Регулярное резервное копирование**: + ```bash + # Ежедневное резервное копирование через cron + 0 2 * * * docker-compose exec -T db pg_dump -U postgres telegram_tinder_bot > /path/to/backups/tg_$(date +\%Y\%m\%d).sql + ``` + +2. **Мониторинг использования ресурсов**: + ```bash + # Просмотр использования ресурсов контейнерами + docker stats + ``` + +3. **Обновление Docker образов**: + ```bash + # Обновление образов + docker-compose pull + docker-compose up -d + ``` + +4. **Проверка журналов на наличие ошибок**: + ```bash + # Поиск ошибок в логах + docker-compose logs | grep -i error + docker-compose logs | grep -i exception + ``` + +--- + +## 📋 Контрольный список деплоя + +- [ ] Установлены Docker и Docker Compose +- [ ] Клонирован репозиторий +- [ ] Настроен файл .env с реальными данными +- [ ] Запущены контейнеры через docker-compose +- [ ] Проверено подключение бота к Telegram API +- [ ] Настроено резервное копирование +- [ ] Настроен файрвол и безопасность сервера +- [ ] Проверены и настроены логи +- [ ] (Опционально) Настроен SSL для веб-интерфейса +- [ ] (Опционально) Настроено автоматическое обновление diff --git a/fixes.md b/fixes.md deleted file mode 100644 index ded6c38..0000000 --- a/fixes.md +++ /dev/null @@ -1,90 +0,0 @@ -# Исправления ошибок в коде - -## Проблема: Несоответствие имен столбцов в таблице swipes - -В коде обнаружены несоответствия в названиях столбцов при работе с таблицей `swipes`. Используются два разных варианта именования: - -1. `user_id` и `target_user_id` -2. `swiper_id` и `swiped_id` - -Судя по ошибкам в консоли и анализу кода, корректными именами столбцов являются `user_id` и `target_user_id`. - -## Необходимые исправления - -### 1. В файле `profileService.ts` - метод `deleteProfile`: - -```typescript -// Неверно: -await client.query('DELETE FROM swipes WHERE swiper_id = $1 OR swiped_id = $1', [userId]); - -// Исправить на: -await client.query('DELETE FROM swipes WHERE user_id = $1 OR target_user_id = $1', [userId]); -``` - -### 2. В файле `matchingService.ts` - метод `performSwipe`: - -```typescript -// Неверно: -const reciprocalSwipe = await client.query(` - SELECT * FROM swipes - WHERE swiper_id = $1 AND swiped_id = $2 AND direction IN ('right', 'super') -`, [targetUserId, userId]); - -// Исправить на: -const reciprocalSwipe = await client.query(` - SELECT * FROM swipes - WHERE user_id = $1 AND target_user_id = $2 AND direction IN ('right', 'super') -`, [targetUserId, userId]); -``` - -### 3. В файле `matchingService.ts` - метод `getRecentLikes`: - -```typescript -// Неверно (если используется метод mapEntityToSwipe): -private mapEntityToSwipe(entity: any): Swipe { - return new Swipe({ - id: entity.id, - userId: entity.swiper_id, - targetUserId: entity.swiped_id, - type: this.convertDirectionToSwipeType(entity.direction), - timestamp: entity.created_at, - isMatch: entity.is_match - }); -} - -// Исправить на: -private mapEntityToSwipe(entity: any): Swipe { - return new Swipe({ - id: entity.id, - userId: entity.user_id, - targetUserId: entity.target_user_id, - type: this.convertDirectionToSwipeType(entity.direction), - timestamp: entity.created_at, - isMatch: entity.is_match - }); -} -``` - -### 4. В файле `matchingService.ts` - метод `getDailySwipeStats`: - -```typescript -// Неверно: -const result = await query(` - SELECT direction, COUNT(*) as count - FROM swipes - WHERE swiper_id = $1 AND created_at >= $2 - GROUP BY direction -`, [userId, today]); - -// Исправить на: -const result = await query(` - SELECT direction, COUNT(*) as count - FROM swipes - WHERE user_id = $1 AND created_at >= $2 - GROUP BY direction -`, [userId, today]); -``` - -## Примечание - -После внесения исправлений рекомендуется проверить все остальные места в коде, где могут использоваться эти имена столбцов, и убедиться в их согласованности. diff --git a/init-notifications-db.js b/init-notifications-db.js deleted file mode 100644 index bcd29d0..0000000 --- a/init-notifications-db.js +++ /dev/null @@ -1,174 +0,0 @@ -require('dotenv').config(); -const { Pool } = require('pg'); -const { v4: uuidv4 } = require('uuid'); - -// Функция для запуска скрипта -async function initializeDatabase() { - console.log('Starting database initialization script...'); - - const pool = new Pool({ - host: 'localhost', // Используем localhost - port: 5432, // Используем стандартный порт 5432 - database: 'telegram_tinder_bot', - user: 'postgres', - password: '', - max: 5, - connectionTimeoutMillis: 5000 - }); - - console.log('DB Connection Details:'); - console.log('- Host: localhost'); - console.log('- Port: 5432'); - console.log('- Database: telegram_tinder_bot'); - console.log('- User: postgres'); - - try { - // Проверяем подключение - console.log('Testing connection...'); - const client = await pool.connect(); - - try { - console.log('✅ Connected to database successfully!'); - - // 1. Создаем расширение для генерации UUID если его нет - console.log('Creating UUID extension...'); - await client.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); - - // 2. Создаем таблицу для шаблонов уведомлений - console.log('Creating notification_templates table...'); - await client.query(` - CREATE TABLE IF NOT EXISTS notification_templates ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - type VARCHAR(50) NOT NULL UNIQUE, - title TEXT NOT NULL, - message_template TEXT NOT NULL, - button_template JSONB NOT NULL, - created_at TIMESTAMP DEFAULT NOW() - ) - `); - - // 3. Вставляем базовые шаблоны - console.log('Inserting notification templates...'); - - const templates = [ - { - id: uuidv4(), - type: 'new_like', - title: 'Новый лайк!', - message_template: '❤️ *{{name}}* поставил(а) вам лайк!\n\nВозраст: {{age}}\n{{city}}\n\nОтветьте взаимностью или посмотрите профиль.', - button_template: JSON.stringify({ - inline_keyboard: [ - [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], - [ - { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, - { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } - ], - [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] - ] - }) - }, - { - id: uuidv4(), - type: 'super_like', - title: 'Супер-лайк!', - message_template: '⭐️ *{{name}}* отправил(а) вам супер-лайк!\n\nВозраст: {{age}}\n{{city}}\n\nВы произвели особое впечатление! Ответьте взаимностью или посмотрите профиль.', - button_template: JSON.stringify({ - inline_keyboard: [ - [{ text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }], - [ - { text: '❤️ Лайк в ответ', callback_data: 'like_back:{{userId}}' }, - { text: '⛔️ Пропустить', callback_data: 'dislike_profile:{{userId}}' } - ], - [{ text: '💕 Открыть все лайки', callback_data: 'view_likes' }] - ] - }) - }, - { - id: uuidv4(), - type: 'new_match', - title: 'Новый матч!', - message_template: '🎊 *Ура! Это взаимно!* 🎊\n\nВы и *{{name}}* понравились друг другу!\nВозраст: {{age}}\n{{city}}\n\nСделайте первый шаг - напишите сообщение!', - button_template: JSON.stringify({ - inline_keyboard: [ - [{ text: '💬 Начать общение', callback_data: 'open_native_chat_{{matchId}}' }], - [ - { text: '👀 Посмотреть профиль', callback_data: 'view_profile:{{userId}}' }, - { text: '📋 Все матчи', callback_data: 'native_chats' } - ] - ] - }) - }, - { - id: uuidv4(), - type: 'new_message', - title: 'Новое сообщение!', - message_template: '💌 *Новое сообщение!*\n\nОт: *{{name}}*\n\n"{{message}}"\n\nОтветьте на сообщение прямо сейчас!', - button_template: JSON.stringify({ - inline_keyboard: [ - [{ text: '📩 Ответить', callback_data: 'open_native_chat_{{matchId}}' }], - [ - { text: '👤 Профиль', callback_data: 'view_profile:{{userId}}' }, - { text: '📋 Все чаты', callback_data: 'native_chats' } - ] - ] - }) - } - ]; - - // Вставляем шаблоны с проверкой на конфликты - for (const template of templates) { - await client.query(` - INSERT INTO notification_templates - (id, type, title, message_template, button_template, created_at) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (type) DO UPDATE - SET title = $3, - message_template = $4, - button_template = $5 - `, [template.id, template.type, template.title, template.message_template, template.button_template]); - } - - console.log('✅ Notification templates created/updated successfully'); - - // 4. Создаем таблицу для хранения логов уведомлений если её нет - console.log('Creating notifications table...'); - await client.query(` - CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - user_id UUID NOT NULL, - type VARCHAR(50) NOT NULL, - data JSONB NOT NULL, - is_read BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT NOW() - ) - `); - - console.log('✅ Notifications table created successfully'); - - // 5. Проверяем, что таблицы созданы - const tablesResult = await client.query(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name IN ('notification_templates', 'notifications') - `); - - console.log('Created tables:'); - tablesResult.rows.forEach(row => { - console.log(`- ${row.table_name}`); - }); - - } finally { - client.release(); - await pool.end(); - } - - console.log('✅ Database initialization completed successfully'); - - } catch (error) { - console.error('❌ Database initialization error:', error); - } -} - -// Запускаем скрипт -initializeDatabase(); diff --git a/profile_views_patch.ts b/profile_views_patch.ts deleted file mode 100644 index f9abfb6..0000000 --- a/profile_views_patch.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Патч для учета просмотренных профилей в функциональности бота - -// 1. Добавляем функцию recordProfileView в ProfileController -import { Profile, ProfileData } from '../models/Profile'; -import { ProfileService } from '../services/profileService'; - -export class ProfileController { - constructor(private profileService: ProfileService) {} - - // Существующие методы... - - // Новый метод для записи просмотра профиля - async recordProfileView(viewerTelegramId: string, viewedTelegramId: string, viewType: string = 'browse'): Promise { - try { - // Получаем внутренние ID пользователей - const viewerId = await this.profileService.getUserIdByTelegramId(viewerTelegramId); - const viewedId = await this.profileService.getUserIdByTelegramId(viewedTelegramId); - - if (!viewerId || !viewedId) { - console.error('Не удалось найти пользователей для записи просмотра профиля'); - return false; - } - - // Проверяем существование таблицы profile_views - const checkTableResult = await this.profileService.checkTableExists('profile_views'); - - if (checkTableResult) { - // Записываем просмотр - await this.profileService.recordProfileView(viewerId, viewedId, viewType); - console.log(`Просмотр профиля записан: ${viewerTelegramId} просмотрел ${viewedTelegramId}`); - return true; - } else { - console.log('Таблица profile_views не существует, просмотр не записан'); - return false; - } - } catch (error) { - console.error('Ошибка при записи просмотра профиля:', error); - return false; - } - } - - // Новый метод для получения списка просмотренных профилей - async getViewedProfiles(telegramId: string, limit: number = 50): Promise { - try { - // Получаем внутренний ID пользователя - const userId = await this.profileService.getUserIdByTelegramId(telegramId); - - if (!userId) { - console.error('Не удалось найти пользователя для получения списка просмотренных профилей'); - return []; - } - - // Проверяем существование таблицы profile_views - const checkTableResult = await this.profileService.checkTableExists('profile_views'); - - if (checkTableResult) { - // Получаем список просмотренных профилей - return await this.profileService.getViewedProfiles(userId, limit); - } else { - console.log('Таблица profile_views не существует, возвращаем пустой список'); - return []; - } - } catch (error) { - console.error('Ошибка при получении списка просмотренных профилей:', error); - return []; - } - } -} - -// 2. Добавляем функцию для проверки существования таблицы в ProfileService -async checkTableExists(tableName: string): Promise { - try { - const result = await query(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = $1 - ); - `, [tableName]); - - return result.rows.length > 0 && result.rows[0].exists; - } catch (error) { - console.error(`Ошибка проверки существования таблицы ${tableName}:`, error); - return false; - } -} - -// 3. Обновляем обработчик показа профиля, чтобы записывать просмотры -async function handleShowProfile(ctx: any) { - // Существующий код... - - // После успешного отображения профиля записываем просмотр - const viewerTelegramId = ctx.from.id.toString(); - const viewedTelegramId = candidateProfile.telegram_id.toString(); - - try { - const profileController = new ProfileController(new ProfileService()); - await profileController.recordProfileView(viewerTelegramId, viewedTelegramId, 'browse'); - } catch (error) { - console.error('Ошибка при записи просмотра профиля:', error); - } - - // Остальной код... -} - -// 4. Обновляем функцию getNextCandidate, чтобы учитывать просмотренные профили -async function getNextCandidate(ctx: any) { - const telegramId = ctx.from.id.toString(); - const isNewUser = false; // Определяем, является ли пользователь новым - - try { - // Сначала пытаемся получить профили, которые пользователь еще не просматривал - const matchingService = new MatchingService(); - const profileService = new ProfileService(); - const profileController = new ProfileController(profileService); - - // Получаем UUID пользователя - const userId = await profileService.getUserIdByTelegramId(telegramId); - - if (!userId) { - console.error('Не удалось найти пользователя для получения следующего кандидата'); - return null; - } - - // Получаем список просмотренных профилей - const viewedProfiles = await profileController.getViewedProfiles(telegramId); - - // Получаем профиль пользователя - const userProfile = await profileService.getProfileByTelegramId(telegramId); - - if (!userProfile) { - console.error('Не удалось найти профиль пользователя для получения следующего кандидата'); - return null; - } - - // Ищем подходящий профиль с учетом просмотренных - const nextCandidate = await matchingService.getNextCandidate(telegramId, isNewUser); - - // Если найден кандидат, записываем просмотр - if (nextCandidate) { - const viewedTelegramId = await profileService.getTelegramIdByUserId(nextCandidate.userId); - - if (viewedTelegramId) { - await profileController.recordProfileView(telegramId, viewedTelegramId, 'browse'); - } - } - - return nextCandidate; - } catch (error) { - console.error('Ошибка при получении следующего кандидата:', error); - return null; - } -} diff --git a/scripts/README.md b/scripts/README.md index 6651500..687bed5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,76 +1,49 @@ -# Исправление проблем с уведомлениями в боте +# Структура скриптов в директории `/scripts` -Этот набор скриптов предназначен для исправления проблем с обработкой уведомлений в боте. +Эта директория содержит вспомогательные скрипты для работы с Telegram Tinder Bot. -## Описание проблемы +## Основные скрипты -После внедрения системы уведомлений и связанных с ней изменений в базе данных, возникла проблема с обработкой callback запросов. Бот перестал реагировать на все callback запросы, кроме тех, что связаны с уведомлениями. +- `startup.sh` - Скрипт запуска бота в Docker-контейнере +- `migrate-sync.js` - Синхронизация миграций базы данных +- `createNotificationTables.js` - Создание таблиц для системы уведомлений +- `add-hobbies-column.js` - Добавление колонки интересов в профиль +- `create_profile_fix.js` - Исправление профилей пользователей +- `createProfileViewsTable.js` - Создание таблицы для учета просмотров профилей +- `update_bot_with_notifications.js` - Обновление бота с поддержкой уведомлений -Проблема вызвана следующими факторами: -1. Отсутствие или неверная структура таблиц в базе данных для хранения уведомлений -2. Отсутствие необходимых полей `state` и `state_data` в таблице `users` -3. Отсутствие правильной регистрации обработчиков уведомлений в файле `bot.ts` +## Директории -## Решение +- `/legacy` - Устаревшие и тестовые скрипты, сохраненные для истории -Для решения проблемы были созданы следующие скрипты: +## Использование скриптов -### 1. `fix_notification_callbacks.js` -Проверяет и создает необходимые таблицы и столбцы в базе данных: -- Таблицы `notifications`, `scheduled_notifications`, `notification_templates` -- Столбцы `notification_settings`, `state`, `state_data` в таблице `users` +Скрипты JavaScript можно запускать с помощью Node.js: -### 2. `update_bot_with_notifications.js` -Обновляет файл `bot.ts`: -- Добавляет импорт класса `NotificationHandlers` -- Добавляет объявление поля `notificationHandlers` в класс `TelegramTinderBot` -- Добавляет создание экземпляра `NotificationHandlers` в конструкторе -- Добавляет регистрацию обработчиков уведомлений в методе `registerHandlers` +```bash +node scripts/script-name.js +``` -### 3. `fix_all_notifications.js` -Запускает оба скрипта последовательно для полного исправления проблемы +Bash скрипты должны быть сделаны исполняемыми: -## Как использовать +```bash +chmod +x scripts/script-name.sh +./scripts/script-name.sh +``` -1. Остановите бота, если он запущен: - ```bash - # Нажмите Ctrl+C в терминале, где запущен бот - # или найдите процесс и завершите его - ``` +## Добавление новых скриптов -2. Запустите комплексный скрипт исправления: - ```bash - node scripts/fix_all_notifications.js - ``` +При добавлении новых скриптов соблюдайте следующие правила: +1. Используйте понятное имя файла, отражающее его назначение +2. Добавьте комментарии в начало файла с описанием его функциональности +3. Добавьте запись об этом скрипте в текущий файл README.md -3. После успешного выполнения скрипта перезапустите бота: - ```bash - npm run start - ``` +## Скрипты миграций -## Проверка результата +Миграции базы данных следует создавать с помощью команды: -После запуска бота убедитесь, что: -1. Бот отвечает на все callback запросы (включая кнопки, не связанные с уведомлениями) -2. Настройки уведомлений работают корректно (команда /notifications или кнопка в меню настроек) -3. Уведомления о лайках, супер-лайках и новых матчах приходят пользователям +```bash +npm run migrate:create your_migration_name +``` -## Если проблемы остались - -Если после выполнения всех шагов проблемы остались, выполните следующие проверки: - -1. Проверьте логи бота на наличие ошибок -2. Проверьте структуру базы данных: - ```sql - \dt -- Список всех таблиц - \d notifications -- Структура таблицы notifications - \d scheduled_notifications -- Структура таблицы scheduled_notifications - \d notification_templates -- Структура таблицы notification_templates - \d users -- Убедитесь, что поля state, state_data и notification_settings существуют - ``` - -3. Проверьте код в файлах: - - `src/bot.ts`: должен содержать импорт, создание и регистрацию `NotificationHandlers` - - `src/handlers/callbackHandlers.ts`: должен правильно обрабатывать все callback-запросы - -В случае обнаружения ошибок, исправьте их вручную и перезапустите бота. +Это создаст файл миграции в директории `/migrations`. diff --git a/scripts/checkCallbackHandlers.js b/scripts/legacy/checkCallbackHandlers.js similarity index 100% rename from scripts/checkCallbackHandlers.js rename to scripts/legacy/checkCallbackHandlers.js diff --git a/scripts/checkDatabase.js b/scripts/legacy/checkDatabase.js similarity index 100% rename from scripts/checkDatabase.js rename to scripts/legacy/checkDatabase.js diff --git a/scripts/checkProfileViews.js b/scripts/legacy/checkProfileViews.js similarity index 100% rename from scripts/checkProfileViews.js rename to scripts/legacy/checkProfileViews.js diff --git a/scripts/checkUserTable.js b/scripts/legacy/checkUserTable.js similarity index 100% rename from scripts/checkUserTable.js rename to scripts/legacy/checkUserTable.js diff --git a/scripts/cleanDatabase.js b/scripts/legacy/cleanDatabase.js similarity index 100% rename from scripts/cleanDatabase.js rename to scripts/legacy/cleanDatabase.js diff --git a/scripts/clearDatabase.js b/scripts/legacy/clearDatabase.js similarity index 100% rename from scripts/clearDatabase.js rename to scripts/legacy/clearDatabase.js diff --git a/scripts/clearDatabase.mjs b/scripts/legacy/clearDatabase.mjs similarity index 100% rename from scripts/clearDatabase.mjs rename to scripts/legacy/clearDatabase.mjs diff --git a/scripts/clear_database.sql b/scripts/legacy/clear_database.sql similarity index 100% rename from scripts/clear_database.sql rename to scripts/legacy/clear_database.sql diff --git a/scripts/createProfileViewsTable.ts b/scripts/legacy/createProfileViewsTable.ts similarity index 100% rename from scripts/createProfileViewsTable.ts rename to scripts/legacy/createProfileViewsTable.ts diff --git a/scripts/fixCallbackHandlers.js b/scripts/legacy/fixCallbackHandlers.js similarity index 100% rename from scripts/fixCallbackHandlers.js rename to scripts/legacy/fixCallbackHandlers.js diff --git a/scripts/fixDatabaseStructure.js b/scripts/legacy/fixDatabaseStructure.js similarity index 100% rename from scripts/fixDatabaseStructure.js rename to scripts/legacy/fixDatabaseStructure.js diff --git a/scripts/fix_all_notifications.js b/scripts/legacy/fix_all_notifications.js similarity index 100% rename from scripts/fix_all_notifications.js rename to scripts/legacy/fix_all_notifications.js diff --git a/scripts/fix_notification_callbacks.js b/scripts/legacy/fix_notification_callbacks.js similarity index 100% rename from scripts/fix_notification_callbacks.js rename to scripts/legacy/fix_notification_callbacks.js diff --git a/scripts/testCallbacks.js b/scripts/legacy/testCallbacks.js similarity index 100% rename from scripts/testCallbacks.js rename to scripts/legacy/testCallbacks.js diff --git a/scripts/testMatching.js b/scripts/legacy/testMatching.js similarity index 100% rename from scripts/testMatching.js rename to scripts/legacy/testMatching.js diff --git a/scripts/testProfileViews.js b/scripts/legacy/testProfileViews.js similarity index 100% rename from scripts/testProfileViews.js rename to scripts/legacy/testProfileViews.js diff --git a/scripts/testVipMethod.js b/scripts/legacy/testVipMethod.js similarity index 100% rename from scripts/testVipMethod.js rename to scripts/legacy/testVipMethod.js diff --git a/scripts/testVipStatus.js b/scripts/legacy/testVipStatus.js similarity index 100% rename from scripts/testVipStatus.js rename to scripts/legacy/testVipStatus.js diff --git a/scripts/startup.sh b/scripts/startup.sh new file mode 100644 index 0000000..9cb83ea --- /dev/null +++ b/scripts/startup.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# startup.sh - Script to run migrations and start the bot + +echo "🚀 Starting Telegram Tinder Bot..." + +# Wait for database to be ready +echo "⏳ Waiting for database to be ready..." +sleep 5 + +# Run database migrations +echo "🔄 Running database migrations..." +node dist/database/migrateOnStartup.js + +# Start the bot +echo "✅ Starting the bot..." +node dist/bot.js diff --git a/set-premium.js b/set-premium.js deleted file mode 100644 index 1cc5808664c9745140209a5d7027592b9127b7a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1060 zcmZ`&O;5r=5PgR^k@z2KJhVo`S0pM3ips%+fKkvR!HP-(R9lEP#=ow<*-}c2X0zMf z>CC)0Z+3sab8z8dh5&O|_~d-U0weecx&PvqJ|uYdWA%Fnfr-(br*YCCc_-p25ZXL1*UKqtNe0YE&4|M;#H8$r&LubiY<8|~|*Cm?hp~cg})arA!GJd!G-=p<4r9yL2(c8RAd(^|#F os_1)|QMc?JsM1?IpTwtY_@8+x#XQH?t#47^>tC-;U+Na;FCCMg^8f$< diff --git a/sql/consolidated.sql b/sql/consolidated.sql new file mode 100644 index 0000000..a61b524 --- /dev/null +++ b/sql/consolidated.sql @@ -0,0 +1,404 @@ +# Consolidated SQL файл для миграции базы данных Telegram Tinder Bot +# Этот файл содержит все необходимые SQL-запросы для создания базы данных с нуля + +-- Создание расширения для UUID +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Создание перечислений +CREATE TYPE gender_type AS ENUM ('male', 'female', 'other'); +CREATE TYPE swipe_action AS ENUM ('like', 'dislike', 'superlike'); + +-- Создание таблицы пользователей +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + telegram_id BIGINT UNIQUE NOT NULL, + username VARCHAR(255), + first_name VARCHAR(255) NOT NULL, + last_name VARCHAR(255), + language_code VARCHAR(10), + is_premium BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Создание таблицы профилей +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + age INTEGER NOT NULL CHECK (age >= 18), + gender gender_type NOT NULL, + bio TEXT, + photos TEXT[], -- JSON array of photo file_ids + location VARCHAR(255), + job VARCHAR(255), + interests TEXT[], -- JSON array of interests + last_active TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_completed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_profile UNIQUE (user_id) +); + +-- Создание индекса для поиска по возрасту и полу +CREATE INDEX idx_profiles_age_gender ON profiles(age, gender); + +-- Создание таблицы предпочтений поиска +CREATE TABLE IF NOT EXISTS search_preferences ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + age_min INTEGER NOT NULL DEFAULT 18 CHECK (age_min >= 18), + age_max INTEGER NOT NULL DEFAULT 99 CHECK (age_max >= age_min), + looking_for gender_type NOT NULL, + distance_max INTEGER, -- max distance in km, NULL means no limit + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_preferences UNIQUE (user_id) +); + +-- Создание таблицы действий (лайки/дизлайки) +CREATE TABLE IF NOT EXISTS swipes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + action swipe_action NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_swipe UNIQUE (user_id, target_id) +); + +-- Создание индекса для быстрого поиска матчей +CREATE INDEX idx_swipes_user_target ON swipes(user_id, target_id); + +-- Создание таблицы матчей +CREATE TABLE IF NOT EXISTS matches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id_1 UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id_2 UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + CONSTRAINT unique_match UNIQUE (user_id_1, user_id_2) +); + +-- Создание индекса для быстрого поиска матчей по пользователю +CREATE INDEX idx_matches_user_id_1 ON matches(user_id_1); +CREATE INDEX idx_matches_user_id_2 ON matches(user_id_2); + +-- Создание таблицы блокировок +CREATE TABLE IF NOT EXISTS blocks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_block UNIQUE (blocker_id, blocked_id) +); + +-- Создание таблицы сообщений +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + match_id UUID NOT NULL REFERENCES matches(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Создание индекса для быстрого поиска сообщений +CREATE INDEX idx_messages_match_id ON messages(match_id); + +-- Создание таблицы уведомлений +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, -- new_match, new_message, etc. + content TEXT NOT NULL, + is_read BOOLEAN DEFAULT FALSE, + reference_id UUID, -- Can reference a match_id, message_id, etc. + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Создание индекса для быстрого поиска уведомлений +CREATE INDEX idx_notifications_user_id ON notifications(user_id); + +-- Создание таблицы настроек +CREATE TABLE IF NOT EXISTS settings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notifications_enabled BOOLEAN DEFAULT TRUE, + show_online_status BOOLEAN DEFAULT TRUE, + visibility BOOLEAN DEFAULT TRUE, -- whether profile is visible in search + theme VARCHAR(20) DEFAULT 'light', + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_settings UNIQUE (user_id) +); + +-- Создание таблицы просмотров профиля +CREATE TABLE IF NOT EXISTS profile_views ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + viewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + viewed_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + view_count INTEGER DEFAULT 1, + last_viewed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_view UNIQUE (viewer_id, viewed_id) +); + +-- Создание таблицы для премиум-пользователей +CREATE TABLE IF NOT EXISTS premium_features ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + is_premium BOOLEAN DEFAULT FALSE, + superlike_quota INTEGER DEFAULT 1, + spotlight_quota INTEGER DEFAULT 0, + see_likes BOOLEAN DEFAULT FALSE, -- Can see who liked their profile + unlimited_likes BOOLEAN DEFAULT FALSE, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_premium UNIQUE (user_id) +); + +-- Функция для обновления поля updated_at +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Триггеры для обновления поля updated_at +CREATE TRIGGER update_users_updated_at +BEFORE UPDATE ON users +FOR EACH ROW +EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER update_profiles_updated_at +BEFORE UPDATE ON profiles +FOR EACH ROW +EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER update_search_preferences_updated_at +BEFORE UPDATE ON search_preferences +FOR EACH ROW +EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER update_settings_updated_at +BEFORE UPDATE ON settings +FOR EACH ROW +EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER update_premium_features_updated_at +BEFORE UPDATE ON premium_features +FOR EACH ROW +EXECUTE FUNCTION update_updated_at(); + +-- Индекс для поиска пользователей по Telegram ID (часто используемый запрос) +CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id); + +-- Индекс для статуса профиля (активный/неактивный, завершенный/незавершенный) +CREATE INDEX IF NOT EXISTS idx_profiles_is_completed ON profiles(is_completed); + +-- Представление для статистики +CREATE OR REPLACE VIEW user_statistics AS +SELECT + u.id, + u.telegram_id, + (SELECT COUNT(*) FROM swipes WHERE user_id = u.id AND action = 'like') AS likes_given, + (SELECT COUNT(*) FROM swipes WHERE user_id = u.id AND action = 'dislike') AS dislikes_given, + (SELECT COUNT(*) FROM swipes WHERE target_id = u.id AND action = 'like') AS likes_received, + (SELECT COUNT(*) FROM matches WHERE user_id_1 = u.id OR user_id_2 = u.id) AS matches_count, + (SELECT COUNT(*) FROM messages WHERE sender_id = u.id) AS messages_sent, + (SELECT COUNT(*) FROM profile_views WHERE viewed_id = u.id) AS profile_views +FROM users u; + +-- Функция для создания матча при взаимных лайках +CREATE OR REPLACE FUNCTION create_match_on_mutual_like() +RETURNS TRIGGER AS $$ +DECLARE + reverse_like_exists BOOLEAN; +BEGIN + -- Check if there is a reverse like + SELECT EXISTS ( + SELECT 1 + FROM swipes + WHERE user_id = NEW.target_id + AND target_id = NEW.user_id + AND action = 'like' + ) INTO reverse_like_exists; + + -- If there is a reverse like, create a match + IF reverse_like_exists AND NEW.action = 'like' THEN + INSERT INTO matches (user_id_1, user_id_2) + VALUES ( + LEAST(NEW.user_id, NEW.target_id), + GREATEST(NEW.user_id, NEW.target_id) + ) + ON CONFLICT (user_id_1, user_id_2) DO NOTHING; + + -- Create notifications for both users + INSERT INTO notifications (user_id, type, content, reference_id) + VALUES ( + NEW.user_id, + 'new_match', + 'У вас новый матч!', + (SELECT id FROM matches WHERE + (user_id_1 = LEAST(NEW.user_id, NEW.target_id) AND user_id_2 = GREATEST(NEW.user_id, NEW.target_id)) + ) + ); + + INSERT INTO notifications (user_id, type, content, reference_id) + VALUES ( + NEW.target_id, + 'new_match', + 'У вас новый матч!', + (SELECT id FROM matches WHERE + (user_id_1 = LEAST(NEW.user_id, NEW.target_id) AND user_id_2 = GREATEST(NEW.user_id, NEW.target_id)) + ) + ); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Триггер для создания матча при взаимных лайках +CREATE TRIGGER create_match_trigger +AFTER INSERT ON swipes +FOR EACH ROW +EXECUTE FUNCTION create_match_on_mutual_like(); + +-- Функция для создания уведомления о новом сообщении +CREATE OR REPLACE FUNCTION notify_new_message() +RETURNS TRIGGER AS $$ +DECLARE + recipient_id UUID; + match_record RECORD; +BEGIN + -- Get the match record + SELECT * INTO match_record FROM matches WHERE id = NEW.match_id; + + -- Determine the recipient + IF match_record.user_id_1 = NEW.sender_id THEN + recipient_id := match_record.user_id_2; + ELSE + recipient_id := match_record.user_id_1; + END IF; + + -- Create notification + INSERT INTO notifications (user_id, type, content, reference_id) + VALUES ( + recipient_id, + 'new_message', + 'У вас новое сообщение!', + NEW.id + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Триггер для создания уведомления о новом сообщении +CREATE TRIGGER notify_new_message_trigger +AFTER INSERT ON messages +FOR EACH ROW +EXECUTE FUNCTION notify_new_message(); + +-- Функция для обновления времени последней активности пользователя +CREATE OR REPLACE FUNCTION update_last_active() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE profiles + SET last_active = CURRENT_TIMESTAMP + WHERE user_id = NEW.user_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Триггер для обновления времени последней активности при свайпах +CREATE TRIGGER update_last_active_on_swipe +AFTER INSERT ON swipes +FOR EACH ROW +EXECUTE FUNCTION update_last_active(); + +-- Триггер для обновления времени последней активности при отправке сообщений +CREATE TRIGGER update_last_active_on_message +AFTER INSERT ON messages +FOR EACH ROW +EXECUTE FUNCTION update_last_active(); + +-- Создание функции для автоматического создания профиля при создании пользователя +CREATE OR REPLACE FUNCTION create_initial_profile() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO profiles (user_id, name, age, gender) + VALUES (NEW.id, COALESCE(NEW.first_name, 'User'), 18, 'other'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Триггер для автоматического создания профиля при создании пользователя +CREATE TRIGGER create_profile_trigger +AFTER INSERT ON users +FOR EACH ROW +EXECUTE FUNCTION create_initial_profile(); + +-- Индексы для оптимизации частых запросов +CREATE INDEX IF NOT EXISTS idx_profiles_last_active ON profiles(last_active); +CREATE INDEX IF NOT EXISTS idx_swipes_action ON swipes(action); +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read); +CREATE INDEX IF NOT EXISTS idx_messages_is_read ON messages(is_read); + +-- Добавление ограничений для проверки возраста +ALTER TABLE profiles DROP CONSTRAINT IF EXISTS age_check; +ALTER TABLE profiles ADD CONSTRAINT age_check CHECK (age >= 18 AND age <= 99); + +-- Добавление ограничений для предпочтений +ALTER TABLE search_preferences DROP CONSTRAINT IF EXISTS age_range_check; +ALTER TABLE search_preferences ADD CONSTRAINT age_range_check CHECK (age_min >= 18 AND age_max >= age_min AND age_max <= 99); + +-- Комментарии к таблицам для документации +COMMENT ON TABLE users IS 'Таблица пользователей Telegram'; +COMMENT ON TABLE profiles IS 'Профили пользователей для знакомств'; +COMMENT ON TABLE search_preferences IS 'Предпочтения поиска пользователей'; +COMMENT ON TABLE swipes IS 'История лайков/дислайков'; +COMMENT ON TABLE matches IS 'Совпадения (матчи) между пользователями'; +COMMENT ON TABLE messages IS 'Сообщения между пользователями'; +COMMENT ON TABLE notifications IS 'Уведомления для пользователей'; +COMMENT ON TABLE settings IS 'Настройки пользователей'; +COMMENT ON TABLE profile_views IS 'История просмотров профилей'; +COMMENT ON TABLE premium_features IS 'Премиум-функции для пользователей'; + +-- Представление для быстрого получения активных матчей с информацией о пользователе +CREATE OR REPLACE VIEW active_matches AS +SELECT + m.id AS match_id, + m.created_at AS match_date, + CASE + WHEN m.user_id_1 = u1.id THEN u2.id + ELSE u1.id + END AS partner_id, + CASE + WHEN m.user_id_1 = u1.id THEN u2.telegram_id + ELSE u1.telegram_id + END AS partner_telegram_id, + CASE + WHEN m.user_id_1 = u1.id THEN p2.name + ELSE p1.name + END AS partner_name, + CASE + WHEN m.user_id_1 = u1.id THEN p2.photos[1] + ELSE p1.photos[1] + END AS partner_photo, + (SELECT COUNT(*) FROM messages WHERE match_id = m.id) AS message_count, + (SELECT COUNT(*) FROM messages WHERE match_id = m.id AND is_read = false AND sender_id != u1.id) AS unread_count, + m.is_active +FROM matches m +JOIN users u1 ON m.user_id_1 = u1.id +JOIN users u2 ON m.user_id_2 = u2.id +JOIN profiles p1 ON u1.id = p1.user_id +JOIN profiles p2 ON u2.id = p2.user_id +WHERE m.is_active = true; diff --git a/src/database/migrateOnStartup.ts b/src/database/migrateOnStartup.ts new file mode 100644 index 0000000..55c58d3 --- /dev/null +++ b/src/database/migrateOnStartup.ts @@ -0,0 +1,108 @@ +// Script to run migrations on startup +import { Pool } from 'pg'; +import * as fs from 'fs'; +import * as path from 'path'; +import 'dotenv/config'; + +async function runMigrations() { + console.log('Starting database migration...'); + + // Create a connection pool + const pool = new Pool({ + 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, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + + try { + // Test connection + const testRes = await pool.query('SELECT NOW()'); + console.log(`Database connection successful at ${testRes.rows[0].now}`); + + // Create migrations table if not exists + await pool.query(` + CREATE TABLE IF NOT EXISTS migrations ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Get list of executed migrations + const migrationRes = await pool.query('SELECT name FROM migrations'); + const executedMigrations = migrationRes.rows.map(row => row.name); + console.log(`Found ${executedMigrations.length} executed migrations`); + + // Get migration files + const migrationsPath = path.join(__dirname, 'migrations'); + let migrationFiles = []; + + try { + migrationFiles = fs.readdirSync(migrationsPath) + .filter(file => file.endsWith('.sql')) + .sort(); + console.log(`Found ${migrationFiles.length} migration files`); + } catch (error: any) { + console.error(`Error reading migrations directory: ${error.message}`); + console.log('Continuing with built-in consolidated migration...'); + + // If no external files found, use consolidated.sql + const consolidatedSQL = fs.readFileSync(path.join(__dirname, 'migrations', 'consolidated.sql'), 'utf8'); + + console.log('Executing consolidated migration...'); + await pool.query(consolidatedSQL); + + if (!executedMigrations.includes('consolidated.sql')) { + await pool.query( + 'INSERT INTO migrations (name) VALUES ($1)', + ['consolidated.sql'] + ); + } + + console.log('Consolidated migration completed successfully'); + return; + } + + // Run each migration that hasn't been executed yet + for (const file of migrationFiles) { + if (!executedMigrations.includes(file)) { + console.log(`Executing migration: ${file}`); + const sql = fs.readFileSync(path.join(migrationsPath, file), 'utf8'); + + try { + await pool.query('BEGIN'); + await pool.query(sql); + await pool.query( + 'INSERT INTO migrations (name) VALUES ($1)', + [file] + ); + await pool.query('COMMIT'); + console.log(`Migration ${file} completed successfully`); + } catch (error: any) { + await pool.query('ROLLBACK'); + console.error(`Error executing migration ${file}: ${error.message}`); + throw error; + } + } else { + console.log(`Migration ${file} already executed, skipping`); + } + } + + console.log('All migrations completed successfully!'); + } catch (error: any) { + console.error(`Migration failed: ${error.message}`); + process.exit(1); + } finally { + await pool.end(); + } +} + +runMigrations().catch((error: any) => { + console.error('Unhandled error during migration:', error); + process.exit(1); +}); diff --git a/src/database/migrations/consolidated.sql b/src/database/migrations/consolidated.sql new file mode 100644 index 0000000..2c24dff --- /dev/null +++ b/src/database/migrations/consolidated.sql @@ -0,0 +1,182 @@ +-- Consolidated migrations for Telegram Tinder Bot + +-- Create extension for UUID if not exists +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +---------------------------------------------- +-- Core Tables +---------------------------------------------- + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + telegram_id BIGINT UNIQUE NOT NULL, + username VARCHAR(255), + first_name VARCHAR(255), + last_name VARCHAR(255), + language_code VARCHAR(10) DEFAULT 'ru', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + last_active_at TIMESTAMP DEFAULT NOW(), + premium BOOLEAN DEFAULT FALSE, + state VARCHAR(255), + state_data JSONB DEFAULT '{}'::jsonb +); + +-- Profiles table +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + age INTEGER NOT NULL CHECK (age >= 18 AND age <= 100), + gender VARCHAR(10) NOT NULL CHECK (gender IN ('male', 'female', 'other')), + interested_in VARCHAR(10) NOT NULL CHECK (interested_in IN ('male', 'female', 'both')), + bio TEXT, + photos JSONB DEFAULT '[]', + interests JSONB DEFAULT '[]', + city VARCHAR(255), + education VARCHAR(255), + job VARCHAR(255), + height INTEGER, + location_lat DECIMAL(10, 8), + location_lon DECIMAL(11, 8), + search_min_age INTEGER DEFAULT 18, + search_max_age INTEGER DEFAULT 50, + search_max_distance INTEGER DEFAULT 50, + is_verified BOOLEAN DEFAULT FALSE, + is_visible BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + religion VARCHAR(255), + dating_goal VARCHAR(50), + smoking VARCHAR(20), + drinking VARCHAR(20), + has_kids BOOLEAN DEFAULT FALSE +); + +-- Swipes table +CREATE TABLE IF NOT EXISTS swipes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + target_user_id UUID REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(20) NOT NULL CHECK (type IN ('like', 'pass', 'superlike')), + created_at TIMESTAMP DEFAULT NOW(), + is_match BOOLEAN DEFAULT FALSE, + UNIQUE(user_id, target_user_id) +); + +-- Matches table +CREATE TABLE IF NOT EXISTS matches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id_1 UUID REFERENCES users(id) ON DELETE CASCADE, + user_id_2 UUID REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT NOW(), + last_message_at TIMESTAMP, + is_active BOOLEAN DEFAULT TRUE, + is_super_match BOOLEAN DEFAULT FALSE, + unread_count_1 INTEGER DEFAULT 0, + unread_count_2 INTEGER DEFAULT 0, + UNIQUE(user_id_1, user_id_2) +); + +-- Messages table +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + match_id UUID REFERENCES matches(id) ON DELETE CASCADE, + sender_id UUID REFERENCES users(id) ON DELETE CASCADE, + receiver_id UUID REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + message_type VARCHAR(20) DEFAULT 'text' CHECK (message_type IN ('text', 'photo', 'gif', 'sticker')), + created_at TIMESTAMP DEFAULT NOW(), + is_read BOOLEAN DEFAULT FALSE +); + +---------------------------------------------- +-- Profile Views Table +---------------------------------------------- + +-- Table for tracking profile views +CREATE TABLE IF NOT EXISTS profile_views ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + viewer_id UUID REFERENCES users(id) ON DELETE CASCADE, + viewed_id UUID REFERENCES users(id) ON DELETE CASCADE, + view_type VARCHAR(20) DEFAULT 'browse' CHECK (view_type IN ('browse', 'search', 'recommended')), + viewed_at TIMESTAMP DEFAULT NOW(), + CONSTRAINT unique_profile_view UNIQUE (viewer_id, viewed_id, view_type) +); + +-- Index for profile views +CREATE INDEX IF NOT EXISTS idx_profile_views_viewer ON profile_views(viewer_id); +CREATE INDEX IF NOT EXISTS idx_profile_views_viewed ON profile_views(viewed_id); + +---------------------------------------------- +-- Notification Tables +---------------------------------------------- + +-- Notifications table +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + content JSONB NOT NULL DEFAULT '{}', + is_read BOOLEAN DEFAULT FALSE, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Notification settings table +CREATE TABLE IF NOT EXISTS notification_settings ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + new_matches BOOLEAN DEFAULT TRUE, + new_messages BOOLEAN DEFAULT TRUE, + new_likes BOOLEAN DEFAULT TRUE, + reminders BOOLEAN DEFAULT TRUE, + daily_summary BOOLEAN DEFAULT FALSE, + time_preference VARCHAR(20) DEFAULT 'evening', + do_not_disturb BOOLEAN DEFAULT FALSE, + do_not_disturb_start TIME, + do_not_disturb_end TIME, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Scheduled notifications table +CREATE TABLE IF NOT EXISTS scheduled_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + content JSONB NOT NULL DEFAULT '{}', + scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL, + processed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +---------------------------------------------- +-- Indexes for better performance +---------------------------------------------- + +-- User Indexes +CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id); + +-- Profile Indexes +CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_profiles_location ON profiles(location_lat, location_lon) + WHERE location_lat IS NOT NULL AND location_lon IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_profiles_age_gender ON profiles(age, gender, interested_in); + +-- Swipe Indexes +CREATE INDEX IF NOT EXISTS idx_swipes_user ON swipes(user_id, target_user_id); + +-- Match Indexes +CREATE INDEX IF NOT EXISTS idx_matches_users ON matches(user_id_1, user_id_2); + +-- Message Indexes +CREATE INDEX IF NOT EXISTS idx_messages_match ON messages(match_id, created_at); + +-- Notification Indexes +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_type ON notifications(type); +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at); +CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_user_id ON scheduled_notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_scheduled_at ON scheduled_notifications(scheduled_at); +CREATE INDEX IF NOT EXISTS idx_scheduled_notifications_processed ON scheduled_notifications(processed); diff --git a/src/premium/README.md b/src/premium/README.md new file mode 100644 index 0000000..aa34eef --- /dev/null +++ b/src/premium/README.md @@ -0,0 +1,62 @@ +# Модуль премиум-функций Telegram Tinder Bot + +Этот каталог содержит модули и скрипты для управления премиум-функциями бота. + +## Содержимое + +- `add-premium-columns.js` - Добавление колонок для премиум-функций в базу данных (версия JavaScript) +- `add-premium-columns.ts` - Добавление колонок для премиум-функций в базу данных (версия TypeScript) +- `add-premium-columns-direct.js` - Прямое добавление премиум-колонок без миграций +- `addPremiumColumn.js` - Добавление отдельной колонки премиум в таблицу пользователей +- `setPremiumStatus.js` - Обновление статуса премиум для пользователей + +## Премиум-функции + +В боте реализованы следующие премиум-функции: + +1. **Неограниченные лайки** - снятие дневного лимита на количество лайков +2. **Супер-лайки** - возможность отправлять супер-лайки (повышенный приоритет) +3. **Просмотр лайков** - возможность видеть, кто поставил лайк вашему профилю +4. **Скрытый режим** - возможность скрывать свою активность +5. **Расширенные фильтры** - дополнительные параметры для поиска + +## Использование + +### Добавление премиум-колонок в базу данных + +```bash +node src/premium/add-premium-columns.js +``` + +### Изменение премиум-статуса пользователя + +```typescript +import { PremiumService } from '../services/premiumService'; + +// Установка премиум-статуса для пользователя +const premiumService = new PremiumService(); +await premiumService.setPremiumStatus(userId, true, 30); // 30 дней премиума +``` + +## Интеграция в основной код + +Проверка премиум-статуса должна выполняться следующим образом: + +```typescript +// В классах контроллеров +const isPremium = await this.premiumService.checkUserPremium(userId); + +if (isPremium) { + // Предоставить премиум-функцию +} else { + // Сообщить о необходимости премиум-подписки +} +``` + +## Период действия премиум-статуса + +По умолчанию премиум-статус устанавливается на 30 дней. Для изменения срока используйте третий параметр в методе `setPremiumStatus`. + +## Дополнительная информация + +Более подробная информация о премиум-функциях содержится в документации проекта в каталоге `docs/VIP_FUNCTIONS.md`. diff --git a/scripts/add-premium-columns-direct.js b/src/premium/add-premium-columns-direct.js similarity index 100% rename from scripts/add-premium-columns-direct.js rename to src/premium/add-premium-columns-direct.js diff --git a/scripts/add-premium-columns.js b/src/premium/add-premium-columns.js similarity index 100% rename from scripts/add-premium-columns.js rename to src/premium/add-premium-columns.js diff --git a/scripts/add-premium-columns.ts b/src/premium/add-premium-columns.ts similarity index 100% rename from scripts/add-premium-columns.ts rename to src/premium/add-premium-columns.ts diff --git a/scripts/addPremiumColumn.js b/src/premium/addPremiumColumn.js similarity index 100% rename from scripts/addPremiumColumn.js rename to src/premium/addPremiumColumn.js diff --git a/scripts/setPremiumStatus.js b/src/premium/setPremiumStatus.js similarity index 100% rename from scripts/setPremiumStatus.js rename to src/premium/setPremiumStatus.js diff --git a/start.bat b/start.bat new file mode 100644 index 0000000..6ef2b25 --- /dev/null +++ b/start.bat @@ -0,0 +1,241 @@ +@echo off +:: start.bat - Скрипт для запуска Telegram Tinder Bot на Windows +:: Позволяет выбрать между локальной БД в контейнере или внешней БД + +echo ================================================ +echo Запуск Telegram Tinder Bot +echo ================================================ + +:: Проверка наличия Docker и Docker Compose +WHERE docker >nul 2>&1 +IF %ERRORLEVEL% NEQ 0 ( + echo [31mОШИБКА: Docker не установлен![0m + echo Установите Docker Desktop для Windows: https://docs.docker.com/desktop/install/windows-install/ + exit /b 1 +) + +:: Проверяем наличие .env файла +IF NOT EXIST .env ( + echo [33mФайл .env не найден. Создаем из шаблона...[0m + IF EXIST .env.example ( + copy .env.example .env + echo [32mФайл .env создан из шаблона. Пожалуйста, отредактируйте его с вашими настройками.[0m + ) ELSE ( + echo [31mОШИБКА: Файл .env.example не найден. Создайте файл .env вручную.[0m + exit /b 1 + ) +) + +:: Спрашиваем про запуск базы данных +set /p use_container_db="Запустить базу данных PostgreSQL в контейнере? (y/n): " + +:: Функции для работы с docker-compose +IF /I "%use_container_db%" NEQ "y" ( + :: Запрашиваем параметры подключения к внешней БД + echo [36mВведите параметры подключения к внешней базе данных:[0m + set /p db_host="Хост (например, localhost): " + set /p db_port="Порт (например, 5432): " + set /p db_name="Имя базы данных: " + set /p db_user="Имя пользователя: " + set /p db_password="Пароль: " + + :: Модифицируем docker-compose.yml + echo [33mМодифицируем docker-compose.yml для работы с внешней базой данных...[0m + + :: Сохраняем оригинальную версию файла + copy docker-compose.yml docker-compose.yml.bak + + :: Создаем временный файл с модифицированным содержимым + ( + echo version: '3.8' + echo. + echo services: + echo bot: + echo build: . + echo container_name: telegram-tinder-bot + echo restart: unless-stopped + echo env_file: .env + echo environment: + echo - NODE_ENV=production + echo - DB_HOST=%db_host% + echo - DB_PORT=%db_port% + echo - DB_NAME=%db_name% + echo - DB_USERNAME=%db_user% + echo - DB_PASSWORD=%db_password% + echo volumes: + echo - ./uploads:/app/uploads + echo - ./logs:/app/logs + echo networks: + echo - bot-network + echo healthcheck: + echo test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + echo interval: 30s + echo timeout: 5s + echo retries: 3 + echo start_period: 10s + echo. + echo adminer: + echo image: adminer:latest + echo container_name: adminer-tinder + echo restart: unless-stopped + echo ports: + echo - "8080:8080" + echo networks: + echo - bot-network + echo. + echo volumes: + echo postgres_data: + echo. + echo networks: + echo bot-network: + echo driver: bridge + ) > docker-compose.temp.yml + + :: Заменяем оригинальный файл + move /y docker-compose.temp.yml docker-compose.yml + + echo [32mdocker-compose.yml обновлен для работы с внешней базой данных[0m + + :: Обновляем .env файл + echo [33mОбновляем файл .env с параметрами внешней базы данных...[0m + + :: Создаем временный файл + type NUL > .env.temp + + :: Читаем .env построчно и заменяем нужные строки + for /f "tokens=*" %%a in (.env) do ( + set line=%%a + set line=!line:DB_HOST=*! + if "!line:~0,1!" == "*" ( + echo DB_HOST=%db_host%>> .env.temp + ) else ( + set line=!line:DB_PORT=*! + if "!line:~0,1!" == "*" ( + echo DB_PORT=%db_port%>> .env.temp + ) else ( + set line=!line:DB_NAME=*! + if "!line:~0,1!" == "*" ( + echo DB_NAME=%db_name%>> .env.temp + ) else ( + set line=!line:DB_USERNAME=*! + if "!line:~0,1!" == "*" ( + echo DB_USERNAME=%db_user%>> .env.temp + ) else ( + set line=!line:DB_PASSWORD=*! + if "!line:~0,1!" == "*" ( + echo DB_PASSWORD=%db_password%>> .env.temp + ) else ( + echo %%a>> .env.temp + ) + ) + ) + ) + ) + ) + + :: Заменяем оригинальный файл + move /y .env.temp .env + + echo [32mФайл .env обновлен с параметрами внешней базы данных[0m + + :: Запускаем только контейнер с ботом + echo [36mЗапускаем Telegram Bot без контейнера базы данных...[0m + docker-compose up -d bot adminer + + echo [32mБот запущен и использует внешнюю базу данных: %db_host%:%db_port%/%db_name%[0m + echo [33mAdminer доступен по адресу: http://localhost:8080/[0m + echo [33mДанные для входа в Adminer:[0m + echo [33mСистема: PostgreSQL[0m + echo [33mСервер: %db_host%[0m + echo [33mПользователь: %db_user%[0m + echo [33mПароль: (введенный вами)[0m + echo [33mБаза данных: %db_name%[0m +) ELSE ( + :: Восстанавливаем оригинальный docker-compose.yml если есть бэкап + if exist docker-compose.yml.bak ( + move /y docker-compose.yml.bak docker-compose.yml + echo [32mdocker-compose.yml восстановлен из резервной копии[0m + ) + + echo [36mЗапускаем Telegram Bot с контейнером базы данных...[0m + + :: Проверка, запущены ли контейнеры + docker ps -q -f name=telegram-tinder-bot > tmp_containers.txt + set /p containers= .env.temp + + :: Читаем .env построчно и заменяем строку с паролем + for /f "tokens=*" %%a in (.env) do ( + set line=%%a + set line=!line:DB_PASSWORD=*! + if "!line:~0,1!" == "*" ( + echo DB_PASSWORD=%random_password%>> .env.temp + ) else ( + echo %%a>> .env.temp + ) + ) + + :: Заменяем оригинальный файл + move /y .env.temp .env + + echo [33mСгенерирован случайный пароль для базы данных и сохранен в .env[0m + ) + + echo [32mTelegram Bot запущен с локальной базой данных[0m + echo [33mAdminer доступен по адресу: http://localhost:8080/[0m + echo [33mДанные для входа в Adminer:[0m + echo [33mСистема: PostgreSQL[0m + echo [33mСервер: db[0m + echo [33mПользователь: postgres[0m + echo [33mПароль: (из переменной DB_PASSWORD в .env)[0m + echo [33mБаза данных: telegram_tinder_bot[0m +) + +:: Проверка статуса контейнеров +echo [36mПроверка статуса контейнеров:[0m +docker-compose ps + +echo ================================================ +echo [32mПроцесс запуска Telegram Tinder Bot завершен![0m +echo ================================================ +echo [33mДля просмотра логов используйте: docker-compose logs -f bot[0m +echo [33mДля остановки: docker-compose down[0m + +pause diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..dd2674e --- /dev/null +++ b/start.ps1 @@ -0,0 +1,229 @@ +function createModifiedDockerCompose { + param ( + [string]$dbHost, + [string]$dbPort, + [string]$dbName, + [string]$dbUser, + [string]$dbPassword + ) + + $dockerComposeContent = @" +version: '3.8' + +services: + bot: + build: . + container_name: telegram-tinder-bot + restart: unless-stopped + env_file: .env + environment: + - NODE_ENV=production + - DB_HOST=$dbHost + - DB_PORT=$dbPort + - DB_NAME=$dbName + - DB_USERNAME=$dbUser + - DB_PASSWORD=$dbPassword + volumes: + - ./uploads:/app/uploads + - ./logs:/app/logs + networks: + - bot-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + adminer: + image: adminer:latest + container_name: adminer-tinder + restart: unless-stopped + ports: + - "8080:8080" + networks: + - bot-network + +volumes: + postgres_data: + +networks: + bot-network: + driver: bridge +"@ + + return $dockerComposeContent +} + +function restoreDockerCompose { + if (Test-Path -Path "docker-compose.yml.bak") { + Copy-Item -Path "docker-compose.yml.bak" -Destination "docker-compose.yml" -Force + Write-Host "docker-compose.yml восстановлен из резервной копии" -ForegroundColor Green + } +} + +function updateEnvFile { + param ( + [string]$dbHost, + [string]$dbPort, + [string]$dbName, + [string]$dbUser, + [string]$dbPassword + ) + + $envContent = Get-Content -Path ".env" -Raw + + $envContent = $envContent -replace "DB_HOST=.*", "DB_HOST=$dbHost" + $envContent = $envContent -replace "DB_PORT=.*", "DB_PORT=$dbPort" + $envContent = $envContent -replace "DB_NAME=.*", "DB_NAME=$dbName" + $envContent = $envContent -replace "DB_USERNAME=.*", "DB_USERNAME=$dbUser" + $envContent = $envContent -replace "DB_PASSWORD=.*", "DB_PASSWORD=$dbPassword" + + Set-Content -Path ".env" -Value $envContent + + Write-Host "Файл .env обновлен с параметрами внешней базы данных" -ForegroundColor Green +} + +function generateRandomPassword { + $length = 16 + $chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + $bytes = New-Object Byte[] $length + $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new() + $rng.GetBytes($bytes) + + $password = "" + for ($i = 0; $i -lt $length; $i++) { + $password += $chars[$bytes[$i] % $chars.Length] + } + + return $password +} + +# Начало основного скрипта +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host " Запуск Telegram Tinder Bot" -ForegroundColor Cyan +Write-Host "==================================================" -ForegroundColor Cyan + +# Проверка наличия Docker +try { + docker --version | Out-Null +} catch { + Write-Host "ОШИБКА: Docker не установлен!" -ForegroundColor Red + Write-Host "Установите Docker Desktop для Windows: https://docs.docker.com/desktop/install/windows-install/" + exit +} + +# Проверяем наличие .env файла +if (-not (Test-Path -Path ".env")) { + Write-Host "Файл .env не найден. Создаем из шаблона..." -ForegroundColor Yellow + + if (Test-Path -Path ".env.example") { + Copy-Item -Path ".env.example" -Destination ".env" + Write-Host "Файл .env создан из шаблона. Пожалуйста, отредактируйте его с вашими настройками." -ForegroundColor Green + } else { + Write-Host "ОШИБКА: Файл .env.example не найден. Создайте файл .env вручную." -ForegroundColor Red + exit + } +} + +# Спрашиваем про запуск базы данных +$useContainerDb = Read-Host "Запустить базу данных PostgreSQL в контейнере? (y/n)" + +if ($useContainerDb -ne "y") { + # Запрашиваем параметры подключения к внешней БД + Write-Host "Введите параметры подключения к внешней базе данных:" -ForegroundColor Cyan + $dbHost = Read-Host "Хост (например, localhost)" + $dbPort = Read-Host "Порт (например, 5432)" + $dbName = Read-Host "Имя базы данных" + $dbUser = Read-Host "Имя пользователя" + $dbPassword = Read-Host "Пароль" -AsSecureString + $dbPasswordText = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($dbPassword)) + + # Модифицируем docker-compose.yml + Write-Host "Модифицируем docker-compose.yml для работы с внешней базой данных..." -ForegroundColor Yellow + + # Сохраняем оригинальную версию файла + Copy-Item -Path "docker-compose.yml" -Destination "docker-compose.yml.bak" -Force + + # Создаем модифицированный docker-compose.yml + $dockerComposeContent = createModifiedDockerCompose -dbHost $dbHost -dbPort $dbPort -dbName $dbName -dbUser $dbUser -dbPassword $dbPasswordText + Set-Content -Path "docker-compose.yml" -Value $dockerComposeContent + + Write-Host "docker-compose.yml обновлен для работы с внешней базой данных" -ForegroundColor Green + + # Обновляем .env файл + Write-Host "Обновляем файл .env с параметрами внешней базы данных..." -ForegroundColor Yellow + updateEnvFile -dbHost $dbHost -dbPort $dbPort -dbName $dbName -dbUser $dbUser -dbPassword $dbPasswordText + + # Запускаем только контейнер с ботом + Write-Host "Запускаем Telegram Bot без контейнера базы данных..." -ForegroundColor Cyan + docker-compose up -d bot adminer + + Write-Host "Бот запущен и использует внешнюю базу данных: $dbHost`:$dbPort/$dbName" -ForegroundColor Green + Write-Host "Adminer доступен по адресу: http://localhost:8080/" -ForegroundColor Yellow + Write-Host "Данные для входа в Adminer:" -ForegroundColor Yellow + Write-Host "Система: PostgreSQL" -ForegroundColor Yellow + Write-Host "Сервер: $dbHost" -ForegroundColor Yellow + Write-Host "Пользователь: $dbUser" -ForegroundColor Yellow + Write-Host "Пароль: (введенный вами)" -ForegroundColor Yellow + Write-Host "База данных: $dbName" -ForegroundColor Yellow +} else { + # Восстанавливаем оригинальный docker-compose.yml если есть бэкап + restoreDockerCompose + + Write-Host "Запускаем Telegram Bot с контейнером базы данных..." -ForegroundColor Cyan + + # Проверка, запущены ли контейнеры + $containers = docker ps -q -f name=telegram-tinder-bot -f name=postgres-tinder + + if ($containers) { + $restartContainers = Read-Host "Контейнеры уже запущены. Перезапустить? (y/n)" + if ($restartContainers -eq "y") { + docker-compose down + docker-compose up -d + Write-Host "Контейнеры перезапущены" -ForegroundColor Green + } else { + Write-Host "Продолжаем работу с уже запущенными контейнерами" -ForegroundColor Cyan + } + } else { + docker-compose up -d + Write-Host "Контейнеры запущены" -ForegroundColor Green + } + + # Проверка наличия пароля для БД в .env + $envContent = Get-Content -Path ".env" -Raw + $match = [Regex]::Match($envContent, "DB_PASSWORD=(.*)(\r?\n|$)") + $dbPassword = if ($match.Success) { $match.Groups[1].Value.Trim() } else { "" } + + if ([string]::IsNullOrWhiteSpace($dbPassword)) { + # Генерируем случайный пароль + $randomPassword = generateRandomPassword + + # Обновляем .env файл + $envContent = $envContent -replace "DB_PASSWORD=.*", "DB_PASSWORD=$randomPassword" + Set-Content -Path ".env" -Value $envContent + + Write-Host "Сгенерирован случайный пароль для базы данных и сохранен в .env" -ForegroundColor Yellow + } + + Write-Host "Telegram Bot запущен с локальной базой данных" -ForegroundColor Green + Write-Host "Adminer доступен по адресу: http://localhost:8080/" -ForegroundColor Yellow + Write-Host "Данные для входа в Adminer:" -ForegroundColor Yellow + Write-Host "Система: PostgreSQL" -ForegroundColor Yellow + Write-Host "Сервер: db" -ForegroundColor Yellow + Write-Host "Пользователь: postgres" -ForegroundColor Yellow + Write-Host "Пароль: (из переменной DB_PASSWORD в .env)" -ForegroundColor Yellow + Write-Host "База данных: telegram_tinder_bot" -ForegroundColor Yellow +} + +# Проверка статуса контейнеров +Write-Host "Проверка статуса контейнеров:" -ForegroundColor Cyan +docker-compose ps + +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host "Процесс запуска Telegram Tinder Bot завершен!" -ForegroundColor Green +Write-Host "==================================================" -ForegroundColor Cyan +Write-Host "Для просмотра логов используйте: docker-compose logs -f bot" -ForegroundColor Yellow +Write-Host "Для остановки: docker-compose down" -ForegroundColor Yellow + +Read-Host "Нажмите Enter для выхода" diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..b458b66 --- /dev/null +++ b/start.sh @@ -0,0 +1,217 @@ +#!/bin/bash +# start.sh - Скрипт для запуска Telegram Tinder Bot +# Позволяет выбрать между локальной БД в контейнере или внешней БД + +# Цвета для вывода +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[0;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${BLUE}==================================================${NC}" +echo -e "${BLUE} Запуск Telegram Tinder Bot ${NC}" +echo -e "${BLUE}==================================================${NC}" + +# Проверка наличия Docker и Docker Compose +if ! command -v docker &> /dev/null || ! command -v docker-compose &> /dev/null; then + echo -e "${RED}ОШИБКА: Docker и/или Docker Compose не установлены!${NC}" + echo -e "Для установки Docker следуйте инструкции на: https://docs.docker.com/get-docker/" + exit 1 +fi + +# Проверяем наличие .env файла +if [ ! -f .env ]; then + echo -e "${YELLOW}Файл .env не найден. Создаем из шаблона...${NC}" + if [ -f .env.example ]; then + cp .env.example .env + echo -e "${GREEN}Файл .env создан из шаблона. Пожалуйста, отредактируйте его с вашими настройками.${NC}" + else + echo -e "${RED}ОШИБКА: Файл .env.example не найден. Создайте файл .env вручную.${NC}" + exit 1 + fi +fi + +# Спрашиваем про запуск базы данных +read -p "Запустить базу данных PostgreSQL в контейнере? (y/n): " use_container_db + +# Функция для изменения docker-compose.yml +modify_docker_compose() { + local host=$1 + local port=$2 + local user=$3 + local password=$4 + local db_name=$5 + + echo -e "${YELLOW}Модифицируем docker-compose.yml для работы с внешней базой данных...${NC}" + + # Сохраняем оригинальную версию файла + cp docker-compose.yml docker-compose.yml.bak + + # Создаем временный файл с модифицированным содержимым + cat > docker-compose.temp.yml << EOL +version: '3.8' + +services: + bot: + build: . + container_name: telegram-tinder-bot + restart: unless-stopped + env_file: .env + environment: + - NODE_ENV=production + - DB_HOST=${host} + - DB_PORT=${port} + - DB_NAME=${db_name} + - DB_USERNAME=${user} + - DB_PASSWORD=${password} + volumes: + - ./uploads:/app/uploads + - ./logs:/app/logs + networks: + - bot-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + adminer: + image: adminer:latest + container_name: adminer-tinder + restart: unless-stopped + ports: + - "8080:8080" + networks: + - bot-network + +volumes: + postgres_data: + +networks: + bot-network: + driver: bridge +EOL + + # Заменяем оригинальный файл + mv docker-compose.temp.yml docker-compose.yml + + echo -e "${GREEN}docker-compose.yml обновлен для работы с внешней базой данных${NC}" +} + +# Функция для восстановления docker-compose.yml +restore_docker_compose() { + if [ -f docker-compose.yml.bak ]; then + mv docker-compose.yml.bak docker-compose.yml + echo -e "${GREEN}docker-compose.yml восстановлен из резервной копии${NC}" + fi +} + +# Обработка выбора +if [[ "$use_container_db" =~ ^[Nn]$ ]]; then + # Запрашиваем параметры подключения к внешней БД + echo -e "${BLUE}Введите параметры подключения к внешней базе данных:${NC}" + read -p "Хост (например, localhost): " db_host + read -p "Порт (например, 5432): " db_port + read -p "Имя базы данных: " db_name + read -p "Имя пользователя: " db_user + read -p "Пароль: " db_password + + # Модифицируем docker-compose.yml + modify_docker_compose "$db_host" "$db_port" "$db_user" "$db_password" "$db_name" + + # Обновляем .env файл + echo -e "${YELLOW}Обновляем файл .env с параметрами внешней базы данных...${NC}" + + # Используем sed для замены переменных в .env + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS требует другой синтаксис для sed + sed -i '' "s/DB_HOST=.*/DB_HOST=${db_host}/" .env + sed -i '' "s/DB_PORT=.*/DB_PORT=${db_port}/" .env + sed -i '' "s/DB_NAME=.*/DB_NAME=${db_name}/" .env + sed -i '' "s/DB_USERNAME=.*/DB_USERNAME=${db_user}/" .env + sed -i '' "s/DB_PASSWORD=.*/DB_PASSWORD=${db_password}/" .env + else + # Linux и другие системы + sed -i "s/DB_HOST=.*/DB_HOST=${db_host}/" .env + sed -i "s/DB_PORT=.*/DB_PORT=${db_port}/" .env + sed -i "s/DB_NAME=.*/DB_NAME=${db_name}/" .env + sed -i "s/DB_USERNAME=.*/DB_USERNAME=${db_user}/" .env + sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=${db_password}/" .env + fi + + echo -e "${GREEN}Файл .env обновлен с параметрами внешней базы данных${NC}" + + # Запускаем только контейнер с ботом + echo -e "${BLUE}Запускаем Telegram Bot без контейнера базы данных...${NC}" + docker-compose up -d bot adminer + + echo -e "${GREEN}Бот запущен и использует внешнюю базу данных: ${db_host}:${db_port}/${db_name}${NC}" + echo -e "${YELLOW}Adminer доступен по адресу: http://localhost:8080/${NC}" + echo -e "${YELLOW}Данные для входа в Adminer:${NC}" + echo -e "${YELLOW}Система: PostgreSQL${NC}" + echo -e "${YELLOW}Сервер: ${db_host}${NC}" + echo -e "${YELLOW}Пользователь: ${db_user}${NC}" + echo -e "${YELLOW}Пароль: (введенный вами)${NC}" + echo -e "${YELLOW}База данных: ${db_name}${NC}" +else + # Восстанавливаем оригинальный docker-compose.yml если есть бэкап + restore_docker_compose + + echo -e "${BLUE}Запускаем Telegram Bot с контейнером базы данных...${NC}" + + # Проверка, запущены ли контейнеры + containers=$(docker ps -q -f name=telegram-tinder-bot -f name=postgres-tinder) + if [ -n "$containers" ]; then + echo -e "${YELLOW}Контейнеры уже запущены. Перезапустить? (y/n): ${NC}" + read restart_containers + if [[ "$restart_containers" =~ ^[Yy]$ ]]; then + docker-compose down + docker-compose up -d + echo -e "${GREEN}Контейнеры перезапущены${NC}" + else + echo -e "${BLUE}Продолжаем работу с уже запущенными контейнерами${NC}" + fi + else + docker-compose up -d + echo -e "${GREEN}Контейнеры запущены${NC}" + fi + + # Проверка наличия пароля для БД в .env + db_password=$(grep DB_PASSWORD .env | cut -d '=' -f2) + if [ -z "$db_password" ]; then + # Генерируем случайный пароль + random_password=$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 16) + + # Обновляем .env файл + if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS требует другой синтаксис для sed + sed -i '' "s/DB_PASSWORD=.*/DB_PASSWORD=${random_password}/" .env + else + # Linux и другие системы + sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=${random_password}/" .env + fi + + echo -e "${YELLOW}Сгенерирован случайный пароль для базы данных и сохранен в .env${NC}" + fi + + echo -e "${GREEN}Telegram Bot запущен с локальной базой данных${NC}" + echo -e "${YELLOW}Adminer доступен по адресу: http://localhost:8080/${NC}" + echo -e "${YELLOW}Данные для входа в Adminer:${NC}" + echo -e "${YELLOW}Система: PostgreSQL${NC}" + echo -e "${YELLOW}Сервер: db${NC}" + echo -e "${YELLOW}Пользователь: postgres${NC}" + echo -e "${YELLOW}Пароль: (из переменной DB_PASSWORD в .env)${NC}" + echo -e "${YELLOW}База данных: telegram_tinder_bot${NC}" +fi + +# Проверка статуса контейнеров +echo -e "${BLUE}Проверка статуса контейнеров:${NC}" +docker-compose ps + +echo -e "${BLUE}==================================================${NC}" +echo -e "${GREEN}Процесс запуска Telegram Tinder Bot завершен!${NC}" +echo -e "${BLUE}==================================================${NC}" +echo -e "${YELLOW}Для просмотра логов используйте: docker-compose logs -f bot${NC}" +echo -e "${YELLOW}Для остановки: docker-compose down${NC}" diff --git a/test-connection.js b/test-connection.js deleted file mode 100644 index 56810cd..0000000 --- a/test-connection.js +++ /dev/null @@ -1,37 +0,0 @@ -require('dotenv').config(); -const { Pool } = require('pg'); - -// Используем параметры напрямую из .env -const pool = new Pool({ - host: process.env.DB_HOST, - port: process.env.DB_PORT, - database: process.env.DB_NAME, - user: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - max: 5, - connectionTimeoutMillis: 5000 -}); - -console.log('DB Connection Details:'); -console.log(`- Host: ${process.env.DB_HOST}`); -console.log(`- Port: ${process.env.DB_PORT}`); -console.log(`- Database: ${process.env.DB_NAME}`); -console.log(`- User: ${process.env.DB_USERNAME}`); - -async function testConnection() { - try { - const client = await pool.connect(); - try { - const result = await client.query('SELECT NOW() as current_time'); - console.log('✅ Connected to database successfully!'); - console.log(`Current database time: ${result.rows[0].current_time}`); - } finally { - client.release(); - } - await pool.end(); - } catch (error) { - console.error('❌ Failed to connect to database:', error); - } -} - -testConnection(); diff --git a/test-db.js b/test-db.js deleted file mode 100644 index e69de29..0000000