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, // <--- Добавляем новую функцию };