Reflex/backend/controllers/userController.js

685 lines
29 KiB
JavaScript
Raw Permalink Normal View History

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');
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) {
// Проверяем имя на запрещённые слова, если оно было изменено
if (req.body.name && req.body.name !== user.name) {
2025-05-26 01:28:53 +07:00
if (await profanityFilter.isProfane(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;
}
// Проверяем bio на запрещённые слова перед установкой
if (req.body.bio !== undefined && req.body.bio !== null) {
2025-05-26 01:28:53 +07:00
if (req.body.bio !== '' && await profanityFilter.isProfane(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;
}
// Обновление предпочтений по городу
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;
// Получаем данные текущего пользователя для фильтрации рекомендаций
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 || [])] // Исключаем уже просмотренных
},
2025-05-26 01:28:53 +07:00
isActive: true, // Только активные пользователи
isAdmin: { $ne: 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
// Проверяем взаимные предпочтения по полу
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
// Проверяем взаимные возрастные предпочтения
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-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);
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,
mainPhotoUrl: mainPhotoUrl,
photos: user.photos,
location: user.location
2025-05-21 22:13:09 +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
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);
}
};
// @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);
}
};
// @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
});
const stats = {
profileViews: profileViewsCount,
likes: likesCount,
matches: matchesCount,
2025-05-24 02:24:21 +07:00
unreadMessages: unreadMessagesCount,
totalMessages: totalMessagesCount // Добавляем новое значение в ответ
};
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,
setMainPhoto,
deletePhoto,
getUserById,
2025-05-24 02:10:15 +07:00
getUserStats,
recordProfileView, // <--- Добавляем новую функцию
2025-05-21 22:13:09 +07:00
};