import { v4 as uuidv4 } from 'uuid'; import { query, transaction } from '../database/connection'; import { Profile, ProfileData } from '../models/Profile'; import { User } from '../models/User'; import { ProfileEntity, UserEntity, ValidationResult, BotError } from '../types'; export class ProfileService { // Создание нового профиля async createProfile(userId: string, profileData: Partial): Promise { const validation = this.validateProfileData(profileData); if (!validation.isValid) { throw new BotError(validation.errors.join(', '), 'VALIDATION_ERROR'); } const profileId = uuidv4(); const now = new Date(); const profile = new Profile({ userId, name: profileData.name!, age: profileData.age!, gender: profileData.gender!, interestedIn: profileData.interestedIn!, bio: profileData.bio, photos: profileData.photos || [], interests: profileData.interests || [], city: profileData.city, education: profileData.education, job: profileData.job, height: profileData.height, location: profileData.location, searchPreferences: profileData.searchPreferences || { minAge: 18, maxAge: 50, maxDistance: 50 }, isVerified: false, isVisible: true, createdAt: now, updatedAt: now }); // Сохранение в базу данных await query(` INSERT INTO profiles ( id, user_id, name, age, gender, looking_for, bio, photos, interests, hobbies, location, education, occupation, height, religion, dating_goal, latitude, longitude, verification_status, is_active, is_visible, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23) `, [ profileId, userId, profile.name, profile.age, profile.gender, profile.interestedIn, profile.bio, profile.photos, profile.interests, profile.hobbies, profile.city, profile.education, profile.job, profile.height, profile.religion, profile.datingGoal, profile.location?.latitude, profile.location?.longitude, 'unverified', true, profile.isVisible, profile.createdAt, profile.updatedAt ]); return profile; } // Получение профиля по ID пользователя async getProfileByUserId(userId: string): Promise { const result = await query( 'SELECT * FROM profiles WHERE user_id = $1', [userId] ); if (result.rows.length === 0) { return null; } return this.mapEntityToProfile(result.rows[0]); } // Получение профиля по Telegram ID async getProfileByTelegramId(telegramId: string): Promise { const result = await query(` 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 u.telegram_id = $1 `, [parseInt(telegramId)]); if (result.rows.length === 0) { return null; } return this.mapEntityToProfile(result.rows[0]); } // Получение Telegram ID по UUID пользователя async getTelegramIdByUserId(userId: string): Promise { const result = await query(` SELECT telegram_id FROM users WHERE id = $1 `, [userId]); return result.rows.length > 0 ? result.rows[0].telegram_id.toString() : null; } // Получение UUID пользователя по Telegram ID async getUserIdByTelegramId(telegramId: string): Promise { const result = await query(` SELECT id FROM users WHERE telegram_id = $1 `, [parseInt(telegramId)]); if (result.rows.length === 0) { return null; } return result.rows[0].id; } // Получение пользователя по Telegram ID async getUserByTelegramId(telegramId: string): Promise { const result = await query(` SELECT * FROM users WHERE telegram_id = $1 `, [parseInt(telegramId)]); return result.rows.length > 0 ? result.rows[0] : null; } // Создание пользователя если не существует async ensureUser(telegramId: string, userData: any): Promise { // Используем UPSERT для избежания дублирования const result = await query(` INSERT INTO users (telegram_id, username, first_name, last_name) VALUES ($1, $2, $3, $4) ON CONFLICT (telegram_id) DO UPDATE SET username = EXCLUDED.username, first_name = EXCLUDED.first_name, last_name = EXCLUDED.last_name, updated_at = CURRENT_TIMESTAMP RETURNING id `, [ parseInt(telegramId), userData.username || null, userData.first_name || null, userData.last_name || null ]); return result.rows[0].id; } // Обновление профиля async updateProfile(userId: string, updates: Partial): Promise { const existingProfile = await this.getProfileByUserId(userId); if (!existingProfile) { throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404); } const validation = this.validateProfileData(updates, false); if (!validation.isValid) { throw new BotError(validation.errors.join(', '), 'VALIDATION_ERROR'); } const updateFields: string[] = []; const updateValues: any[] = []; let paramIndex = 1; // Строим динамический запрос обновления Object.entries(updates).forEach(([key, value]) => { if (value !== undefined && key !== 'updatedAt') { // Исключаем updatedAt из цикла switch (key) { case 'photos': case 'interests': updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`); // Для PostgreSQL массивы передаем как есть, не как JSON строки updateValues.push(value); break; case 'location': if (value && typeof value === 'object' && 'latitude' in value) { updateFields.push(`latitude = $${paramIndex++}`); updateValues.push(value.latitude); updateFields.push(`longitude = $${paramIndex++}`); updateValues.push(value.longitude); } break; case 'searchPreferences': // Поля search preferences больше не хранятся в БД, пропускаем break; default: updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`); updateValues.push(value); } } }); if (updateFields.length === 0) { return existingProfile; } // Всегда добавляем updated_at в конце updateFields.push(`updated_at = $${paramIndex++}`); updateValues.push(new Date()); updateValues.push(userId); const updateQuery = ` UPDATE profiles SET ${updateFields.join(', ')} WHERE user_id = $${paramIndex} RETURNING * `; const result = await query(updateQuery, updateValues); return this.mapEntityToProfile(result.rows[0]); } // Добавление фото к профилю async addPhoto(userId: string, photoFileId: string): Promise { const profile = await this.getProfileByUserId(userId); if (!profile) { throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404); } profile.addPhoto(photoFileId); await query( 'UPDATE profiles SET photos = $1, updated_at = $2 WHERE user_id = $3', [JSON.stringify(profile.photos), new Date(), userId] ); return profile; } // Удаление фото из профиля async removePhoto(userId: string, photoId: string): Promise { const profile = await this.getProfileByUserId(userId); if (!profile) { throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404); } profile.removePhoto(photoId); await query( 'UPDATE profiles SET photos = $1, updated_at = $2 WHERE user_id = $3', [JSON.stringify(profile.photos), new Date(), userId] ); return profile; } // Поиск совместимых профилей async findCompatibleProfiles( userId: string, limit: number = 10, excludeUserIds: string[] = [] ): Promise { const userProfile = await this.getProfileByUserId(userId); if (!userProfile) { throw new BotError('User profile not found', 'PROFILE_NOT_FOUND', 404); } // Получаем ID пользователей, которых уже свайпали const swipedUsersResult = await query( 'SELECT target_user_id FROM swipes WHERE user_id = $1', [userId] ); const swipedUserIds = swipedUsersResult.rows.map((row: any) => row.target_user_id); const allExcludedIds = [...excludeUserIds, ...swipedUserIds, userId]; // Базовый запрос для поиска совместимых профилей let searchQuery = ` SELECT p.*, u.id as user_id FROM profiles p JOIN users u ON p.user_id = u.id WHERE p.is_visible = true AND u.is_active = true AND p.user_id != $1 AND p.age BETWEEN $2 AND $3 AND p.gender = $4 AND p.interested_in IN ($5, 'both') AND $6 BETWEEN p.search_min_age AND p.search_max_age `; const queryParams: any[] = [ userId, userProfile.searchPreferences.minAge, userProfile.searchPreferences.maxAge, userProfile.interestedIn === 'both' ? userProfile.gender : userProfile.interestedIn, userProfile.gender, userProfile.age ]; // Исключаем уже просмотренных пользователей if (allExcludedIds.length > 0) { const placeholders = allExcludedIds.map((_, index) => `$${queryParams.length + index + 1}`).join(','); searchQuery += ` AND p.user_id NOT IN (${placeholders})`; queryParams.push(...allExcludedIds); } // Добавляем фильтр по расстоянию, если есть координаты if (userProfile.location) { searchQuery += ` AND ( p.location_lat IS NULL OR p.location_lon IS NULL OR ( 6371 * acos( cos(radians($${queryParams.length + 1})) * cos(radians(p.location_lat)) * cos(radians(p.location_lon) - radians($${queryParams.length + 2})) + sin(radians($${queryParams.length + 1})) * sin(radians(p.location_lat)) ) ) <= $${queryParams.length + 3} ) `; queryParams.push( userProfile.location.latitude, userProfile.location.longitude, userProfile.searchPreferences.maxDistance ); } searchQuery += ` ORDER BY RANDOM() LIMIT $${queryParams.length + 1}`; queryParams.push(limit); const result = await query(searchQuery, queryParams); return result.rows.map((row: any) => this.mapEntityToProfile(row)); } // Получение статистики профиля async getProfileStats(userId: string): Promise<{ totalLikes: number; totalMatches: number; profileViews: number; likesReceived: number; }> { const [likesResult, matchesResult, likesReceivedResult] = await Promise.all([ query('SELECT COUNT(*) as count FROM swipes WHERE swiper_id = $1 AND direction IN ($2, $3)', [userId, 'like', 'super']), query('SELECT COUNT(*) as count FROM matches WHERE (user1_id = $1 OR user2_id = $1) AND status = $2', [userId, 'active']), query('SELECT COUNT(*) as count FROM swipes WHERE swiped_id = $1 AND direction IN ($2, $3)', [userId, 'like', 'super']) ]); return { totalLikes: parseInt(likesResult.rows[0].count), totalMatches: parseInt(matchesResult.rows[0].count), profileViews: await this.getProfileViewsCount(userId), likesReceived: parseInt(likesReceivedResult.rows[0].count) }; } // Валидация данных профиля private validateProfileData(data: Partial, isRequired = true): ValidationResult { const errors: string[] = []; if (isRequired || data.name !== undefined) { if (!data.name || data.name.trim().length === 0) { errors.push('Name is required'); } else if (data.name.length > 50) { errors.push('Name must be less than 50 characters'); } } if (isRequired || data.age !== undefined) { if (!data.age || data.age < 18 || data.age > 100) { errors.push('Age must be between 18 and 100'); } } if (isRequired || data.gender !== undefined) { if (!data.gender || !['male', 'female', 'other'].includes(data.gender)) { errors.push('Gender must be male, female, or other'); } } if (isRequired || data.interestedIn !== undefined) { if (!data.interestedIn || !['male', 'female', 'both'].includes(data.interestedIn)) { errors.push('Interested in must be male, female, or both'); } } if (data.bio && data.bio.length > 500) { errors.push('Bio must be less than 500 characters'); } if (data.photos && data.photos.length > 6) { errors.push('Maximum 6 photos allowed'); } if (data.interests && data.interests.length > 10) { errors.push('Maximum 10 interests allowed'); } if (data.height && (data.height < 100 || data.height > 250)) { errors.push('Height must be between 100 and 250 cm'); } return { isValid: errors.length === 0, errors }; } // Преобразование entity в модель Profile public mapEntityToProfile(entity: any): Profile { // Функция для парсинга PostgreSQL массивов const parsePostgresArray = (pgArray: string | null): string[] => { if (!pgArray) return []; // PostgreSQL возвращает массивы в формате {item1,item2,item3} if (typeof pgArray === 'string' && pgArray.startsWith('{') && pgArray.endsWith('}')) { const content = pgArray.slice(1, -1); // Убираем фигурные скобки if (content === '') return []; return content.split(',').map(item => item.trim()); } // Если это уже массив, возвращаем как есть if (Array.isArray(pgArray)) return pgArray; return []; }; return new Profile({ userId: entity.user_id, name: entity.name, age: entity.age, gender: entity.gender, interestedIn: entity.looking_for, bio: entity.bio, photos: parsePostgresArray(entity.photos), interests: parsePostgresArray(entity.interests), hobbies: entity.hobbies, city: entity.location || entity.city, education: entity.education, job: entity.occupation || entity.job, height: entity.height, religion: entity.religion, datingGoal: entity.dating_goal, lifestyle: { smoking: entity.smoking, drinking: entity.drinking, kids: entity.has_kids }, location: entity.latitude && entity.longitude ? { latitude: entity.latitude, longitude: entity.longitude } : undefined, searchPreferences: { minAge: 18, maxAge: 50, maxDistance: 50 }, isVerified: entity.verification_status === 'verified', isVisible: entity.is_visible, createdAt: entity.created_at, updatedAt: entity.updated_at }); } // Преобразование camelCase в snake_case private camelToSnake(str: string): string { // Специальные случаи для некоторых полей const specialCases: { [key: string]: string } = { 'interestedIn': 'looking_for', 'job': 'occupation', 'city': 'location', 'datingGoal': 'dating_goal' }; if (specialCases[str]) { return specialCases[str]; } return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); } // Удаление профиля async deleteProfile(userId: string): Promise { try { await transaction(async (client) => { // Удаляем связанные данные await client.query('DELETE FROM messages WHERE sender_id = $1 OR receiver_id = $1', [userId]); await client.query('DELETE FROM matches WHERE user1_id = $1 OR user2_id = $1', [userId]); await client.query('DELETE FROM swipes WHERE swiper_id = $1 OR swiped_id = $1', [userId]); await client.query('DELETE FROM profiles WHERE user_id = $1', [userId]); }); return true; } catch (error) { console.error('Error deleting profile:', error); return false; } } // Скрыть/показать профиль async toggleVisibility(userId: string): Promise { const profile = await this.getProfileByUserId(userId); if (!profile) { throw new BotError('Profile not found', 'PROFILE_NOT_FOUND', 404); } const newVisibility = !profile.isVisible; await query( 'UPDATE profiles SET is_visible = $1, updated_at = $2 WHERE user_id = $3', [newVisibility, new Date(), userId] ); profile.isVisible = newVisibility; return profile; } // Записать просмотр профиля async recordProfileView(viewerId: string, viewedProfileId: string, viewType: string = 'browse'): Promise { try { 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 ) ON CONFLICT (viewer_id, viewed_profile_id) DO UPDATE SET viewed_at = CURRENT_TIMESTAMP, view_type = EXCLUDED.view_type `, [viewerId, viewedProfileId, viewType]); } catch (error) { console.error('Error recording profile view:', error); } } // Получить количество просмотров профиля async getProfileViewsCount(userId: string): Promise { try { 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 `, [userId]); return parseInt(result.rows[0].count) || 0; } catch (error) { console.error('Error getting profile views count:', error); return 0; } } // Получить список кто просматривал профиль 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 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 LIMIT $2 `, [userId, limit]); return result.rows.map((row: any) => this.mapEntityToProfile(row)); } catch (error) { console.error('Error getting profile viewers:', error); return []; } } }