Files
tg_tinder_bot/src/services/profileService.ts

571 lines
22 KiB
TypeScript

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<ProfileData>): Promise<Profile> {
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<Profile | null> {
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<Profile | null> {
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<string | null> {
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<string | null> {
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<any | null> {
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<string> {
// Используем 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<ProfileData>): Promise<Profile> {
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<Profile> {
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<Profile> {
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<Profile[]> {
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<ProfileData>, 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<boolean> {
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<Profile> {
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<void> {
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<number> {
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<Profile[]> {
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 [];
}
}
}