- Added a new button for '⭐ VIP поиск' in command handlers.
- Implemented profile editing states and methods in message handlers.
- Enhanced profile model to include hobbies, religion, dating goals, and lifestyle preferences.
- Updated profile service to handle new fields and ensure proper database interactions.
- Introduced a VIP function in matching service to find candidates based on dating goals.
414 lines
15 KiB
TypeScript
414 lines
15 KiB
TypeScript
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();
|
|
}
|
|
|
|
// Выполнить свайп
|
|
// Конвертация типов свайпов между 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;
|
|
isMatch: boolean;
|
|
match?: Match;
|
|
}> {
|
|
|
|
// Получить профили пользователей
|
|
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
|
|
const targetProfile = await this.profileService.getProfileByUserId(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();
|
|
const direction = this.convertSwipeTypeToDirection(swipeType);
|
|
let isMatch = false;
|
|
let match: Match | undefined;
|
|
|
|
await transaction(async (client) => {
|
|
// Создаем свайп
|
|
await client.query(`
|
|
INSERT INTO swipes (id, swiper_id, swiped_id, direction, created_at)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, [swipeId, userId, targetUserId, direction, 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 ('like', 'super')
|
|
`, [targetUserId, userId]);
|
|
|
|
if (reciprocalSwipe.rows.length > 0) {
|
|
isMatch = true;
|
|
const matchId = uuidv4();
|
|
const isSuperMatch = swipeType === 'superlike' || reciprocalSwipe.rows[0].direction === 'super';
|
|
|
|
// Создаем матч
|
|
await client.query(`
|
|
INSERT INTO matches (id, user1_id, user2_id, matched_at, status)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, [matchId, userId, targetUserId, new Date(), 'active']);
|
|
|
|
match = new Match({
|
|
id: matchId,
|
|
userId1: userId,
|
|
userId2: targetUserId,
|
|
createdAt: new Date(),
|
|
isActive: true,
|
|
isSuperMatch: false,
|
|
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<Swipe | null> {
|
|
const result = await query(`
|
|
SELECT * FROM swipes
|
|
WHERE swiper_id = $1 AND swiped_id = $2
|
|
`, [userId, targetUserId]);
|
|
|
|
if (result.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return this.mapEntityToSwipe(result.rows[0]);
|
|
}
|
|
|
|
// Получить все матчи пользователя по telegram ID
|
|
async getUserMatches(telegramId: string, limit: number = 50): Promise<Match[]> {
|
|
// Сначала получаем userId по telegramId
|
|
const userId = await this.profileService.getUserIdByTelegramId(telegramId);
|
|
if (!userId) {
|
|
return [];
|
|
}
|
|
|
|
const result = await query(`
|
|
SELECT * FROM matches
|
|
WHERE (user1_id = $1 OR user2_id = $1) AND status = 'active'
|
|
ORDER BY matched_at DESC
|
|
LIMIT $2
|
|
`, [userId, limit]);
|
|
|
|
return result.rows.map((row: any) => this.mapEntityToMatch(row));
|
|
}
|
|
|
|
// Получить матч по ID
|
|
async getMatchById(matchId: string): Promise<Match | null> {
|
|
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<Match | null> {
|
|
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<boolean> {
|
|
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<Swipe[]> {
|
|
const result = await query(`
|
|
SELECT * FROM swipes
|
|
WHERE swiped_id = $1 AND direction IN ('like', 'super') 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 direction, COUNT(*) as count
|
|
FROM swipes
|
|
WHERE swiper_id = $1 AND created_at >= $2
|
|
GROUP BY direction
|
|
`, [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.direction) {
|
|
case 'like':
|
|
stats.likes = count;
|
|
break;
|
|
case 'super':
|
|
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<string[]> {
|
|
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.swiper_id,
|
|
targetUserId: entity.swiped_id,
|
|
type: this.convertDirectionToSwipeType(entity.direction),
|
|
timestamp: entity.created_at,
|
|
isMatch: entity.is_match
|
|
});
|
|
}
|
|
|
|
// Преобразование entity в модель Match
|
|
private mapEntityToMatch(entity: any): Match {
|
|
return new Match({
|
|
id: entity.id,
|
|
userId1: entity.user1_id,
|
|
userId2: entity.user2_id,
|
|
createdAt: entity.matched_at || entity.created_at,
|
|
lastMessageAt: entity.last_message_at,
|
|
isActive: entity.status === 'active',
|
|
isSuperMatch: false, // Определяется из swipes если нужно
|
|
unreadCount1: 0,
|
|
unreadCount2: 0
|
|
});
|
|
}
|
|
|
|
// Получить взаимные лайки (потенциальные матчи)
|
|
async getMutualLikes(userId: string): Promise<string[]> {
|
|
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): Promise<Profile | null> {
|
|
// Сначала получаем профиль пользователя по telegramId
|
|
const userProfile = await this.profileService.getProfileByTelegramId(telegramId);
|
|
if (!userProfile) {
|
|
throw new BotError('User profile not found', 'PROFILE_NOT_FOUND');
|
|
}
|
|
|
|
// Получаем UUID пользователя
|
|
const userId = userProfile.userId;
|
|
|
|
// Получаем список уже просмотренных пользователей
|
|
const viewedUsers = await query(`
|
|
SELECT DISTINCT swiped_id
|
|
FROM swipes
|
|
WHERE swiper_id = $1
|
|
`, [userId]);
|
|
|
|
const viewedUserIds = viewedUsers.rows.map((row: any) => row.swiped_id);
|
|
viewedUserIds.push(userId); // Исключаем самого себя
|
|
|
|
// Формируем условие для исключения уже просмотренных
|
|
const excludeCondition = viewedUserIds.length > 0
|
|
? `AND p.user_id NOT IN (${viewedUserIds.map((_: any, i: number) => `$${i + 2}`).join(', ')})`
|
|
: '';
|
|
|
|
// Ищем подходящих кандидатов
|
|
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.age BETWEEN ${userProfile.searchPreferences.minAge} AND ${userProfile.searchPreferences.maxAge}
|
|
${excludeCondition}
|
|
ORDER BY RANDOM()
|
|
LIMIT 1
|
|
`;
|
|
|
|
const params = [userProfile.interestedIn, ...viewedUserIds];
|
|
const result = await query(candidateQuery, params);
|
|
|
|
if (result.rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const candidateData = result.rows[0];
|
|
|
|
// Используем ProfileService для правильного маппинга данных
|
|
return this.profileService.mapEntityToProfile(candidateData);
|
|
}
|
|
|
|
// VIP функция: поиск кандидатов по цели знакомства
|
|
async getCandidatesWithGoal(userProfile: Profile, targetGoal: string): Promise<Profile[]> {
|
|
const swipedUsersResult = await query(`
|
|
SELECT swiped_id
|
|
FROM swipes
|
|
WHERE swiper_id = $1
|
|
`, [userProfile.userId]);
|
|
|
|
const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.swiped_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 p.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));
|
|
}
|
|
} |