import { v4 as uuidv4 } from 'uuid'; import { query, transaction } from '../database/connection'; import { Swipe, SwipeData, SwipeType } from '../models/Swipe'; import { Match, MatchData } from '../models/Match'; import { Profile } from '../models/Profile'; import { ProfileService } from './profileService'; import { NotificationService } from './notificationService'; import { BotError } from '../types'; export class MatchingService { private profileService: ProfileService; private notificationService: NotificationService; constructor() { this.profileService = new ProfileService(); this.notificationService = new NotificationService(); } // Выполнить свайп async performSwipe(telegramId: string, targetTelegramId: string, swipeType: SwipeType): Promise<{ swipe: Swipe; isMatch: boolean; match?: Match; }> { // Получить профили пользователей const userProfile = await this.profileService.getProfileByTelegramId(telegramId); const targetProfile = await this.profileService.getProfileByTelegramId(targetTelegramId); if (!userProfile || !targetProfile) { throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 400); } const userId = userProfile.userId; const targetUserId = targetProfile.userId; // Проверяем, что пользователь не свайпает сам себя if (userId === targetUserId) { throw new BotError('Cannot swipe yourself', 'INVALID_SWIPE'); } // Проверяем, что свайп еще не был сделан const existingSwipe = await this.getSwipe(userId, targetUserId); if (existingSwipe) { throw new BotError('Already swiped this profile', 'ALREADY_SWIPED'); } 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) VALUES ($1, $2, $3, $4, $5) `, [matchId, user1Id, user2Id, new Date(), true]); match = new Match({ id: matchId, userId1: user1Id, userId2: user2Id, createdAt: new Date(), isActive: true, isSuperMatch: isSuperMatch, unreadCount1: 0, unreadCount2: 0 }); } } } }); const swipe = new Swipe({ id: swipeId, userId, targetUserId, type: swipeType, timestamp: new Date(), isMatch }); // Отправляем уведомления if (swipeType === 'like' || swipeType === 'superlike') { this.notificationService.sendLikeNotification(targetTelegramId, telegramId, swipeType === 'superlike'); } if (isMatch && match) { this.notificationService.sendMatchNotification(userId, targetUserId); this.notificationService.sendMatchNotification(targetUserId, userId); } return { swipe, isMatch, match }; } // Получить свайп между двумя пользователями async getSwipe(userId: string, targetUserId: string): Promise { const result = await query(` SELECT * FROM swipes WHERE user_id = $1 AND target_user_id = $2 `, [userId, targetUserId]); if (result.rows.length === 0) { return null; } 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 { // Сначала получаем userId по telegramId const userId = await this.profileService.getUserIdByTelegramId(telegramId); if (!userId) { return []; } const result = await query(` SELECT * FROM matches WHERE (user_id_1 = $1 OR user_id_2 = $1) AND is_active = true ORDER BY created_at DESC LIMIT $2 `, [userId, limit]); return result.rows.map((row: any) => this.mapEntityToMatch(row)); } // Получить матч по ID async getMatchById(matchId: string): Promise { const result = await query(` SELECT * FROM matches WHERE id = $1 `, [matchId]); if (result.rows.length === 0) { return null; } return this.mapEntityToMatch(result.rows[0]); } // Получить матч между двумя пользователями async getMatchBetweenUsers(userId1: string, userId2: string): Promise { const result = await query(` SELECT * 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 `, [userId1, userId2]); if (result.rows.length === 0) { return null; } return this.mapEntityToMatch(result.rows[0]); } // Размэтчить (деактивировать матч) async unmatch(userId: string, matchId: string): Promise { const match = await this.getMatchById(matchId); if (!match || !match.includesUser(userId)) { throw new BotError('Match not found or access denied', 'MATCH_NOT_FOUND'); } await query(` UPDATE matches SET is_active = false WHERE id = $1 `, [matchId]); return true; } // Получить недавние лайки async getRecentLikes(userId: string, limit: number = 20): Promise { const result = await query(` SELECT * FROM swipes WHERE target_user_id = $1 AND type IN ('like', 'superlike') AND is_match = false ORDER BY created_at DESC LIMIT $2 `, [userId, limit]); return result.rows.map((row: any) => this.mapEntityToSwipe(row)); } // Получить статистику свайпов пользователя за день async getDailySwipeStats(userId: string): Promise<{ likes: number; superlikes: number; passes: number; total: number; }> { const today = new Date(); today.setHours(0, 0, 0, 0); const result = await query(` SELECT type, COUNT(*) as count FROM swipes WHERE user_id = $1 AND created_at >= $2 GROUP BY type `, [userId, today]); const stats = { likes: 0, superlikes: 0, passes: 0, total: 0 }; result.rows.forEach((row: any) => { const count = parseInt(row.count); stats.total += count; switch (row.type) { case 'like': stats.likes = count; break; case 'superlike': stats.superlikes = count; break; case 'pass': stats.passes = count; break; } }); return stats; } // Проверить лимиты свайпов async checkSwipeLimits(userId: string): Promise<{ canLike: boolean; canSuperLike: boolean; likesLeft: number; superLikesLeft: number; }> { const stats = await this.getDailySwipeStats(userId); const likesPerDay = 100; // Из конфига const superLikesPerDay = 1; // Из конфига return { canLike: stats.likes < likesPerDay, canSuperLike: stats.superlikes < superLikesPerDay, likesLeft: Math.max(0, likesPerDay - stats.likes), superLikesLeft: Math.max(0, superLikesPerDay - stats.superlikes) }; } // Получить рекомендации для пользователя async getRecommendations(userId: string, limit: number = 10): Promise { return this.profileService.findCompatibleProfiles(userId, limit) .then(profiles => profiles.map(p => p.userId)); } // Преобразование entity в модель Swipe private mapEntityToSwipe(entity: any): Swipe { return new Swipe({ id: entity.id, 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 }); } // Преобразование entity в модель Match private mapEntityToMatch(entity: any): Match { return new Match({ id: entity.id, userId1: entity.user_id_1, userId2: entity.user_id_2, createdAt: entity.created_at, lastMessageAt: entity.last_message_at, isActive: entity.is_active === true, isSuperMatch: false, // Определяется из swipes если нужно unreadCount1: 0, unreadCount2: 0 }); } // Получить взаимные лайки (потенциальные матчи) async getMutualLikes(userId: string): Promise { const result = await query(` SELECT DISTINCT s1.target_user_id 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.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) OR (m.user_id_1 = s1.target_user_id AND m.user_id_2 = s1.user_id) ) `, [userId]); return result.rows.map((row: any) => row.target_user_id); } // Получить следующего кандидата для просмотра 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; // Определяем, каким должен быть пол показываемых профилей let targetGender: string; if (userProfile.interestedIn === 'male' || userProfile.interestedIn === 'female') { targetGender = userProfile.interestedIn; } else { // Если "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 = '${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 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 для правильного маппинга данных const profile = this.profileService.mapEntityToProfile(candidateData); console.log(`[DEBUG] Профиль преобразован и возвращается клиенту`); return profile; } // VIP функция: поиск кандидатов по цели знакомства async getCandidatesWithGoal(userProfile: Profile, targetGoal: string): Promise { const swipedUsersResult = await query(` SELECT target_user_id FROM swipes WHERE user_id = $1 `, [userProfile.userId]); const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.target_user_id); swipedUserIds.push(userProfile.userId); // Исключаем себя let candidateQuery = ` SELECT DISTINCT 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 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(', ')}) ORDER BY p.created_at DESC LIMIT 50 `; const params = [userProfile.interestedIn, targetGoal, ...swipedUserIds]; const result = await query(candidateQuery, params); return result.rows.map((row: any) => this.profileService.mapEntityToProfile(row)); } }