571 lines
22 KiB
TypeScript
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 [];
|
|
}
|
|
}
|
|
} |