2025-05-21 22:13:09 +07:00
|
|
|
|
const User = require('../models/User');
|
|
|
|
|
const cloudinary = require('../config/cloudinaryConfig'); // Импортируем настроенный Cloudinary SDK
|
|
|
|
|
const path = require('path'); // Может понадобиться для получения расширения файла
|
2025-05-24 02:10:15 +07:00
|
|
|
|
const ProfileView = require('../models/ProfileView');
|
2025-05-25 22:36:17 +07:00
|
|
|
|
const profanityFilter = require('../utils/profanityFilter'); // Импортируем фильтр запрещённых слов
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
|
|
|
|
// @desc Обновить профиль пользователя
|
|
|
|
|
// @route PUT /api/users/profile
|
|
|
|
|
// @access Private (пользователь должен быть аутентифицирован)
|
|
|
|
|
const updateUserProfile = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
console.log('Запрос на обновление профиля от пользователя:', req.user._id);
|
|
|
|
|
console.log('Данные для обновления:', req.body);
|
|
|
|
|
|
|
|
|
|
// req.user должен быть доступен благодаря middleware 'protect'
|
|
|
|
|
const user = await User.findById(req.user._id);
|
|
|
|
|
|
|
|
|
|
if (user) {
|
2025-05-25 22:36:17 +07:00
|
|
|
|
// Проверяем имя на запрещённые слова, если оно было изменено
|
|
|
|
|
if (req.body.name && req.body.name !== user.name) {
|
|
|
|
|
if (profanityFilter.hasProfanity(req.body.name)) {
|
|
|
|
|
res.status(400);
|
|
|
|
|
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-21 22:13:09 +07:00
|
|
|
|
// Обновляем только те поля, которые пользователь может изменять
|
|
|
|
|
user.name = req.body.name || user.name;
|
|
|
|
|
|
2025-05-25 01:35:58 +07:00
|
|
|
|
// Обрабатываем dateOfBirth, но не позволяем удалять её
|
|
|
|
|
if (req.body.dateOfBirth && req.body.dateOfBirth !== '') {
|
2025-05-21 22:13:09 +07:00
|
|
|
|
user.dateOfBirth = req.body.dateOfBirth;
|
|
|
|
|
}
|
2025-05-25 01:35:58 +07:00
|
|
|
|
// Примечание: Мы больше не позволяем устанавливать dateOfBirth в undefined
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
|
|
|
|
// Обрабатываем gender, учитывая null и пустые строки
|
|
|
|
|
if (req.body.gender === null || req.body.gender === '') {
|
|
|
|
|
user.gender = undefined; // Удаляем поле, если оно пустое или null
|
|
|
|
|
} else if (req.body.gender) {
|
|
|
|
|
user.gender = req.body.gender;
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-25 22:36:17 +07:00
|
|
|
|
// Проверяем bio на запрещённые слова перед установкой
|
|
|
|
|
if (req.body.bio !== undefined && req.body.bio !== null) {
|
|
|
|
|
if (req.body.bio !== '' && profanityFilter.hasProfanity(req.body.bio)) {
|
|
|
|
|
res.status(400);
|
|
|
|
|
throw new Error('Ваша биография содержит запрещённые слова. Пожалуйста, отредактируйте текст.');
|
|
|
|
|
}
|
|
|
|
|
user.bio = req.body.bio;
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Обновление местоположения (если передано)
|
|
|
|
|
if (req.body.location) {
|
|
|
|
|
user.location.city = req.body.location.city || user.location.city;
|
|
|
|
|
user.location.country = req.body.location.country || user.location.country;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Обновление предпочтений (если переданы)
|
|
|
|
|
if (req.body.preferences) {
|
|
|
|
|
if (req.body.preferences.gender) {
|
|
|
|
|
user.preferences.gender = req.body.preferences.gender;
|
|
|
|
|
}
|
|
|
|
|
if (req.body.preferences.ageRange) {
|
|
|
|
|
user.preferences.ageRange.min = req.body.preferences.ageRange.min || user.preferences.ageRange.min;
|
|
|
|
|
user.preferences.ageRange.max = req.body.preferences.ageRange.max || user.preferences.ageRange.max;
|
|
|
|
|
}
|
2025-05-25 00:57:08 +07:00
|
|
|
|
// Обновление предпочтений по городу
|
|
|
|
|
if (req.body.preferences.cityPreferences) {
|
|
|
|
|
if (typeof req.body.preferences.cityPreferences.sameCity === 'boolean') {
|
|
|
|
|
user.preferences.cityPreferences.sameCity = req.body.preferences.cityPreferences.sameCity;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(req.body.preferences.cityPreferences.allowedCities)) {
|
|
|
|
|
user.preferences.cityPreferences.allowedCities = req.body.preferences.cityPreferences.allowedCities;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('Данные пользователя перед сохранением:', user);
|
|
|
|
|
const updatedUser = await user.save();
|
|
|
|
|
console.log('Профиль успешно обновлен:', updatedUser);
|
|
|
|
|
|
|
|
|
|
res.status(200).json({
|
|
|
|
|
_id: updatedUser._id,
|
|
|
|
|
name: updatedUser.name,
|
|
|
|
|
email: updatedUser.email,
|
|
|
|
|
dateOfBirth: updatedUser.dateOfBirth,
|
|
|
|
|
gender: updatedUser.gender,
|
|
|
|
|
bio: updatedUser.bio,
|
|
|
|
|
photos: updatedUser.photos,
|
|
|
|
|
location: updatedUser.location,
|
|
|
|
|
preferences: updatedUser.preferences,
|
|
|
|
|
message: 'Профиль успешно обновлен!'
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
res.status(404);
|
|
|
|
|
throw new Error('Пользователь не найден.');
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error(`Ошибка в ${req.method} ${req.originalUrl}:`, error.message); // Более информативный лог
|
|
|
|
|
|
|
|
|
|
// Устанавливаем statusCode на объекте ошибки, если он еще не установлен
|
|
|
|
|
// или если мы хотим его переопределить для этого конкретного случая
|
|
|
|
|
if (error.name === 'ValidationError' || error.name === 'CastError') {
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
} else if (error.message === 'Пользователь не найден.') { // Если мы сами выбросили эту ошибку с throw
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
} else if (!error.statusCode) {
|
|
|
|
|
// Если это какая-то другая ошибка без явно установленного statusCode,
|
|
|
|
|
// можно считать ее серверной ошибкой.
|
|
|
|
|
error.statusCode = 500;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Передаем ошибку (с установленным error.statusCode) в наш центральный errorHandler
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getUsersForSwiping = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const currentUserId = req.user._id;
|
2025-05-25 00:57:08 +07:00
|
|
|
|
|
|
|
|
|
// Получаем данные текущего пользователя для фильтрации рекомендаций
|
|
|
|
|
const currentUser = await User.findById(currentUserId)
|
|
|
|
|
.select('preferences location dateOfBirth liked passed');
|
|
|
|
|
|
|
|
|
|
if (!currentUser) {
|
|
|
|
|
const error = new Error('Пользователь не найден.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Вычисляем возраст текущего пользователя
|
|
|
|
|
let currentUserAge = null;
|
|
|
|
|
if (currentUser.dateOfBirth) {
|
|
|
|
|
const birthDate = new Date(currentUser.dateOfBirth);
|
|
|
|
|
const today = new Date();
|
|
|
|
|
currentUserAge = today.getFullYear() - birthDate.getFullYear();
|
|
|
|
|
const m = today.getMonth() - birthDate.getMonth();
|
|
|
|
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
|
|
|
|
currentUserAge--;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Автоматически устанавливаем возрастные предпочтения ±2 года, если они не заданы
|
|
|
|
|
let ageRangeMin = 18;
|
|
|
|
|
let ageRangeMax = 99;
|
|
|
|
|
|
|
|
|
|
if (currentUser.preferences?.ageRange?.min && currentUser.preferences?.ageRange?.max) {
|
|
|
|
|
// Используем пользовательские настройки
|
|
|
|
|
ageRangeMin = currentUser.preferences.ageRange.min;
|
|
|
|
|
ageRangeMax = currentUser.preferences.ageRange.max;
|
|
|
|
|
} else if (currentUserAge) {
|
|
|
|
|
// Устанавливаем ±2 года от возраста пользователя по умолчанию
|
|
|
|
|
ageRangeMin = Math.max(18, currentUserAge - 2);
|
|
|
|
|
ageRangeMax = Math.min(99, currentUserAge + 2);
|
|
|
|
|
console.log(`[USER_CTRL] Автоматически установлен возрастной диапазон: ${ageRangeMin}-${ageRangeMax} для пользователя ${currentUserAge} лет`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Строим базовый фильтр
|
|
|
|
|
let matchFilter = {
|
|
|
|
|
_id: {
|
|
|
|
|
$ne: currentUserId,
|
|
|
|
|
$nin: [...(currentUser.liked || []), ...(currentUser.passed || [])] // Исключаем уже просмотренных
|
|
|
|
|
},
|
|
|
|
|
isActive: true // Только активные пользователи
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Фильтрация по полу согласно предпочтениям
|
|
|
|
|
if (currentUser.preferences?.gender && currentUser.preferences.gender !== 'any') {
|
|
|
|
|
matchFilter.gender = currentUser.preferences.gender;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Улучшенная фильтрация по городу
|
|
|
|
|
const cityFilter = [];
|
|
|
|
|
|
|
|
|
|
// Если включена настройка "только мой город" и город указан
|
|
|
|
|
if (currentUser.preferences?.cityPreferences?.sameCity !== false && currentUser.location?.city) {
|
|
|
|
|
cityFilter.push(currentUser.location.city);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Добавляем дополнительные разрешенные города
|
|
|
|
|
if (currentUser.preferences?.cityPreferences?.allowedCities?.length > 0) {
|
|
|
|
|
cityFilter.push(...currentUser.preferences.cityPreferences.allowedCities);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Применяем фильтр по городу только если есть что фильтровать
|
|
|
|
|
if (cityFilter.length > 0) {
|
|
|
|
|
// Убираем дубликаты
|
|
|
|
|
const uniqueCities = [...new Set(cityFilter)];
|
|
|
|
|
matchFilter['location.city'] = { $in: uniqueCities };
|
|
|
|
|
console.log(`[USER_CTRL] Фильтрация по городам: ${uniqueCities.join(', ')}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Получаем пользователей с базовой фильтрацией
|
|
|
|
|
const users = await User.find(matchFilter)
|
|
|
|
|
.select('name dateOfBirth gender bio photos location preferences.ageRange preferences.gender');
|
|
|
|
|
|
|
|
|
|
// Дополнительная фильтрация по возрасту и взаимным предпочтениям
|
|
|
|
|
const filteredUsers = users.filter(user => {
|
|
|
|
|
// Вычисляем возраст пользователя
|
|
|
|
|
let userAge = null;
|
|
|
|
|
if (user.dateOfBirth) {
|
|
|
|
|
const birthDate = new Date(user.dateOfBirth);
|
|
|
|
|
const today = new Date();
|
|
|
|
|
userAge = today.getFullYear() - birthDate.getFullYear();
|
|
|
|
|
const m = today.getMonth() - birthDate.getMonth();
|
|
|
|
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
|
|
|
|
userAge--;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем возрастные предпочтения текущего пользователя
|
|
|
|
|
if (userAge) {
|
|
|
|
|
if (userAge < ageRangeMin || userAge > ageRangeMax) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
2025-05-25 00:57:08 +07:00
|
|
|
|
// Проверяем взаимные предпочтения по полу
|
|
|
|
|
if (user.preferences?.gender && user.preferences.gender !== 'any' && currentUser.gender) {
|
|
|
|
|
if (user.preferences.gender !== currentUser.gender) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
2025-05-25 00:57:08 +07:00
|
|
|
|
// Проверяем взаимные возрастные предпочтения
|
|
|
|
|
if (currentUserAge && user.preferences?.ageRange) {
|
|
|
|
|
const { min = 18, max = 99 } = user.preferences.ageRange;
|
|
|
|
|
if (currentUserAge < min || currentUserAge > max) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Формируем ответ с дополнительными данными
|
|
|
|
|
const usersWithAgeAndPhoto = filteredUsers.map(user => {
|
2025-05-21 22:13:09 +07:00
|
|
|
|
let age = null;
|
|
|
|
|
if (user.dateOfBirth) {
|
|
|
|
|
const birthDate = new Date(user.dateOfBirth);
|
|
|
|
|
const today = new Date();
|
|
|
|
|
age = today.getFullYear() - birthDate.getFullYear();
|
|
|
|
|
const m = today.getMonth() - birthDate.getMonth();
|
|
|
|
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
|
|
|
|
age--;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-25 00:57:08 +07:00
|
|
|
|
// Логика выбора главной фотографии
|
2025-05-21 22:13:09 +07:00
|
|
|
|
let mainPhotoUrl = null;
|
|
|
|
|
if (user.photos && user.photos.length > 0) {
|
|
|
|
|
const profilePic = user.photos.find(photo => photo.isProfilePhoto === true);
|
2025-05-25 00:57:08 +07:00
|
|
|
|
mainPhotoUrl = profilePic ? profilePic.url : user.photos[0].url;
|
2025-05-21 22:13:09 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
_id: user._id,
|
|
|
|
|
name: user.name,
|
|
|
|
|
age: age,
|
|
|
|
|
gender: user.gender,
|
|
|
|
|
bio: user.bio,
|
2025-05-25 00:57:08 +07:00
|
|
|
|
mainPhotoUrl: mainPhotoUrl,
|
|
|
|
|
photos: user.photos,
|
|
|
|
|
location: user.location
|
2025-05-21 22:13:09 +07:00
|
|
|
|
};
|
|
|
|
|
});
|
2025-05-25 00:57:08 +07:00
|
|
|
|
|
|
|
|
|
// Улучшенная сортировка пользователей
|
|
|
|
|
const sortedUsers = usersWithAgeAndPhoto.sort((a, b) => {
|
|
|
|
|
// 1. Приоритет пользователям из того же города
|
|
|
|
|
const aInSameCity = a.location?.city === currentUser.location?.city;
|
|
|
|
|
const bInSameCity = b.location?.city === currentUser.location?.city;
|
|
|
|
|
|
|
|
|
|
if (aInSameCity && !bInSameCity) return -1;
|
|
|
|
|
if (!aInSameCity && bInSameCity) return 1;
|
|
|
|
|
|
|
|
|
|
// 2. Если оба из одного города или оба из разных, сортируем по близости возраста
|
|
|
|
|
if (currentUserAge && a.age && b.age) {
|
|
|
|
|
const aDiff = Math.abs(a.age - currentUserAge);
|
|
|
|
|
const bDiff = Math.abs(b.age - currentUserAge);
|
|
|
|
|
if (aDiff !== bDiff) {
|
|
|
|
|
return aDiff - bDiff;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Приоритет пользователям с фотографиями
|
|
|
|
|
const aHasPhotos = a.photos && a.photos.length > 0;
|
|
|
|
|
const bHasPhotos = b.photos && b.photos.length > 0;
|
|
|
|
|
|
|
|
|
|
if (aHasPhotos && !bHasPhotos) return -1;
|
|
|
|
|
if (!aHasPhotos && bHasPhotos) return 1;
|
|
|
|
|
|
|
|
|
|
// 4. Приоритет пользователям с био
|
|
|
|
|
const aHasBio = a.bio && a.bio.trim().length > 0;
|
|
|
|
|
const bHasBio = b.bio && b.bio.trim().length > 0;
|
|
|
|
|
|
|
|
|
|
if (aHasBio && !bHasBio) return -1;
|
|
|
|
|
if (!aHasBio && bHasBio) return 1;
|
|
|
|
|
|
|
|
|
|
return 0;
|
|
|
|
|
});
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
2025-05-25 00:57:08 +07:00
|
|
|
|
console.log(`[USER_CTRL] Найдено ${sortedUsers.length} пользователей для рекомендаций (возраст: ${ageRangeMin}-${ageRangeMax})`);
|
|
|
|
|
res.status(200).json(sortedUsers);
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Ошибка при получении пользователей для свайпа:', error.message);
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const uploadUserProfilePhoto = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const user = await User.findById(req.user._id);
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
const error = new Error('Пользователь не найден.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!req.file) { // req.file будет добавлен multer'ом
|
|
|
|
|
const error = new Error('Файл для загрузки не найден.');
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('[USER_CTRL] Получен файл для загрузки:', req.file.originalname, 'Размер:', req.file.size);
|
|
|
|
|
|
|
|
|
|
// Проверяем инициализацию Cloudinary
|
|
|
|
|
if (!cloudinary || !cloudinary.uploader) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка: Cloudinary не настроен');
|
|
|
|
|
const error = new Error('Ошибка настройки службы хранения файлов.');
|
|
|
|
|
error.statusCode = 500;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Загрузка файла в Cloudinary
|
|
|
|
|
const b64 = Buffer.from(req.file.buffer).toString("base64");
|
|
|
|
|
let dataURI = "data:" + req.file.mimetype + ";base64," + b64;
|
|
|
|
|
|
|
|
|
|
const result = await cloudinary.uploader.upload(dataURI, {
|
|
|
|
|
folder: `dating_app/user_photos/${user._id}`,
|
|
|
|
|
resource_type: 'image',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.log('[USER_CTRL] Файл успешно загружен в Cloudinary:', result.secure_url);
|
|
|
|
|
|
|
|
|
|
const newPhoto = {
|
|
|
|
|
url: result.secure_url,
|
|
|
|
|
public_id: result.public_id,
|
2025-05-21 23:43:24 +07:00
|
|
|
|
// Если это первая фотография, делаем ее главной. Иначе - нет.
|
|
|
|
|
isProfilePhoto: user.photos.length === 0,
|
2025-05-21 22:13:09 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Добавляем новое фото в массив фотографий пользователя
|
|
|
|
|
user.photos.push(newPhoto);
|
|
|
|
|
await user.save();
|
|
|
|
|
|
|
|
|
|
// Получаем обновленного пользователя с обновленным массивом фотографий
|
2025-05-21 23:43:24 +07:00
|
|
|
|
const updatedUser = await User.findById(req.user._id).lean(); // .lean() для простого объекта
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
|
|
|
|
// Важно! Явно создаем массив allPhotos для ответа, чтобы убедиться, что структура каждого объекта фото совпадает
|
|
|
|
|
const allPhotos = updatedUser.photos.map(photo => ({
|
2025-05-21 23:43:24 +07:00
|
|
|
|
_id: photo._id, // Добавляем _id для фото
|
2025-05-21 22:13:09 +07:00
|
|
|
|
url: photo.url,
|
|
|
|
|
public_id: photo.public_id,
|
|
|
|
|
isProfilePhoto: photo.isProfilePhoto
|
|
|
|
|
}));
|
|
|
|
|
|
2025-05-21 23:43:24 +07:00
|
|
|
|
// Найдем только что добавленное фото в обновленном массиве, чтобы вернуть его с _id
|
|
|
|
|
const addedPhotoWithId = allPhotos.find(p => p.public_id === newPhoto.public_id);
|
|
|
|
|
|
2025-05-21 22:13:09 +07:00
|
|
|
|
// Возвращаем ответ с точной структурой, ожидаемой тестами
|
|
|
|
|
res.status(200).json({
|
|
|
|
|
message: 'Фотография успешно загружена!',
|
2025-05-21 23:43:24 +07:00
|
|
|
|
photo: addedPhotoWithId, // Возвращаем фото с _id
|
2025-05-21 22:13:09 +07:00
|
|
|
|
allPhotos: allPhotos
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка при загрузке фотографии:', error.message, error.stack);
|
|
|
|
|
if (error.http_code) {
|
|
|
|
|
error.statusCode = error.http_code;
|
|
|
|
|
}
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-21 23:43:24 +07:00
|
|
|
|
// @desc Установить фотографию как главную
|
|
|
|
|
// @route PUT /api/users/profile/photo/:photoId/set-main
|
|
|
|
|
// @access Private
|
|
|
|
|
const setMainPhoto = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const user = await User.findById(req.user._id);
|
|
|
|
|
const { photoId } = req.params;
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
const error = new Error('Пользователь не найден.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let photoFound = false;
|
|
|
|
|
user.photos.forEach(photo => {
|
|
|
|
|
if (photo._id.toString() === photoId) {
|
|
|
|
|
photo.isProfilePhoto = true;
|
|
|
|
|
photoFound = true;
|
|
|
|
|
} else {
|
|
|
|
|
photo.isProfilePhoto = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!photoFound) {
|
|
|
|
|
const error = new Error('Фотография не найдена.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await user.save();
|
|
|
|
|
const updatedUser = await User.findById(req.user._id).lean();
|
|
|
|
|
|
|
|
|
|
res.status(200).json({
|
|
|
|
|
message: 'Главная фотография успешно обновлена.',
|
|
|
|
|
photos: updatedUser.photos.map(p => ({_id: p._id, url: p.url, public_id: p.public_id, isProfilePhoto: p.isProfilePhoto }))
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка при установке главной фотографии:', error.message);
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// @desc Удалить фотографию пользователя
|
|
|
|
|
// @route DELETE /api/users/profile/photo/:photoId
|
|
|
|
|
// @access Private
|
|
|
|
|
const deletePhoto = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const user = await User.findById(req.user._id);
|
|
|
|
|
const { photoId } = req.params;
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
const error = new Error('Пользователь не найден.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const photoIndex = user.photos.findIndex(p => p._id.toString() === photoId);
|
|
|
|
|
|
|
|
|
|
if (photoIndex === -1) {
|
|
|
|
|
const error = new Error('Фотография не найдена.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const photoToDelete = user.photos[photoIndex];
|
|
|
|
|
|
|
|
|
|
// Удаление из Cloudinary
|
|
|
|
|
if (photoToDelete.public_id) {
|
|
|
|
|
try {
|
|
|
|
|
await cloudinary.uploader.destroy(photoToDelete.public_id);
|
|
|
|
|
console.log(`[USER_CTRL] Фото ${photoToDelete.public_id} удалено из Cloudinary.`);
|
|
|
|
|
} catch (cloudinaryError) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка при удалении фото из Cloudinary:', cloudinaryError.message);
|
|
|
|
|
// Не прерываем процесс, если фото не удалось удалить из Cloudinary, но логируем ошибку
|
|
|
|
|
// Можно добавить более сложную логику обработки, например, пометить фото для последующего удаления
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Удаление из массива photos пользователя
|
|
|
|
|
user.photos.splice(photoIndex, 1);
|
|
|
|
|
|
|
|
|
|
// Если удаленная фотография была главной и остались другие фотографии,
|
|
|
|
|
// делаем первую из оставшихся главной.
|
|
|
|
|
if (photoToDelete.isProfilePhoto && user.photos.length > 0) {
|
|
|
|
|
user.photos[0].isProfilePhoto = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await user.save();
|
|
|
|
|
const updatedUser = await User.findById(req.user._id).lean();
|
|
|
|
|
|
|
|
|
|
res.status(200).json({
|
|
|
|
|
message: 'Фотография успешно удалена.',
|
|
|
|
|
photos: updatedUser.photos.map(p => ({_id: p._id, url: p.url, public_id: p.public_id, isProfilePhoto: p.isProfilePhoto }))
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка при удалении фотографии:', error.message);
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-24 01:46:38 +07:00
|
|
|
|
// @desc Получить профиль пользователя по ID (для просмотра чужих профилей)
|
|
|
|
|
// @route GET /api/users/:userId
|
|
|
|
|
// @access Private
|
|
|
|
|
const getUserById = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { userId } = req.params;
|
|
|
|
|
const currentUserId = req.user._id;
|
|
|
|
|
|
|
|
|
|
// Проверяем, что пользователь не запрашивает свой собственный профиль
|
|
|
|
|
// (для этого есть отдельный роут /auth/me)
|
|
|
|
|
if (currentUserId.equals(userId)) {
|
|
|
|
|
const error = new Error('Для получения своего профиля используйте /auth/me');
|
|
|
|
|
error.statusCode = 400;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Находим пользователя по ID
|
|
|
|
|
const user = await User.findById(userId)
|
|
|
|
|
.select('name dateOfBirth gender bio photos location createdAt');
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
const error = new Error('Пользователь не найден.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Вычисляем возраст, если указана дата рождения
|
|
|
|
|
let age = null;
|
|
|
|
|
if (user.dateOfBirth) {
|
|
|
|
|
const birthDate = new Date(user.dateOfBirth);
|
|
|
|
|
const today = new Date();
|
|
|
|
|
age = today.getFullYear() - birthDate.getFullYear();
|
|
|
|
|
const m = today.getMonth() - birthDate.getMonth();
|
|
|
|
|
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
|
|
|
|
|
age--;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Формируем ответ
|
|
|
|
|
const userProfile = {
|
|
|
|
|
_id: user._id,
|
|
|
|
|
name: user.name,
|
|
|
|
|
age: age,
|
|
|
|
|
gender: user.gender,
|
|
|
|
|
bio: user.bio,
|
|
|
|
|
photos: user.photos,
|
|
|
|
|
location: user.location,
|
|
|
|
|
memberSince: user.createdAt
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
res.status(200).json(userProfile);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка при получении профиля пользователя:', error.message);
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-05-21 22:13:09 +07:00
|
|
|
|
|
2025-05-24 02:10:15 +07:00
|
|
|
|
// @desc Записать просмотр профиля пользователя
|
|
|
|
|
// @route POST /api/users/:userId/view
|
|
|
|
|
// @access Private
|
|
|
|
|
const recordProfileView = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const { userId } = req.params;
|
|
|
|
|
const viewerId = req.user._id;
|
|
|
|
|
const { source = 'other' } = req.body;
|
|
|
|
|
|
|
|
|
|
// Проверяем, что пользователь не пытается просматривать свой собственный профиль
|
|
|
|
|
if (viewerId.equals(userId)) {
|
|
|
|
|
return res.status(200).json({ message: 'Просмотр собственного профиля не записывается' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем, существует ли пользователь, чей профиль просматривают
|
|
|
|
|
const profileOwner = await User.findById(userId);
|
|
|
|
|
if (!profileOwner) {
|
|
|
|
|
const error = new Error('Пользователь не найден.');
|
|
|
|
|
error.statusCode = 404;
|
|
|
|
|
return next(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Проверяем, был ли уже просмотр от этого пользователя в последние 5 минут
|
|
|
|
|
// Это предотвращает спам просмотров при обновлении страницы
|
|
|
|
|
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
|
|
|
|
const recentView = await ProfileView.findOne({
|
|
|
|
|
viewer: viewerId,
|
|
|
|
|
profileOwner: userId,
|
|
|
|
|
viewedAt: { $gte: fiveMinutesAgo }
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (recentView) {
|
|
|
|
|
console.log(`[USER_CTRL] Недавний просмотр уже существует для пользователя ${viewerId} -> ${userId}`);
|
|
|
|
|
return res.status(200).json({ message: 'Просмотр уже записан' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Создаем новую запись просмотра
|
|
|
|
|
const newView = new ProfileView({
|
|
|
|
|
viewer: viewerId,
|
|
|
|
|
profileOwner: userId,
|
|
|
|
|
source: source,
|
|
|
|
|
viewedAt: new Date()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await newView.save();
|
|
|
|
|
|
|
|
|
|
console.log(`[USER_CTRL] Записан просмотр профиля ${userId} пользователем ${viewerId}, источник: ${source}`);
|
|
|
|
|
|
|
|
|
|
res.status(200).json({
|
|
|
|
|
message: 'Просмотр профиля записан',
|
|
|
|
|
viewId: newView._id
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка при записи просмотра профиля:', error.message);
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-24 02:02:16 +07:00
|
|
|
|
// @desc Получить статистику пользователя для главной страницы
|
|
|
|
|
// @route GET /api/users/stats
|
|
|
|
|
// @access Private
|
|
|
|
|
const getUserStats = async (req, res, next) => {
|
|
|
|
|
try {
|
|
|
|
|
const currentUserId = req.user._id;
|
|
|
|
|
|
|
|
|
|
// 1. Получаем количество лайков (кто лайкнул текущего пользователя)
|
|
|
|
|
const likesCount = await User.countDocuments({
|
|
|
|
|
liked: currentUserId
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. Получаем количество совпадений (matches)
|
|
|
|
|
const matchesCount = await User.findById(currentUserId)
|
|
|
|
|
.select('matches')
|
|
|
|
|
.then(user => user?.matches?.length || 0);
|
|
|
|
|
|
|
|
|
|
// 3. Получаем количество непрочитанных сообщений
|
|
|
|
|
const Conversation = require('../models/Conversation');
|
|
|
|
|
const Message = require('../models/Message');
|
|
|
|
|
|
|
|
|
|
// Находим все диалоги пользователя
|
|
|
|
|
const userConversations = await Conversation.find({
|
|
|
|
|
participants: currentUserId
|
|
|
|
|
}).select('_id');
|
|
|
|
|
|
|
|
|
|
const conversationIds = userConversations.map(conv => conv._id);
|
|
|
|
|
|
|
|
|
|
// Считаем непрочитанные сообщения в этих диалогах
|
|
|
|
|
const unreadMessagesCount = await Message.countDocuments({
|
|
|
|
|
conversationId: { $in: conversationIds },
|
|
|
|
|
sender: { $ne: currentUserId }, // Сообщения от других пользователей
|
|
|
|
|
readBy: { $ne: currentUserId } // Которые текущий пользователь не читал
|
|
|
|
|
});
|
|
|
|
|
|
2025-05-24 02:24:21 +07:00
|
|
|
|
// 4. Получаем общее количество сообщений пользователя
|
|
|
|
|
const totalMessagesCount = await Message.countDocuments({
|
|
|
|
|
conversationId: { $in: conversationIds },
|
|
|
|
|
sender: currentUserId // Только сообщения, отправленные текущим пользователем
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 5. Получаем количество просмотров профиля
|
2025-05-24 02:10:15 +07:00
|
|
|
|
const profileViewsCount = await ProfileView.countDocuments({
|
|
|
|
|
profileOwner: currentUserId
|
|
|
|
|
});
|
2025-05-24 02:02:16 +07:00
|
|
|
|
|
|
|
|
|
const stats = {
|
|
|
|
|
profileViews: profileViewsCount,
|
|
|
|
|
likes: likesCount,
|
|
|
|
|
matches: matchesCount,
|
2025-05-24 02:24:21 +07:00
|
|
|
|
unreadMessages: unreadMessagesCount,
|
|
|
|
|
totalMessages: totalMessagesCount // Добавляем новое значение в ответ
|
2025-05-24 02:02:16 +07:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
console.log(`[USER_CTRL] Статистика пользователя ${currentUserId}:`, stats);
|
|
|
|
|
|
|
|
|
|
res.status(200).json(stats);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('[USER_CTRL] Ошибка при получении статистики пользователя:', error.message);
|
|
|
|
|
next(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-21 22:13:09 +07:00
|
|
|
|
module.exports = {
|
|
|
|
|
updateUserProfile,
|
|
|
|
|
getUsersForSwiping,
|
|
|
|
|
uploadUserProfilePhoto,
|
2025-05-24 02:02:16 +07:00
|
|
|
|
setMainPhoto,
|
|
|
|
|
deletePhoto,
|
|
|
|
|
getUserById,
|
2025-05-24 02:10:15 +07:00
|
|
|
|
getUserStats,
|
|
|
|
|
recordProfileView, // <--- Добавляем новую функцию
|
2025-05-21 22:13:09 +07:00
|
|
|
|
};
|