init commit

This commit is contained in:
2025-09-12 21:25:54 +09:00
commit 17efb2fb53
37 changed files with 12637 additions and 0 deletions

View 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);
}
}