init commit
This commit is contained in:
384
src/services/matchingService.ts
Normal file
384
src/services/matchingService.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user