Reflex/backend/controllers/userController.js
2025-05-26 01:28:53 +07:00

685 lines
29 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const User = require('../models/User');
const cloudinary = require('../config/cloudinaryConfig'); // Импортируем настроенный Cloudinary SDK
const path = require('path'); // Может понадобиться для получения расширения файла
const ProfileView = require('../models/ProfileView');
const profanityFilter = require('../utils/profanityFilter'); // Импортируем фильтр запрещённых слов
// @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) {
if (await profanityFilter.isProfane(req.body.name)) {
res.status(400);
throw new Error('Имя содержит запрещённые слова. Пожалуйста, используйте другое имя.');
}
}
// Обновляем только те поля, которые пользователь может изменять
user.name = req.body.name || user.name;
// Обрабатываем dateOfBirth, но не позволяем удалять её
if (req.body.dateOfBirth && req.body.dateOfBirth !== '') {
user.dateOfBirth = req.body.dateOfBirth;
}
// Примечание: Мы больше не позволяем устанавливать dateOfBirth в undefined
// Обрабатываем 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) {
if (req.body.bio !== '' && await profanityFilter.isProfane(req.body.bio)) {
res.status(400);
throw new Error('Ваша биография содержит запрещённые слова. Пожалуйста, отредактируйте текст.');
}
user.bio = req.body.bio;
}
// Обновление местоположения (если передано)
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;
}
}
}
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 || [])] // Исключаем уже просмотренных
},
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;
}
}
// Проверяем взаимные предпочтения по полу
if (user.preferences?.gender && user.preferences.gender !== 'any' && currentUser.gender) {
if (user.preferences.gender !== currentUser.gender) {
return false;
}
}
// Проверяем взаимные возрастные предпочтения
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 => {
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--;
}
}
// Логика выбора главной фотографии
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;
}
return {
_id: user._id,
name: user.name,
age: age,
gender: user.gender,
bio: user.bio,
mainPhotoUrl: mainPhotoUrl,
photos: user.photos,
location: user.location
};
});
// Улучшенная сортировка пользователей
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;
});
console.log(`[USER_CTRL] Найдено ${sortedUsers.length} пользователей для рекомендаций (возраст: ${ageRangeMin}-${ageRangeMax})`);
res.status(200).json(sortedUsers);
} 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,
// Если это первая фотография, делаем ее главной. Иначе - нет.
isProfilePhoto: user.photos.length === 0,
};
// Добавляем новое фото в массив фотографий пользователя
user.photos.push(newPhoto);
await user.save();
// Получаем обновленного пользователя с обновленным массивом фотографий
const updatedUser = await User.findById(req.user._id).lean(); // .lean() для простого объекта
// Важно! Явно создаем массив allPhotos для ответа, чтобы убедиться, что структура каждого объекта фото совпадает
const allPhotos = updatedUser.photos.map(photo => ({
_id: photo._id, // Добавляем _id для фото
url: photo.url,
public_id: photo.public_id,
isProfilePhoto: photo.isProfilePhoto
}));
// Найдем только что добавленное фото в обновленном массиве, чтобы вернуть его с _id
const addedPhotoWithId = allPhotos.find(p => p.public_id === newPhoto.public_id);
// Возвращаем ответ с точной структурой, ожидаемой тестами
res.status(200).json({
message: 'Фотография успешно загружена!',
photo: addedPhotoWithId, // Возвращаем фото с _id
allPhotos: allPhotos
});
} catch (error) {
console.error('[USER_CTRL] Ошибка при загрузке фотографии:', error.message, error.stack);
if (error.http_code) {
error.statusCode = error.http_code;
}
next(error);
}
};
// @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);
}
};
// @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 } // Которые текущий пользователь не читал
});
// 4. Получаем общее количество сообщений пользователя
const totalMessagesCount = await Message.countDocuments({
conversationId: { $in: conversationIds },
sender: currentUserId // Только сообщения, отправленные текущим пользователем
});
// 5. Получаем количество просмотров профиля
const profileViewsCount = await ProfileView.countDocuments({
profileOwner: currentUserId
});
const stats = {
profileViews: profileViewsCount,
likes: likesCount,
matches: matchesCount,
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);
}
};
module.exports = {
updateUserProfile,
getUsersForSwiping,
uploadUserProfilePhoto,
setMainPhoto,
deletePhoto,
getUserById,
getUserStats,
recordProfileView, // <--- Добавляем новую функцию
};