init commit
This commit is contained in:
470
src/services/profileService.ts
Normal file
470
src/services/profileService.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
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,
|
||||
location, education, occupation, height, 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)
|
||||
`, [
|
||||
profileId, userId, profile.name, profile.age, profile.gender, profile.interestedIn,
|
||||
profile.bio, profile.photos, profile.interests,
|
||||
profile.city, profile.education, profile.job, profile.height,
|
||||
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]);
|
||||
} // Получение 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;
|
||||
}
|
||||
|
||||
// Создание пользователя если не существует
|
||||
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) {
|
||||
switch (key) {
|
||||
case 'photos':
|
||||
case 'interests':
|
||||
updateFields.push(`${this.camelToSnake(key)} = $${paramIndex++}`);
|
||||
updateValues.push(JSON.stringify(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;
|
||||
}
|
||||
|
||||
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: 0, // TODO: implement profile views tracking
|
||||
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),
|
||||
city: entity.location || entity.city,
|
||||
education: entity.education,
|
||||
job: entity.occupation || entity.job,
|
||||
height: entity.height,
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user