diff --git a/controllers/application/academy/academyControllers.js b/controllers/application/academy/academyControllers.js index 2d465fa..658adb7 100644 --- a/controllers/application/academy/academyControllers.js +++ b/controllers/application/academy/academyControllers.js @@ -15,6 +15,13 @@ const CourseComment = require("../../../models/CourseComentModel"); const IsPaymentCourse = require("../../../models/IsPaymentCourseModel"); const Tax = require("../../../models/TaxModel"); const AcademyCategoryModel = require('../../../models/AcademyCategoryModel'); +const { + createLikeNotification, + resolveCourseOwnerId +} = require('../../../utils/likeNotification'); +const { + createCommentNotification +} = require('../../../utils/commentNotification'); const onServerRestart = async () => { const CoursePayment = await CoursePaymentModel.findOne({ @@ -1178,6 +1185,14 @@ const likeCourseContent = async (req, res, next) => { createdAt: new Date(), }); + const courseOwnerId = await resolveCourseOwnerId(course); + await createLikeNotification({ + ownerId: courseOwnerId, + likerId: userId, + entityId: course._id, + type: 'academy_like' + }); + res.status(200).json({ success: true, message: "دوره با موفقیت لایک شد", @@ -1394,6 +1409,14 @@ const createComment = async (req, res) => { newComment._id ).populate("user_id", "name username"); + const courseOwnerId = await resolveCourseOwnerId(course); + await createCommentNotification({ + ownerId: courseOwnerId, + commenterId: userId, + entityId: course._id, + type: 'academy_comment' + }); + res.status(201).json({ success: true, message: "کامنت با موفقیت ثبت شد", diff --git a/controllers/application/advertising/advertisingController.js b/controllers/application/advertising/advertisingController.js index f8278ee..772da30 100644 --- a/controllers/application/advertising/advertisingController.js +++ b/controllers/application/advertising/advertisingController.js @@ -14,6 +14,11 @@ const AdvertisingLikeModel = require('../../../models/AdvertisingLikeModel') const AdvertisingComment = require('../../../models/AdvertisingCommentModel') const AdvertisingRatingModel = require('../../../models/AdvertisingRatingModel') const AdvertisingProfileModel = require('../../../models/AdvertisingProfile') +const { createLikeNotification } = require('../../../utils/likeNotification') +const { + createCommentNotification, + createRatingNotification +} = require('../../../utils/commentNotification') const createAdvertisingValidationRules = () => { return [ @@ -698,6 +703,14 @@ const toggleLike = async (req, res, next) => { // افزایش تعداد لایک‌ها در تبلیغات advertising.likesCount += 1 await advertising.save() + + await createLikeNotification({ + ownerId: advertising.creator_id, + likerId: userId, + entityId: advertising._id, + type: 'billboard_like' + }) + return res.status(201).json({ message: 'ویترین با موفقیت لایک شد' }) } } catch (error) { @@ -727,8 +740,16 @@ const addComment = async (req, res, next) => { }) await newComment.save() - // افزایش تعداد کامنت‌های تبلیغ - // await AdvertisingModel.findByIdAndUpdate(advertisingId, { $inc: { commentsCount: 1 } }) + + const advertising = await AdvertisingModel.findById(advertisingId) + if (advertising) { + await createCommentNotification({ + ownerId: advertising.creator_id, + commenterId: userId, + entityId: advertising._id, + type: 'billboard_comment' + }) + } res.status(201).json({ message: 'کامنت با موفقیت اضافه شد', comment: newComment }) } catch (error) { @@ -783,6 +804,13 @@ const rateAdvertising = async (req, res, next) => { rating }) await newRating.save() + + await createRatingNotification({ + ownerId: advertising.creator_id, + raterId: userId, + entityId: advertising._id, + type: 'billboard_rating' + }) } // به‌روزرسانی امتیازات کلی کاربر diff --git a/controllers/application/login/loginController.js b/controllers/application/login/loginController.js index 2c2015c..a14f46e 100644 --- a/controllers/application/login/loginController.js +++ b/controllers/application/login/loginController.js @@ -1,6 +1,7 @@ const UserModel = require('../../../models/UserModel') const { check, validationResult } = require('express-validator') const { default: axios } = require('axios') +const { OTP_VALID_MS } = require('../../../utils/otpExpiry') const loginValidationRules = () => { return [ check('mobile') @@ -34,18 +35,22 @@ const loginUser = async (req, res, next) => { } const otp = generateOTP() + const otpSentAt = new Date() // eslint-disable-next-line no-unused-vars const user = await UserModel.findOneAndUpdate( { mobile }, - { $set: { mobile, otp } }, + { $set: { mobile, otp: String(otp), otpSentAt } }, { upsert: true, new: true, lean: true } ) - // زمانبندی تنظیم مقدار otp به null پس از 10 دقیقه setTimeout(() => { - UserModel.findOneAndUpdate({ mobile }, { $set: { otp: null } }, { new: true }) + UserModel.findOneAndUpdate( + { mobile }, + { $set: { otp: null, otpSentAt: null } }, + { new: true } + ) .then(() => {}) .catch(error => console.error('Error setting OTP to null:', error)) - }, 5 * 60 * 1000) + }, OTP_VALID_MS) const data = JSON.stringify({ mobile, templateId: '930719', diff --git a/controllers/application/login/loginWithUserName.js b/controllers/application/login/loginWithUserName.js index ded6b6f..e0ffa74 100644 --- a/controllers/application/login/loginWithUserName.js +++ b/controllers/application/login/loginWithUserName.js @@ -2,8 +2,14 @@ const UserModel = require('../../../models/UserModel') const bcrypt = require('bcryptjs') const TokenService = require('../../../services/TokenService') +const { + expireBlockSuspensionIfNeeded, + getBlockedAccountResponse +} = require('../../../utils/blockSuspension') // const jwt = require('jsonwebtoken') +const INVALID_CREDENTIALS = 'نام کاربری یا کلمه عبور را اشتباه وارد کردید' + const loginWithUserName = async (req, res, next) => { try { // اعتبارسنجی ورودی ها @@ -11,30 +17,36 @@ const loginWithUserName = async (req, res, next) => { if (!user_name || !password) { return res.status(422).json({ error: true, - message: 'اطلاعات ارسالی اشتباه است' + message: INVALID_CREDENTIALS }) } // یافتن کاربر با نام کاربری const userLow = user_name.toLowerCase() - const user = await UserModel.findOne({ user_name: userLow }) + let user = await UserModel.findOne({ user_name: userLow }) if (!user) { - return res.status(404).json({ + return res.status(422).json({ error: true, - message: 'کاربری با این نام کاربری یافت نشد' + message: INVALID_CREDENTIALS }) } + + user = await expireBlockSuspensionIfNeeded(user) + const blockResponse = getBlockedAccountResponse(user) + if (blockResponse) { + return res.status(403).json(blockResponse) + } if (!user.password) { return res.status(422).json({ error: true, - message: 'رمز عبور برای این کاربر تنظیم نشده است' + message: INVALID_CREDENTIALS }) } const isPasswordValid = await bcrypt.compare(password, user.password) if (!isPasswordValid) { return res.status(422).json({ error: true, - message: 'نام کاربری یا رمز عبور اشتباه است' + message: INVALID_CREDENTIALS }) } // اگر هم نام کاربری و هم رمز عبور درست بود، ارسال پیام موفقیت آمیز diff --git a/controllers/application/login/verifyController.js b/controllers/application/login/verifyController.js index abff53c..2c57e90 100644 --- a/controllers/application/login/verifyController.js +++ b/controllers/application/login/verifyController.js @@ -1,5 +1,10 @@ const UserModel = require('../../../models/UserModel') const TokenService = require('../../../services/TokenService') +const { isOtpExpired } = require('../../../utils/otpExpiry') +const { + expireBlockSuspensionIfNeeded, + getBlockedAccountResponse +} = require('../../../utils/blockSuspension') // const jwt = require('jsonwebtoken') const verifyUser = async (req, res, next) => { @@ -12,15 +17,30 @@ const verifyUser = async (req, res, next) => { }) } - const user = await UserModel.findOne({ mobile }) + let user = await UserModel.findOne({ mobile }) if (!user) { return res.status(404).json({ error: true, message: 'کاربری با این شماره موبایل یافت نشد' }) } + + user = await expireBlockSuspensionIfNeeded(user) + const blockResponse = getBlockedAccountResponse(user) + if (blockResponse) { + return res.status(403).json(blockResponse) + } + const token = TokenService.sign({ id: user._id }) - if (user.otp === otp) { + + if (!user.otp || isOtpExpired(user)) { + return res.status(422).json({ + error: true, + message: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید' + }) + } + + if (String(user.otp) === String(otp)) { // if (user.user_name === null) { // return res.json({ // message: 'کد تایید صحیح بود', @@ -77,7 +97,7 @@ const verifyUser = async (req, res, next) => { return res.status(422).json({ error: true, - message: 'کد تایید اشتباه است' + message: 'کد را اشتباه وارد کردید' }) } catch (error) { next(error) diff --git a/controllers/application/messages/messageController.js b/controllers/application/messages/messageController.js index a3871f1..4ce0412 100644 --- a/controllers/application/messages/messageController.js +++ b/controllers/application/messages/messageController.js @@ -2,6 +2,10 @@ const MessageModel = require('../../../models/MessageModel') const UserModel = require('../../../models/UserModel') const jwt = require('jsonwebtoken') const moment = require('moment-jalaali') +const { + viewerBlockedUser, + userBlockedViewer, +} = require('../../../utils/blockVisibility') const getMessages = async (req, res, next) => { try { const token = req.header('Authorization').split(' ')[1] @@ -74,8 +78,8 @@ const getMessages = async (req, res, next) => { } // بررسی آیا کاربر فعلی در لیست بلاک‌شده‌های این کاربر است یا خیر - const blockedByCurrentUser = user.blocked_by.includes(userId) - const blockedYou = user.blocked_users.includes(userId) + const blockedByCurrentUser = viewerBlockedUser(user, userId) + const blockedYou = userBlockedViewer(user, userId) return { first_name: user.first_name, diff --git a/controllers/application/offer/offerController.js b/controllers/application/offer/offerController.js index 7c22be0..9e532f2 100644 --- a/controllers/application/offer/offerController.js +++ b/controllers/application/offer/offerController.js @@ -8,6 +8,10 @@ const PaymentModel = require('../../../models/PaymentModel') const NotificationModel = require('../../../models/NotificationModel') const { default: axios } = require('axios') const CommentModel = require('../../../models/CommentModel') +const { + resolveRatingForComment, + recalculateUserRating +} = require('../../../utils/commentRating') const getOfferPriceFromDatabase = async (itemName) => { const item = await OfferTypeModel.findOne({ name: itemName }) @@ -374,55 +378,35 @@ const createOfferComment = async (req, res, next) => { const userId = decodedToken.id const { offerId, comment, rate, user_id } = req.body - if (!offerId || !comment || !rate || !user_id) { + if (!offerId || !comment || !user_id) { return res.status(400).send({ message: 'All fields are required' }) } - // بررسی اینکه آیا کاربر قبلاً برای این پیشنهاد نظر داده است - const existingComment = await CommentModel.findOne({ - offer: offerId, - creator: userId + + const { ratingValue, isNewRating, requiresRating } = await resolveRatingForComment({ + creatorId: userId, + targetUserId: user_id, + rate }) - if (existingComment) { - return res.status(400).send({ message: 'شما قبلاً نظر خود را برای این درخواست ثبت کرده‌اید' }) + + if (requiresRating) { + return res.status(400).send({ message: 'Rating must be between 1 and 5' }) } + const newComment = new CommentModel({ user: user_id, offer: offerId, creator: userId, - rating: rate, + rating: ratingValue, comment, comment_for: 'user', status: 'pending' }) await newComment.save() - const user = await UserModel.findById(user_id) - - // پیدا کردن کامنت‌ها و محاسبه‌ی امتیاز کل - const comments = await CommentModel.find({ user: user_id, comment_for: 'user' }) - const totalRating = Number(comments.reduce((acc, comment) => acc + comment.rating, 0)) - - let userLevel - if (user.expertise === 'مدل') { - if (totalRating <= 50) userLevel = 'تازه وارد' - else if (totalRating <= 100) userLevel = 'استاندارد' - else if (totalRating <= 300) userLevel = 'حرفه‌ای' - else userLevel = 'استاد' - } else if (['زیبایی', 'عکاس'].includes(user.expertise)) { - if (totalRating <= 50) userLevel = 'تازه وارد' - else if (totalRating <= 100) userLevel = 'استاندارد' - else if (totalRating <= 300) userLevel = 'حرفه‌ای' - else userLevel = 'استاد' + if (isNewRating) { + await recalculateUserRating(user_id) } - user.user_score = totalRating - user.user_level = userLevel - - const totalComments = await CommentModel.countDocuments({ user: user_id, comment_for: 'user' }) - const averageRating = totalRating / totalComments - user.rate = averageRating.toFixed(1) - await user.save() - res.status(200).json({ message: 'نظر شما با موفقیت ثبت شد' }) } catch (error) { next(error) diff --git a/controllers/application/posts/likeController.js b/controllers/application/posts/likeController.js index 9b511b8..9c2b078 100644 --- a/controllers/application/posts/likeController.js +++ b/controllers/application/posts/likeController.js @@ -3,6 +3,7 @@ const PostModel = require('../../../models/PostModel'); const jwt = require('jsonwebtoken'); const UserModel = require('../../../models/UserModel'); const LikeModel = require('../../../models/LikeModel'); +const { createLikeNotification } = require('../../../utils/likeNotification'); const toggleLike = async (req, res, next) => { try { @@ -48,6 +49,13 @@ const toggleLike = async (req, res, next) => { await post.save(); await LikeModel.create({ postId, userId }); + await createLikeNotification({ + ownerId: post.user_id, + likerId: userId, + entityId: post._id, + type: 'post_like' + }); + return res.status(200).json({ message: 'پست با موفقیت لایک شد', is_liked: true, diff --git a/controllers/application/posts/postController.js b/controllers/application/posts/postController.js index 89db710..8e3d9bd 100644 --- a/controllers/application/posts/postController.js +++ b/controllers/application/posts/postController.js @@ -5,6 +5,7 @@ const { check, validationResult } = require('express-validator'); const jwt = require('jsonwebtoken'); const path = require('path'); const { default: mongoose } = require('mongoose'); +const { viewerIsBlockedBy } = require('../../../utils/blockVisibility'); const createPostValidationRules = () => { console.log(4); @@ -139,6 +140,15 @@ const getUserPostsWeb = async (req, res, next) => { return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' }) } + if (userReqId && viewerIsBlockedBy(user, userReqId)) { + return res.status(200).json({ + posts: [], + postsCount: 0, + totalPages: 0, + totalItems: 0, + }) + } + const data = {} const options = { page: req.query.page || 1, diff --git a/controllers/application/profile/profileController.js b/controllers/application/profile/profileController.js index bda10aa..2fc0f74 100644 --- a/controllers/application/profile/profileController.js +++ b/controllers/application/profile/profileController.js @@ -35,6 +35,7 @@ const getUserById = async (req, res, next) => { user_name: user.user_name, show_location: user.show_location, is_verified: user.is_verified, + is_Register: user.is_Register, user_score: user.user_score, rate: user.rate, bio: user.bio, diff --git a/controllers/application/projects/updateProjectController.js b/controllers/application/projects/updateProjectController.js index 2343404..41aaeed 100644 --- a/controllers/application/projects/updateProjectController.js +++ b/controllers/application/projects/updateProjectController.js @@ -6,6 +6,46 @@ const { check, validationResult } = require('express-validator') const { ProvinceModel, CityModel } = require('../../../models/StateCity') const NotificationModel = require('../../../models/NotificationModel') const CommentModel = require('../../../models/CommentModel') +const { + resolveRatingForComment, + recalculateUserRating +} = require('../../../utils/commentRating') + +const saveProjectComment = async ({ + targetUserId, + projectId, + creatorId, + comment, + rate, + commentFor +}) => { + const { ratingValue, isNewRating, requiresRating } = await resolveRatingForComment({ + creatorId, + targetUserId, + rate + }) + + if (requiresRating) { + return { error: { status: 400, message: 'Rating must be between 1 and 5' } } + } + + const newComment = new CommentModel({ + user: targetUserId, + project: projectId, + creator: creatorId, + rating: ratingValue, + comment, + comment_for: commentFor, + status: 'pending' + }) + await newComment.save() + + if (isNewRating && commentFor === 'user') { + await recalculateUserRating(targetUserId) + } + + return { ratingValue, isNewRating } +} const doneProject = async (req, res, next) => { try { @@ -16,7 +56,7 @@ const doneProject = async (req, res, next) => { const userId = decodedToken.id const { project_id, comment, rate, user_id } = req.body - if (!project_id || !comment || !rate || !user_id) { + if (!project_id || !comment || !user_id) { return res.status(400).send({ message: 'All fields are required' }) } @@ -42,43 +82,20 @@ const doneProject = async (req, res, next) => { project.status = 'done' await project.save() - const newComment = new CommentModel({ - user: user_id, - project: project_id, - creator: userId, - rating: rate, + const commentResult = await saveProjectComment({ + targetUserId: user_id, + projectId: project_id, + creatorId: userId, comment, - comment_for: 'user', - status: 'pending' + rate, + commentFor: 'user' }) - await newComment.save() - const user = await UserModel.findById(user_id) - - // پیدا کردن کامنت‌ها و محاسبه‌ی امتیاز کل - const comments = await CommentModel.find({ user: user_id, comment_for: 'user' }) - const totalRating = Number(comments.reduce((acc, comment) => acc + comment.rating, 0)) - - let userLevel - if (user.expertise === 'مدل') { - if (totalRating <= 50) userLevel = 'تازه وارد' - else if (totalRating <= 100) userLevel = 'استاندارد' - else if (totalRating <= 300) userLevel = 'حرفه‌ای' - else userLevel = 'استاد' - } else if (['زیبایی', 'عکاس'].includes(user.expertise)) { - if (totalRating <= 50) userLevel = 'تازه وارد' - else if (totalRating <= 100) userLevel = 'استاندارد' - else if (totalRating <= 300) userLevel = 'حرفه‌ای' - else userLevel = 'استاد' + if (commentResult.error) { + return res.status(commentResult.error.status).send({ message: commentResult.error.message }) } - user.user_score = totalRating - user.user_level = userLevel - - const totalComments = await CommentModel.countDocuments({ user: user_id, comment_for: 'user' }) - const averageRating = totalRating / totalComments - user.rate = averageRating.toFixed(1) - await user.save() + const user = await UserModel.findById(user_id) const notification = new NotificationModel({ user_id: user._id, @@ -105,7 +122,7 @@ const doneProjectWeb = async (req, res, next) => { const userId = decodedToken.id const { project_id, comment, rate, user_id } = req.body - if (!project_id || !comment || !rate || !user_id) { + if (!project_id || !comment || !user_id) { return res.status(400).send({ message: 'All fields are required' }) } @@ -117,43 +134,20 @@ const doneProjectWeb = async (req, res, next) => { project.status = 'done' await project.save() - const newComment = new CommentModel({ - user: user_id, - project: project_id, - creator: userId, - rating: rate, + const commentResult = await saveProjectComment({ + targetUserId: user_id, + projectId: project_id, + creatorId: userId, comment, - comment_for: 'user', - status: 'pending' + rate, + commentFor: 'user' }) - await newComment.save() - const user = await UserModel.findById(user_id) - - // پیدا کردن کامنت‌ها و محاسبه‌ی امتیاز کل - const comments = await CommentModel.find({ user: user_id, comment_for: 'user' }) - const totalRating = Number(comments.reduce((acc, comment) => acc + comment.rating, 0)) - - let userLevel - if (user.expertise === 'مدل') { - if (totalRating <= 50) userLevel = 'تازه وارد' - else if (totalRating <= 100) userLevel = 'استاندارد' - else if (totalRating <= 300) userLevel = 'حرفه‌ای' - else userLevel = 'استاد' - } else if (['زیبایی', 'عکاس'].includes(user.expertise)) { - if (totalRating <= 50) userLevel = 'تازه وارد' - else if (totalRating <= 100) userLevel = 'استاندارد' - else if (totalRating <= 300) userLevel = 'حرفه‌ای' - else userLevel = 'استاد' + if (commentResult.error) { + return res.status(commentResult.error.status).send({ message: commentResult.error.message }) } - user.user_score = totalRating - user.user_level = userLevel - - const totalComments = await CommentModel.countDocuments({ user: user_id, comment_for: 'user' }) - const averageRating = totalRating / totalComments - user.rate = averageRating.toFixed(1) - await user.save() + const user = await UserModel.findById(user_id) const notification = new NotificationModel({ user_id: user._id, @@ -182,7 +176,7 @@ const cancleProject = async (req, res, next) => { const { project_id, comment, rate, user_id } = req.body - if (!project_id || !comment || !rate || !user_id) { + if (!project_id || !comment || !user_id) { return res.status(400).send({ message: 'All fields are required' }) } @@ -194,16 +188,18 @@ const cancleProject = async (req, res, next) => { project.status = 'cancled' await project.save() - const newComment = new CommentModel({ - user: user_id, - project: project_id, - creator: userId, - rating: rate, + const commentResult = await saveProjectComment({ + targetUserId: user_id, + projectId: project_id, + creatorId: userId, comment, - comment_for: 'project', - status: 'pending' + rate, + commentFor: 'project' }) - await newComment.save() + + if (commentResult.error) { + return res.status(commentResult.error.status).send({ message: commentResult.error.message }) + } res.status(200).json({ message: 'پروژه با موفقیت تغییر یافت شد' }) } catch (error) { @@ -363,6 +359,14 @@ const setRateProject = async (req, res, next) => { if (!project) { return res.status(403).send({ message: 'Access denied: You are not the creator of this project.' }) } + + const alreadyRated = project.ratings.some((item) => + item.creator_id.toString() === userId.toString() + ) + if (alreadyRated) { + return res.status(400).send({ message: 'شما قبلاً به این پروژه امتیاز داده‌اید' }) + } + project.ratings.push({ project_id, rating: rate, diff --git a/controllers/application/register/registerController.js b/controllers/application/register/registerController.js index 836d82d..02af194 100644 --- a/controllers/application/register/registerController.js +++ b/controllers/application/register/registerController.js @@ -1,6 +1,7 @@ const { default: axios } = require('axios') const UserModel = require('../../../models/UserModel') const { check, validationResult } = require('express-validator') +const { OTP_VALID_MS } = require('../../../utils/otpExpiry') // const { Token, VerificationCode } = require('sms-ir') const registerValidationRules = () => { @@ -36,17 +37,22 @@ const registerUser = async (req, res, next) => { } const otp = generateOTP() + const otpSentAt = new Date() // eslint-disable-next-line no-unused-vars const user = await UserModel.findOneAndUpdate( { mobile }, - { $set: { mobile, otp } }, + { $set: { mobile, otp: String(otp), otpSentAt } }, { upsert: true, new: true, lean: true } ) setTimeout(() => { - UserModel.findOneAndUpdate({ mobile }, { $set: { otp: null } }, { new: true }) + UserModel.findOneAndUpdate( + { mobile }, + { $set: { otp: null, otpSentAt: null } }, + { new: true } + ) .then(() => {}) .catch(error => console.error('Error setting OTP to null:', error)) - }, 5 * 60 * 1000) + }, OTP_VALID_MS) const data = JSON.stringify({ mobile, templateId: '930719', diff --git a/controllers/application/register/userFullNameController.js b/controllers/application/register/userFullNameController.js index a1e2d87..412a0b8 100644 --- a/controllers/application/register/userFullNameController.js +++ b/controllers/application/register/userFullNameController.js @@ -34,8 +34,8 @@ const setUserFullName = async (req, res, next) => { } // آپدیت نام کاربری به صورت lowercase - user.first_name = first_name.toLowerCase() - user.last_name = last_name.toLowerCase() + user.first_name = first_name.trim().toLowerCase() + user.last_name = last_name.trim().toLowerCase() await user.save() return res.json({ diff --git a/controllers/application/register/usernameController.js b/controllers/application/register/usernameController.js index 8b2428c..0fa941e 100644 --- a/controllers/application/register/usernameController.js +++ b/controllers/application/register/usernameController.js @@ -1,6 +1,8 @@ /* eslint-disable camelcase */ const UserModel = require('../../../models/UserModel') const jwt = require('jsonwebtoken') +const { validateUsername } = require('../../../utils/usernameValidation') +const { generateUsernameSuggestions } = require('../../../utils/usernameSuggestions') const setUserName = async (req, res, next) => { try { @@ -12,7 +14,6 @@ const setUserName = async (req, res, next) => { }) } - // جستجوی کاربر با شماره موبایل ارسالی const user = await UserModel.findOne({ mobile }) if (!user) { return res.status(404).json({ @@ -20,32 +21,32 @@ const setUserName = async (req, res, next) => { message: 'کاربری با این شماره موبایل یافت نشد' }) } - // بررسی حداقل طول نام کاربری - if (user_name.length < 5) { + + const validationError = validateUsername(user_name) + if (validationError) { return res.status(422).json({ error: true, - message: 'نام کاربری باید حداقل 5 کاراکتر باشد' - }) - } - // بررسی محدودیت‌های مجاز در نام کاربری - // const usernameRegex = /^[a-zA-Z0-9_]+$/ - // if (!usernameRegex.test(user_name)) { - // return res.status(422).json({ - // error: true, - // message: 'نام کاربری فقط می‌تواند شامل حروف الفبای انگلیسی، اعداد و کاراکتر _ باشد' - // }) - // } - // بررسی تکراری بودن user_name - const existingUser = await UserModel.findOne({ user_name: { $regex: new RegExp('^' + user_name + '$', 'i') } }) - if (existingUser && existingUser._id.toString() !== user._id.toString()) { - return res.status(422).json({ - error: true, - message: 'نام کاربری تکراری است' + message: validationError }) } - // آپدیت نام کاربری به صورت lowercase - user.user_name = user_name.toLowerCase() + const normalizedUserName = user_name.trim().toLowerCase() + + const existingUser = await UserModel.findOne({ user_name: { $regex: new RegExp('^' + normalizedUserName + '$', 'i') } }) + if (existingUser && existingUser._id.toString() !== user._id.toString()) { + const suggestions = await generateUsernameSuggestions( + UserModel, + normalizedUserName, + user._id.toString() + ) + return res.status(422).json({ + error: true, + message: 'نام کاربری تکراری است', + suggestions + }) + } + + user.user_name = normalizedUserName await user.save() return res.json({ @@ -73,7 +74,37 @@ const updateUserName = async (req, res, next) => { }) } - const user = await UserModel.findByIdAndUpdate(userId, { user_name }, { new: true }) + const validationError = validateUsername(user_name) + if (validationError) { + return res.status(422).json({ + error: true, + message: validationError + }) + } + + const normalizedUserName = user_name.trim().toLowerCase() + + const existingUser = await UserModel.findOne({ + user_name: { $regex: new RegExp('^' + normalizedUserName + '$', 'i') } + }) + if (existingUser && existingUser._id.toString() !== userId) { + const suggestions = await generateUsernameSuggestions( + UserModel, + normalizedUserName, + userId + ) + return res.status(422).json({ + error: true, + message: 'نام کاربری تکراری است', + suggestions + }) + } + + const user = await UserModel.findByIdAndUpdate( + userId, + { user_name: normalizedUserName }, + { new: true } + ) if (!user) { return res.status(404).json({ diff --git a/controllers/application/register/verifyController.js b/controllers/application/register/verifyController.js index e5b8cf1..a43bb71 100644 --- a/controllers/application/register/verifyController.js +++ b/controllers/application/register/verifyController.js @@ -1,5 +1,6 @@ const UserModel = require('../../../models/UserModel') const TokenService = require('../../../services/TokenService') +const { isOtpExpired } = require('../../../utils/otpExpiry') // const jwt = require('jsonwebtoken') const verifyUser = async (req, res, next) => { @@ -25,7 +26,14 @@ const verifyUser = async (req, res, next) => { (!user.password) { step = 'password' } else if (!user.first_name) { step = 'first_name' } else if (!user.user_type) { step = 'user_type' } else { step = 'profile_image' } - if (user.otp === otp) { + if (!user.otp || isOtpExpired(user)) { + return res.status(422).json({ + error: true, + message: 'کد تایید منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید' + }) + } + + if (String(user.otp) === String(otp)) { return res.json({ message: 'کد تایید صحیح بود', token, @@ -35,7 +43,7 @@ const verifyUser = async (req, res, next) => { return res.status(422).json({ error: true, - message: 'کد تایید اشتباه است' + message: 'کد را اشتباه وارد کردید' }) } catch (error) { next(error) diff --git a/controllers/application/users/getUserController.js b/controllers/application/users/getUserController.js index 6965266..f461737 100644 --- a/controllers/application/users/getUserController.js +++ b/controllers/application/users/getUserController.js @@ -6,11 +6,25 @@ const jwt = require("jsonwebtoken"); const moment = require("moment-jalaali"); const NotificationModel = require("../../../models/NotificationModel"); const CommentModel = require("../../../models/CommentModel"); +const { + hasCreatorRatedUser, + resolveRatingForComment, + recalculateUserRating, +} = require("../../../utils/commentRating"); +const { createCommentNotification } = require("../../../utils/commentNotification"); const { ProvinceModel, CityModel } = require("../../../models/StateCity"); const OfferModel = require("../../../models/OfferModel"); const PostModel = require("../../../models/PostModel"); const ProjectModel = require("../../../models/ProjectModel"); const LicenseModel = require("../../../models/license") +const { + viewerIsBlockedBy, + viewerBlockedUser, + userBlockedViewer, + blockedProfilePayload, + getBlockerUserIds, +} = require("../../../utils/blockVisibility") +const { buildUserLevelMongoFilter, normalizeUserLevel } = require("../../../utils/userLevelFilter") const getAllLicenses = async (req, res, next) => { @@ -310,7 +324,8 @@ const getUsers = async (req, res, next) => { } } if (userLevel) { - filter.user_level = userLevel; + const levelFilter = buildUserLevelMongoFilter(userLevel) + if (levelFilter) Object.assign(filter, levelFilter) } let users = await UserModel.aggregate([ @@ -775,9 +790,19 @@ const getPostsWeb = async (req, res, next) => { .lean(); const userIds = [...new Set(explorePosts.map((p) => String(p.user_id)))]; const usersMap = await UserModel.find({ _id: { $in: userIds } }) - .select('_id user_name first_name last_name expertise province city user_level') + .select('_id user_name first_name last_name expertise province city user_level show_location blocked_users') .lean(); const usersById = Object.fromEntries(usersMap.map((u) => [String(u._id), u])); + + if (decodedToken?.id) { + const blockerIds = new Set( + (await getBlockerUserIds(UserModel, decodedToken.id)).map(String) + ); + explorePosts = explorePosts.filter( + (post) => !blockerIds.has(String(post.user_id)) + ); + } + explorePosts = explorePosts.map((post) => { const user = usersById[String(post.user_id)] || {}; return { @@ -786,8 +811,9 @@ const getPostsWeb = async (req, res, next) => { first_name: user.first_name || '', last_name: user.last_name || '', expertise: user.expertise || '', - province: user.province || {}, - city: user.city || {}, + province: user.show_location ? user.province || {} : {}, + city: user.show_location ? user.city || {} : {}, + show_location: user.show_location, user_level: user.user_level || '', likesCount: post.likes ? post.likes.length : 0, is_liked: false, @@ -814,11 +840,21 @@ const getPostsWeb = async (req, res, next) => { const cityFind = await CityModel.findOne({ id: city }); if (cityFind) userFilter["city.id"] = cityFind.id; } - if (userLevel) userFilter.user_level = userLevel; + if (userLevel) { + const levelFilter = buildUserLevelMongoFilter(userLevel) + if (levelFilter) Object.assign(userFilter, levelFilter) + } if (_id) userFilter._id = _id; + if (_id && decodedToken?.id) { + const profileOwner = await UserModel.findById(_id).select('blocked_users'); + if (viewerIsBlockedBy(profileOwner, decodedToken.id)) { + return res.status(200).json({ posts: [], totalPages: 0, totalItems: 0 }); + } + } + const users = await UserModel.find(userFilter) - .select("_id user_name first_name last_name expertise province city user_level") + .select("_id user_name first_name last_name expertise province city user_level show_location") .lean(); if (!users || users.length === 0) { @@ -836,6 +872,16 @@ const getPostsWeb = async (req, res, next) => { return res.status(200).json({ posts: [], totalPages: 0, totalItems: 0 }); } + if (decodedToken?.id) { + const blockerIds = new Set( + (await getBlockerUserIds(UserModel, decodedToken.id)).map(String) + ); + posts = posts.filter((post) => !blockerIds.has(String(post.user_id))); + if (!posts.length) { + return res.status(200).json({ posts: [], totalPages: 0, totalItems: 0 }); + } + } + let likeFilter = {}; if (decodedToken) likeFilter.userId = decodedToken.id; const likes = decodedToken @@ -860,8 +906,9 @@ const getPostsWeb = async (req, res, next) => { first_name: user?.first_name || "", last_name: user?.last_name || "", expertise: user?.expertise || "", - province: user?.province || {}, - city: user?.city || {}, + province: user?.show_location ? user?.province || {} : {}, + city: user?.show_location ? user?.city || {} : {}, + show_location: user?.show_location, user_level: user?.user_level || "", likesCount: post.likes ? post.likes.length : 0, is_liked: !!likesMap[post._id.toString()], @@ -915,8 +962,12 @@ const getSingleUser = async (req, res, next) => { const lastOnline = moment(user.last_online).format("jYYYY-jMM-jDD"); // بررسی بلاک شدن - const blockedByCurrentUser = user.blocked_by.includes(userReqId); - const blockedYou = user.blocked_users.includes(userReqId); + const blockedByCurrentUser = viewerBlockedUser(user, userReqId); + const blockedYou = userBlockedViewer(user, userReqId); + + if (blockedYou) { + return res.status(200).json({ user: blockedProfilePayload(user) }); + } // Allow chat const offerExists = await OfferModel.exists({ @@ -937,6 +988,7 @@ const getSingleUser = async (req, res, next) => { user_name: user.user_name, show_location: user.show_location, is_verified: user.is_verified, + is_Register: user.is_Register, user_score: user.user_score, rate: user.rate, bio: user.bio, @@ -948,8 +1000,8 @@ const getSingleUser = async (req, res, next) => { profile_image: user.profile_image, lat: user.show_location ? user.lat : null, lng: user.show_location ? user.lng : null, - province: user.province, - city: user.city, + province: user.show_location ? user.province : null, + city: user.show_location ? user.city : null, address: user.show_location ? user.address : null, is_blocked: blockedByCurrentUser, blocked_you: blockedYou, @@ -1009,13 +1061,18 @@ const getSingleUserWeb = async (req, res, next) => { let offerExists = false; if (userReqId) { - blockedByCurrentUser = user.blocked_by.includes(userReqId); - blockedYou = user.blocked_users.includes(userReqId); + blockedByCurrentUser = viewerBlockedUser(user, userReqId); + blockedYou = userBlockedViewer(user, userReqId); offerExists = await OfferModel.exists({ sender: userReqId, receiver: user._id, }); } + + if (blockedYou && userReqId) { + return res.status(200).json({ user: blockedProfilePayload(user) }); + } + // دریافت تعداد پست‌ها و پروژه‌ها const postsCount = await PostModel.countDocuments({ user_id: user._id, @@ -1043,6 +1100,7 @@ const getSingleUserWeb = async (req, res, next) => { user_name: user.user_name, show_location: user.show_location, is_verified: user.is_verified, + is_Register: user.is_Register, user_score: user.user_score, rate: user.rate, bio: user.bio, @@ -1054,8 +1112,8 @@ const getSingleUserWeb = async (req, res, next) => { profile_image: user.profile_image, lat: user.show_location ? user.lat : null, lng: user.show_location ? user.lng : null, - province: user.province, - city: user.city, + province: user.show_location ? user.province : null, + city: user.show_location ? user.city : null, address: user.show_location ? user.address : null, is_blocked: blockedByCurrentUser, blocked_you: blockedYou, @@ -1099,6 +1157,11 @@ const getUserContactInfo = async (req, res, next) => { next(error); } }; +const { + applyAutoBlockSuspension, + clearAutoBlockSuspension +} = require('../../../utils/blockSuspension') + const blockUser = async (req, res) => { try { const { user_to_block } = req.body; @@ -1107,17 +1170,30 @@ const blockUser = async (req, res) => { const decodedToken = jwt.verify(token, process.env.APP_SECRET); const userReqId = decodedToken.id; - const currentUser = await UserModel.findById(userReqId); // Req - const userToBlock = await UserModel.findById(user_to_block); // To Block - - userToBlock.blocked_by.push(currentUser); - currentUser.blocked_users.push(userToBlock); - - // بررسی تعداد بلاک ها و تنظیم block_status - if (userToBlock.blocked_by.length >= 3) { - userToBlock.block_status = true; + if (userReqId === user_to_block) { + return res.status(400).json({ message: "امکان بلاک خودتان وجود ندارد" }); } + const currentUser = await UserModel.findById(userReqId); + const userToBlock = await UserModel.findById(user_to_block); + + if (!currentUser || !userToBlock) { + return res.status(404).json({ message: "کاربر یافت نشد" }); + } + + const alreadyBlocked = userToBlock.blocked_by.some((id) => + id.equals(currentUser._id) + ); + + if (alreadyBlocked) { + return res.status(400).json({ message: "این کاربر قبلاً بلاک شده است" }); + } + + userToBlock.blocked_by.push(currentUser._id); + currentUser.blocked_users.push(userToBlock._id); + + applyAutoBlockSuspension(userToBlock); + await currentUser.save(); await userToBlock.save(); @@ -1136,18 +1212,17 @@ const unblockUser = async (req, res) => { const decodedToken = jwt.verify(token, process.env.APP_SECRET); const userReqId = decodedToken.id; - const currentUser = await UserModel.findById(userReqId); // Req - const userToUnblock = await UserModel.findById(user_to_unblock); // To Unblock + const currentUser = await UserModel.findById(userReqId); + const userToUnblock = await UserModel.findById(user_to_unblock); + + if (!currentUser || !userToUnblock) { + return res.status(404).json({ message: "کاربر یافت نشد" }); + } - // حذف کاربر از لیست blocked_by کاربر فعلی currentUser.blocked_users.pull(userToUnblock._id); - // حذف کاربر از لیست blocked_users کاربر مورد نظر userToUnblock.blocked_by.pull(currentUser._id); - // بررسی تعداد بلاک ها و تنظیم block_status - if (userToUnblock.blocked_by.length < 3) { - userToUnblock.block_status = null; - } + clearAutoBlockSuspension(userToUnblock); await currentUser.save(); await userToUnblock.save(); @@ -1163,6 +1238,7 @@ const getComments = async (req, res, next) => { try { const token = req.header("Authorization").split(" ")[1]; if (!token) return res.status(401).send("Access Denied"); + const decodedToken = jwt.verify(token, process.env.APP_SECRET); const user_id = req.query.user_id; const post_id = req.query.post_id; @@ -1192,27 +1268,53 @@ const getComments = async (req, res, next) => { const comments = await CommentModel.paginate(filter, options); - const formattedComments = comments.docs.map((comment) => ({ - _id: comment._id, - comment: comment.comment, - rating: comment.rating, - createdAt: moment(comment.createdAt).format("jYYYY-jMM-jDD HH:mm"), - status: comment.status, - user: { - profile_image: comment.creator.profile_image, - user_name: comment.creator.user_name, - is_verified: comment.creator.is_verified, - _id: comment.creator._id, - }, - project: comment.project ? comment.project.project_name : null, - post_id: comment.post ? comment.post.toString() : null, - })); + const earliestRatedComments = await CommentModel.find({ + user: user_id, + rating: { $ne: null, $gt: 0 }, + }) + .select("creator _id createdAt") + .sort({ createdAt: 1 }); + + const ratedCreatorCommentIds = new Map(); + for (const ratedComment of earliestRatedComments) { + const creatorId = ratedComment.creator.toString(); + if (!ratedCreatorCommentIds.has(creatorId)) { + ratedCreatorCommentIds.set(creatorId, ratedComment._id.toString()); + } + } + + const formattedComments = comments.docs.map((comment) => { + const creatorId = comment.creator._id.toString(); + const isPrimaryRatingComment = + comment.rating != null && + comment.rating > 0 && + ratedCreatorCommentIds.get(creatorId) === comment._id.toString(); + + return { + _id: comment._id, + comment: comment.comment, + rating: isPrimaryRatingComment ? comment.rating : null, + createdAt: moment(comment.createdAt).format("jYYYY-jMM-jDD HH:mm"), + status: comment.status, + user: { + profile_image: comment.creator.profile_image, + user_name: comment.creator.user_name, + is_verified: comment.creator.is_verified, + _id: comment.creator._id, + }, + project: comment.project ? comment.project.project_name : null, + post_id: comment.post ? comment.post.toString() : null, + }; + }); + + const hasRated = await hasCreatorRatedUser(decodedToken.id, user_id); res.status(200).json({ comments: formattedComments, totalPages: comments.totalPages, totalItems: comments.totalDocs, currentPage: comments.page, + has_rated: hasRated, }); } catch (error) { next(error); @@ -1231,20 +1333,17 @@ const createUserComment = async (req, res, next) => { return res.status(400).send({ message: "All fields are required" }); } - const existingRating = await CommentModel.findOne({ - user: user_id, - creator: creatorId, - comment_for: post_id ? "post" : "user", - rating: { $exists: true, $ne: null } - }); + const { ratingValue, isNewRating, requiresRating } = + await resolveRatingForComment({ + creatorId, + targetUserId: user_id, + rate, + }); - let ratingValue = rate; - if (existingRating) { - ratingValue = existingRating.rating; - } else { - if (!rate || rate < 1 || rate > 5) { - return res.status(400).send({ message: "Rating must be between 1 and 5" }); - } + if (requiresRating) { + return res + .status(400) + .send({ message: "Rating must be between 1 and 5" }); } const newComment = new CommentModel({ @@ -1258,50 +1357,16 @@ const createUserComment = async (req, res, next) => { }); await newComment.save(); - const user = await UserModel.findById(user_id); - - // محاسبه امتیاز جدید کاربر - const comments = await CommentModel.find({ - user: user_id, - comment_for: "user", - }); - const totalRating = comments.reduce( - (acc, comment) => acc + comment.rating, - 0 - ); - const totalComments = comments.length; - const averageRating = totalRating / totalComments; - - // تعیین سطح کاربر - let userLevel; - if (user.expertise === "مدل") { - if (totalRating <= 50) userLevel = "تازه وارد"; - else if (totalRating <= 100) userLevel = "استاندارد"; - else if (totalRating <= 300) userLevel = "حرفه‌ای"; - else userLevel = "استاد"; - } else if (["زیبایی", "عکاس"].includes(user.expertise)) { - if (totalRating <= 50) userLevel = "تازه وارد"; - else if (totalRating <= 100) userLevel = "استاندارد"; - else if (totalRating <= 300) userLevel = "حرفه‌ای"; - else userLevel = "استاد"; + if (isNewRating) { + await recalculateUserRating(user_id); } - // به‌روزرسانی اطلاعات کاربر - user.user_score = totalRating; - user.user_level = userLevel; - user.rate = averageRating.toFixed(1); - await user.save(); - - // ارسال نوتیفیکیشن - const notification = new NotificationModel({ - user_id: user._id, - type: "user_comment", - title: "نظر جدید", - description: `یک کاربر جدید برای شما نظر ثبت کرد: - ${comment} - امتیاز: ${rate} از 5`, + await createCommentNotification({ + ownerId: user_id, + commenterId: creatorId, + entityId: post_id || user_id, + type: post_id ? "post_comment" : "profile_comment", }); - await notification.save(); res.status(201).json({ message: "نظر با موفقیت ثبت شد و بعد از تایید، منتشر میشود.", diff --git a/controllers/application/verify/addressController.js b/controllers/application/verify/addressController.js index baf7330..b8217f9 100644 --- a/controllers/application/verify/addressController.js +++ b/controllers/application/verify/addressController.js @@ -9,7 +9,18 @@ const setAddress = async (req, res, next) => { if (!token) return res.status(401).send('Access Denied') const { province_id, city_id, address, lat, lng, show_location } = req.body // اعتبارسنجی داده‌ها - if (!province_id || !city_id || !address || !lat || !lng || show_location === 'undefined') { + if ( + !province_id || + !city_id || + !address || + lat === undefined || + lat === null || + lat === '' || + lng === undefined || + lng === null || + lng === '' || + show_location === undefined + ) { return res.status(422).json({ error: true, message: 'اطلاعات ارسالی ناقص است' @@ -41,7 +52,8 @@ const setAddress = async (req, res, next) => { user.address = address user.lat = lat user.lng = lng - user.show_location = show_location + user.show_location = + show_location === true || show_location === 'true' await user.save() @@ -60,7 +72,18 @@ const updateAddress = async (req, res, next) => { const { province_id, city_id, address, lat, lng, show_location } = req.body // اعتبارسنجی داده‌ها - if (!province_id || !city_id || !address || !lat || !lng || show_location === undefined) { + if ( + !province_id || + !city_id || + !address || + lat === undefined || + lat === null || + lat === '' || + lng === undefined || + lng === null || + lng === '' || + show_location === undefined + ) { return res.status(422).json({ error: true, message: 'اطلاعات ارسالی ناقص است' @@ -95,7 +118,8 @@ const updateAddress = async (req, res, next) => { user.address = address user.lat = lat user.lng = lng - user.show_location = show_location + user.show_location = + show_location === true || show_location === 'true' await user.save() diff --git a/controllers/application/verify/completeRegistrationController.js b/controllers/application/verify/completeRegistrationController.js new file mode 100644 index 0000000..5555ef5 --- /dev/null +++ b/controllers/application/verify/completeRegistrationController.js @@ -0,0 +1,39 @@ +const UserModel = require('../../../models/UserModel') +const jwt = require('jsonwebtoken') + +const completeRegistration = async (req, res, next) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) return res.status(401).send('Access Denied') + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const user = await UserModel.findById(decodedToken.id) + + if (!user) { + return res.status(404).json({ + error: true, + message: 'کاربر یافت نشد' + }) + } + + user.is_Register = true + + if (!user.is_verified || user.is_verified === 'none') { + user.is_verified = 'pending' + } + + await user.save() + + return res.json({ + message: 'ثبت نام تکمیل شد', + is_verified: user.is_verified, + is_Register: user.is_Register + }) + } catch (error) { + next(error) + } +} + +module.exports = { + completeRegistration +} diff --git a/controllers/application/verify/setNationalCardImageController.js b/controllers/application/verify/setNationalCardImageController.js index 75b3a5a..3547e14 100644 --- a/controllers/application/verify/setNationalCardImageController.js +++ b/controllers/application/verify/setNationalCardImageController.js @@ -38,6 +38,7 @@ const setNationalCardImage = async (req, res, next) => { // ذخیره مسیر فایل در دیتابیس user.national_card_image = `/carts/${uniqueFileName}` + user.is_Register = true user.is_verified = 'pending' await user.save() diff --git a/controllers/application/verify/setProfileImageController.js b/controllers/application/verify/setProfileImageController.js index 7b4ac95..c32e9a9 100644 --- a/controllers/application/verify/setProfileImageController.js +++ b/controllers/application/verify/setProfileImageController.js @@ -33,7 +33,7 @@ const setProfileImage = async (req, res, next) => { } // ذخیره فایل عکس پروفایل - const uploadDir = path.join(__dirname, '../../../../storage/profiles') + const uploadDir = path.join(__dirname, '../../../storage/profiles') if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }) } @@ -78,7 +78,7 @@ const updateProfileImage = async (req, res, next) => { }) } - const uploadDir = path.join(__dirname, '../../../../storage/profiles') + const uploadDir = path.join(__dirname, '../../../storage/profiles') if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }) diff --git a/helpers/chatHelpers.js b/helpers/chatHelpers.js index f398a6d..827087e 100644 --- a/helpers/chatHelpers.js +++ b/helpers/chatHelpers.js @@ -30,6 +30,14 @@ async function enrichMessage(message) { return { ...doc, createdAt: formatTimeOnly(doc.createdAt), + createdAtIso: doc.createdAt ? new Date(doc.createdAt).toISOString() : undefined, + expiresAt: doc.expiresAt ? new Date(doc.expiresAt).toISOString() : undefined, + viewOnce: Boolean(doc.viewOnce), + viewOnceLocked: Boolean(doc.viewOnceLocked), + viewOnceExpired: Boolean(doc.viewOnceExpired), + viewOnceOpenedAt: doc.viewOnceOpenedAt + ? new Date(doc.viewOnceOpenedAt).toISOString() + : undefined, replyTo, forwardedFrom: doc.forwardedFrom || undefined } diff --git a/helpers/expiredMessages.js b/helpers/expiredMessages.js new file mode 100644 index 0000000..e0dbd62 --- /dev/null +++ b/helpers/expiredMessages.js @@ -0,0 +1,129 @@ +const path = require('path') +const fs = require('fs-extra') +const MessageModel = require('../models/MessageModel') + +const ALLOWED_SELF_DESTRUCT_SECONDS = [10, 30, 60, 300, 3600] + +function parseSelfDestructSeconds(raw) { + if (raw == null || raw === '' || raw === '0' || raw === 0) return null + const n = parseInt(String(raw), 10) + if (!Number.isFinite(n) || !ALLOWED_SELF_DESTRUCT_SECONDS.includes(n)) return null + return n +} + +function buildExpiresAt(seconds) { + if (!seconds) return undefined + return new Date(Date.now() + seconds * 1000) +} + +function activeMessageFilter(baseFilter) { + return { + $and: [ + baseFilter, + { + $or: [ + { expiresAt: { $exists: false } }, + { expiresAt: null }, + { expiresAt: { $gt: new Date() } } + ] + } + ] + } +} + +async function removeMessageFiles(messages) { + for (const msg of messages) { + if (!msg.file) continue + const relative = String(msg.file).replace(/^\//, '') + const filePath = path.join(__dirname, '../storage', relative) + if (fs.existsSync(filePath)) { + await fs.remove(filePath).catch(() => {}) + } + } +} + +async function deleteMessagesAndNotify(io, messages, options = {}) { + if (!messages?.length) return [] + + await removeMessageFiles(messages) + const deletedIds = messages.map((m) => String(m._id)) + await MessageModel.deleteMany({ _id: { $in: deletedIds } }) + + if (io) { + const byThread = new Map() + messages.forEach((m) => { + const s = String(m.senderId) + const r = String(m.receiverId) + const key = [s, r].sort().join(':') + if (!byThread.has(key)) byThread.set(key, { s, r, ids: [] }) + byThread.get(key).ids.push(String(m._id)) + }) + + byThread.forEach(({ s, r, ids }) => { + io.to(`chat:${s}:${r}`) + .to(`chat:${r}:${s}`) + .emit('messagesDeleted', { + messageIds: ids, + deletedBy: options.deletedBy, + reason: options.reason || (options.deletedBy ? 'manual' : 'expired') + }) + io.to(`user:${s}`).to(`user:${r}`).emit('chatListUpdate', { senderId: s, receiverId: r }) + }) + } + + return deletedIds +} + +async function purgeExpiredMessages(io) { + try { + const expired = await MessageModel.find({ expiresAt: { $lte: new Date() } }) + if (!expired.length) return [] + return deleteMessagesAndNotify(io, expired) + } catch (err) { + console.error('purgeExpiredMessages error:', err) + return [] + } +} + +const scheduledTimers = new Map() + +function scheduleMessageExpiry(io, message) { + if (!message?.expiresAt || !message._id) return + + const id = String(message._id) + if (scheduledTimers.has(id)) { + clearTimeout(scheduledTimers.get(id)) + } + + const ms = new Date(message.expiresAt).getTime() - Date.now() + if (ms <= 0) { + MessageModel.findById(id) + .then((doc) => doc && deleteMessagesAndNotify(io, [doc])) + .catch(() => {}) + return + } + + const timer = setTimeout(async () => { + scheduledTimers.delete(id) + try { + const doc = await MessageModel.findById(id) + if (doc && doc.expiresAt && new Date(doc.expiresAt) <= new Date()) { + await deleteMessagesAndNotify(io, [doc]) + } + } catch (err) { + console.error('scheduleMessageExpiry error:', err) + } + }, ms) + + scheduledTimers.set(id, timer) +} + +module.exports = { + ALLOWED_SELF_DESTRUCT_SECONDS, + parseSelfDestructSeconds, + buildExpiresAt, + activeMessageFilter, + purgeExpiredMessages, + scheduleMessageExpiry, + deleteMessagesAndNotify +} diff --git a/helpers/viewOnceMessages.js b/helpers/viewOnceMessages.js new file mode 100644 index 0000000..b94562b --- /dev/null +++ b/helpers/viewOnceMessages.js @@ -0,0 +1,104 @@ +const MessageModel = require('../models/MessageModel') +const { deleteMessagesAndNotify } = require('./expiredMessages') + +function parseViewOnce(raw) { + if (raw == null || raw === '' || raw === false || raw === 'false' || raw === 0) { + return false + } + return raw === true || raw === 'true' || raw === 1 || raw === '1' +} + +const VIEW_ONCE_MEDIA = new Set(['image', 'video', 'voice']) + +function isViewOnceMediaType(fileType) { + return VIEW_ONCE_MEDIA.has(fileType) +} + +/** Hide file URL for receiver until they open view-once media */ +function sanitizeMessageForViewer(message, viewerId) { + const doc = message._doc ? { ...message._doc } : { ...message } + const viewer = String(viewerId) + const sender = String(doc.senderId) + const receiver = String(doc.receiverId) + + if (!doc.viewOnce || !isViewOnceMediaType(doc.fileType)) { + return doc + } + + if (viewer === sender) { + return doc + } + + if (viewer === receiver) { + if (doc.viewOnceOpenedAt) { + return { + ...doc, + file: '', + viewOnceExpired: true + } + } + return { + ...doc, + file: '', + viewOnceLocked: true + } + } + + return doc +} + +async function openViewOnceMessage(messageId, userId) { + const msg = await MessageModel.findById(messageId) + if (!msg || !msg.viewOnce) { + return { error: 'پیام یافت نشد', status: 404 } + } + if (String(msg.receiverId) !== String(userId)) { + return { error: 'دسترسی مجاز نیست', status: 403 } + } + if (msg.viewOnceOpenedAt) { + return { error: 'این پیام قبلاً باز شده است', status: 410 } + } + + msg.viewOnceOpenedAt = new Date() + await msg.save() + + return { + message: msg.toObject(), + file: msg.file, + fileType: msg.fileType + } +} + +async function completeViewOnceMessage(io, messageId, userId) { + const msg = await MessageModel.findById(messageId) + if (!msg || !msg.viewOnce) { + return { error: 'پیام یافت نشد', status: 404 } + } + + const uid = String(userId) + const isReceiver = String(msg.receiverId) === uid + const isSender = String(msg.senderId) === uid + + if (!isReceiver && !isSender) { + return { error: 'دسترسی مجاز نیست', status: 403 } + } + + if (isReceiver && !msg.viewOnceOpenedAt) { + return { error: 'پیام هنوز باز نشده', status: 400 } + } + + await deleteMessagesAndNotify(io, [msg], { + deletedBy: userId, + reason: 'viewOnce' + }) + + return { deletedIds: [String(msg._id)] } +} + +module.exports = { + parseViewOnce, + isViewOnceMediaType, + sanitizeMessageForViewer, + openViewOnceMessage, + completeViewOnceMessage +} diff --git a/index.js b/index.js index c9096a9..587dbde 100644 --- a/index.js +++ b/index.js @@ -98,14 +98,22 @@ const io = require('socket.io')(http, { app.use(express.json({ limit: '1000mb' })); app.use(express.urlencoded({ limit: '1000mb', extended: true })); -app.use('/storage', express.static(path.join(__dirname, 'storage'))); +// مسیرهای مشخص قبل از /storage عمومی — پروفایل از هر دو محل (قدیم و جدید) سرو می‌شود app.use('/storage/profiles', express.static(path.join(__dirname, '../storage/profiles'))); +app.use('/storage/profiles', express.static(path.join(__dirname, 'storage/profiles'))); app.use('/storage/carts', express.static(path.join(__dirname, '../storage/carts'))); +app.use('/storage/carts', express.static(path.join(__dirname, 'storage/carts'))); +app.use('/storage/posts', express.static(path.join(__dirname, 'storage/posts'))); app.use('/storage/posts', express.static(path.join(__dirname, '../storage/posts'))); app.use('/storage/messages', express.static(path.join(__dirname, '../storage/messages'))); +app.use('/storage/messages', express.static(path.join(__dirname, 'storage/messages'))); app.use('/storage/tickets', express.static(path.join(__dirname, '../storage/tickets'))); +app.use('/storage/tickets', express.static(path.join(__dirname, 'storage/tickets'))); app.use('/storage/advertising', express.static(path.join(__dirname, '../storage/advertising'))); +app.use('/storage/advertising', express.static(path.join(__dirname, 'storage/advertising'))); app.use('/storage/services', express.static(path.join(__dirname, '../storage/services'))); +app.use('/storage/services', express.static(path.join(__dirname, 'storage/services'))); +app.use('/storage', express.static(path.join(__dirname, 'storage'))); require('./boot'); @@ -146,19 +154,50 @@ app.set('trust proxy', 'loopback'); require('./routes')(app); const { enrichMessage, emitChatEvents, parseForwardedContent } = require('./helpers/chatHelpers') +const { + parseSelfDestructSeconds, + buildExpiresAt, + activeMessageFilter, + purgeExpiredMessages, + scheduleMessageExpiry, + deleteMessagesAndNotify +} = require('./helpers/expiredMessages') +const { + parseViewOnce, + isViewOnceMediaType, + sanitizeMessageForViewer, + openViewOnceMessage, + completeViewOnceMessage +} = require('./helpers/viewOnceMessages') + +const mongoose = require('mongoose') app.get('/api/v1/chat', async (req, res) => { try { const { senderId, receiverId, limit, page } = req.query + + if (!senderId || !receiverId) { + return res.status(400).json({ error: 'senderId and receiverId are required' }) + } + + if ( + !mongoose.Types.ObjectId.isValid(String(senderId)) || + !mongoose.Types.ObjectId.isValid(String(receiverId)) + ) { + return res.status(400).json({ error: 'Invalid senderId or receiverId' }) + } + + const sid = new mongoose.Types.ObjectId(String(senderId)) + const rid = new mongoose.Types.ObjectId(String(receiverId)) const pageNumber = parseInt(page) || 1 const limitPerPage = parseInt(limit) || 40 - const filter = { + const filter = activeMessageFilter({ $or: [ - { senderId, receiverId }, - { senderId: receiverId, receiverId: senderId } + { senderId: sid, receiverId: rid }, + { senderId: rid, receiverId: sid } ] - } + }) const totalMessagesCount = await MessageModel.countDocuments(filter) const totalPages = Math.ceil(totalMessagesCount / limitPerPage) || 1 @@ -170,11 +209,15 @@ app.get('/api/v1/chat', async (req, res) => { .limit(limitPerPage) await MessageModel.updateMany( - { _id: { $in: messages.map((m) => m._id) }, receiverId: senderId }, + { _id: { $in: messages.map((m) => m._id) }, receiverId: sid }, { $set: { readStatus: 1 } } ) - const enriched = await Promise.all(messages.map((m) => enrichMessage(m))) + const enriched = await Promise.all( + messages.map((m) => + enrichMessage(sanitizeMessageForViewer(m, sid)) + ) + ) res.json({ messages: enriched.reverse(), totalPages }) } catch (error) { console.error('Error fetching messages:', error) @@ -184,15 +227,17 @@ app.get('/api/v1/chat', async (req, res) => { app.post('/api/v1/chat', [blockCheck], async (req, res) => { try { - const { senderId, receiverId, content, replyToId, forwardedFrom: fwdBody } = req.body + const { senderId, receiverId, content, replyToId, forwardedFrom: fwdBody, selfDestructSeconds } = req.body const { body, forwardedFrom: fwdParsed } = parseForwardedContent(content) + const destructSec = parseSelfDestructSeconds(selfDestructSeconds) const payload = { senderId, receiverId, content: body || content, replyToId: replyToId || undefined, - forwardedFrom: fwdBody || fwdParsed || undefined + forwardedFrom: fwdBody || fwdParsed || undefined, + expiresAt: buildExpiresAt(destructSec) } const newMessage = new MessageModel(payload) @@ -201,6 +246,7 @@ app.post('/api/v1/chat', [blockCheck], async (req, res) => { res.status(201).json({ data: formatted }) emitChatEvents(io, formatted, senderId, receiverId) + scheduleMessageExpiry(io, newMessage) } catch (error) { console.error('Error sending message:', error) res.status(500).json({ error: 'Server error' }) @@ -263,7 +309,14 @@ app.post("/api/v1/notification/send-sms", async (req, res) => { app.post('/api/v1/chat/file', [blockCheck], async (req, res) => { try { - const { senderId, receiverId, content, replyToId, fileType } = req.body + const { senderId, receiverId, content, replyToId, fileType, selfDestructSeconds, viewOnce } = req.body + const destructSec = parseSelfDestructSeconds(selfDestructSeconds) + const viewOnceFlag = parseViewOnce(viewOnce) + const resolvedFileType = fileType || undefined + + if (viewOnceFlag && !isViewOnceMediaType(resolvedFileType)) { + return res.status(422).json({ error: 'View once is only for image, video, and voice' }) + } const { file } = req.files let fileUrl = null if (file) { @@ -282,19 +335,94 @@ app.post('/api/v1/chat/file', [blockCheck], async (req, res) => { receiverId, content: content || '', file: fileUrl, - fileType: fileType || undefined, - replyToId: replyToId || undefined + fileType: resolvedFileType, + replyToId: replyToId || undefined, + expiresAt: buildExpiresAt(destructSec), + viewOnce: viewOnceFlag }) await newMessage.save() const formatted = await enrichMessage(newMessage) res.status(201).json({ data: formatted }) emitChatEvents(io, formatted, senderId, receiverId) + scheduleMessageExpiry(io, newMessage) } catch (error) { console.error('Error sending message:', error) res.status(500).json({ error: 'Server error' }) } }) + +app.post('/api/v1/chat/view-once/open', [blockCheck], async (req, res) => { + try { + const userId = String(req.user._id) + const { messageId } = req.body + if (!messageId) { + return res.status(422).json({ error: 'messageId is required' }) + } + + const result = await openViewOnceMessage(messageId, userId) + if (result.error) { + return res.status(result.status || 400).json({ error: result.error }) + } + + res.json({ + file: result.file, + fileType: result.fileType, + messageId: String(result.message._id) + }) + } catch (error) { + console.error('view-once open error:', error) + res.status(500).json({ error: 'Server error' }) + } +}) + +app.post('/api/v1/chat/view-once/complete', [blockCheck], async (req, res) => { + try { + const userId = String(req.user._id) + const { messageId } = req.body + if (!messageId) { + return res.status(422).json({ error: 'messageId is required' }) + } + + const result = await completeViewOnceMessage(io, messageId, userId) + if (result.error) { + return res.status(result.status || 400).json({ error: result.error }) + } + + res.json({ deletedIds: result.deletedIds }) + } catch (error) { + console.error('view-once complete error:', error) + res.status(500).json({ error: 'Server error' }) + } +}) + +app.delete('/api/v1/chat', [blockCheck], async (req, res) => { + try { + const userId = String(req.user._id) + const { messageIds } = req.body + + if (!Array.isArray(messageIds) || messageIds.length === 0) { + return res.status(422).json({ message: 'پیامی انتخاب نشده است' }) + } + + const messages = await MessageModel.find({ + _id: { $in: messageIds }, + senderId: userId + }) + + if (!messages.length) { + return res.status(403).json({ message: 'فقط پیام‌های خودتان قابل حذف هستند' }) + } + + const deletedIds = await deleteMessagesAndNotify(io, messages, { deletedBy: userId }) + + res.json({ deletedIds, message: 'پیام‌ها حذف شدند' }) + } catch (error) { + console.error('Error deleting messages:', error) + res.status(500).json({ error: 'Server error' }) + } +}) + // Errors require('./middlewares/exception')(app) require('./middlewares/404')(app) @@ -336,6 +464,11 @@ io.on('connection', (socket) => { // Free Project require('./services/AdvertisingCron') require('./services/ProjectCron') + +cron.schedule('*/30 * * * * *', () => { + purgeExpiredMessages(io).catch((err) => console.error('expired messages cron:', err)) +}) + cron.schedule('0 0 1 * *', async () => { try { await UserModel.updateMany({}, { diff --git a/middlewares/blockCheck.js b/middlewares/blockCheck.js index fb46d6f..357a056 100644 --- a/middlewares/blockCheck.js +++ b/middlewares/blockCheck.js @@ -1,8 +1,11 @@ -const UserModel = require('../models/UserModel') // مسیر درست به مدل کاربر +const UserModel = require('../models/UserModel') const jwt = require('jsonwebtoken') +const { + expireBlockSuspensionIfNeeded, + getBlockedAccountResponse +} = require('../utils/blockSuspension') module.exports = async (req, res, next) => { - console.log(2); try { if (!('authorization' in req.headers)) { return res.status(401).send({ @@ -24,17 +27,15 @@ module.exports = async (req, res, next) => { }) } - const user = await UserModel.findById(decodedToken.id) - if (user.block_status === true) { - return res.status(403).send({ - status: 'error', - code: 403, - message: 'حساب کاربری شما مسدود شده است، برای اطلاعات بیشتر با پشتیبانی تماس بگیرید!', - type: 'block' - }) + let user = await UserModel.findById(decodedToken.id) + user = await expireBlockSuspensionIfNeeded(user) + + const blockResponse = getBlockedAccountResponse(user) + if (blockResponse) { + return res.status(403).send(blockResponse) } - req.user = user // کاربر را به درخواست اضافه کنید تا در سایر middleware ها و کنترلرها استفاده شود. + req.user = user next() } catch (error) { console.error('Error in blockCheck middleware:', error) diff --git a/models/CommentModel.js b/models/CommentModel.js index 07c4103..30f2cc8 100644 --- a/models/CommentModel.js +++ b/models/CommentModel.js @@ -25,7 +25,8 @@ const commentSchema = new mongoose.Schema({ }, rating: { type: Number, - required: true + required: false, + default: null }, comment: { type: String, diff --git a/models/MessageModel.js b/models/MessageModel.js index d694642..5d344e1 100644 --- a/models/MessageModel.js +++ b/models/MessageModel.js @@ -43,6 +43,21 @@ const messageSchema = new Schema({ readStatus: { type: Number, default: 0 + }, + /** Auto-delete after this time (self-destruct messages) */ + expiresAt: { + type: Date, + required: false, + index: true + }, + /** View-once photo / video / voice */ + viewOnce: { + type: Boolean, + default: false + }, + viewOnceOpenedAt: { + type: Date, + required: false } }) diff --git a/models/UserModel.js b/models/UserModel.js index ddf6b87..baeffdc 100644 --- a/models/UserModel.js +++ b/models/UserModel.js @@ -16,6 +16,11 @@ const userSchema = new mongoose.Schema({ maxLength: 6, default: null }, + otpSentAt: { + type: Date, + required: false, + default: null + }, user_name: { type: String, required: false, @@ -312,6 +317,11 @@ const userSchema = new mongoose.Schema({ required: false, default: null }, + blocked_until: { + type: Date, + required: false, + default: null + }, advertising_profiles: [{ type: mongoose.Schema.Types.ObjectId, ref: 'AdvertisingProfile' diff --git a/routes/application/verify/index.js b/routes/application/verify/index.js index 0bec659..1b9fc5d 100644 --- a/routes/application/verify/index.js +++ b/routes/application/verify/index.js @@ -9,6 +9,7 @@ const { setConversation, updateConversation } = require('../../../controllers/ap const { saveAuth, saveAuthValidationRules, updateShaba } = require('../../../controllers/application/verify/authController') const { setAddress, updateAddress } = require('../../../controllers/application/verify/addressController') const { setNationalCardImage } = require('../../../controllers/application/verify/setNationalCardImageController') +const { completeRegistration } = require('../../../controllers/application/verify/completeRegistrationController') const blockCheck = require('../../../middlewares/blockCheck') const auth = require('../../../middlewares/auth') const { setServices, updateServices } = require('../../../controllers/application/verify/servicesController') @@ -34,5 +35,6 @@ router.patch('/shaba', [blockCheck], [auth], updateShaba) router.post('/address', setAddress) router.patch('/address', [blockCheck], [auth], updateAddress) router.post('/national_card_image', setNationalCardImage) +router.post('/complete', completeRegistration) module.exports = router diff --git a/utils/blockSuspension.js b/utils/blockSuspension.js new file mode 100644 index 0000000..0d503e3 --- /dev/null +++ b/utils/blockSuspension.js @@ -0,0 +1,77 @@ +const moment = require('moment-jalaali') + +const BLOCK_THRESHOLD = 5 +const BLOCK_DURATION_MS = 7 * 24 * 60 * 60 * 1000 + +const applyAutoBlockSuspension = (user) => { + if (user.blocked_by.length >= BLOCK_THRESHOLD) { + user.block_status = true + user.blocked_until = new Date(Date.now() + BLOCK_DURATION_MS) + } +} + +const clearAutoBlockSuspension = (user) => { + if (user.blocked_by.length < BLOCK_THRESHOLD && user.blocked_until) { + user.block_status = null + user.blocked_until = null + } +} + +const isUserCurrentlyBlocked = (user) => { + if (!user.block_status) { + return false + } + + if (user.blocked_until) { + if (new Date() >= new Date(user.blocked_until)) { + return false + } + return true + } + + return user.block_status === true +} + +const expireBlockSuspensionIfNeeded = async (user) => { + if (!user.blocked_until) { + return user + } + + if (new Date() >= new Date(user.blocked_until)) { + user.block_status = null + user.blocked_until = null + await user.save() + } + + return user +} + +const getBlockedAccountResponse = (user) => { + if (!isUserCurrentlyBlocked(user)) { + return null + } + + const untilText = user.blocked_until + ? moment(user.blocked_until).format('jYYYY/jMM/jDD') + : null + + return { + status: 'error', + code: 403, + message: untilText + ? `حساب کاربری شما به دلیل بلاک شدن توسط ${BLOCK_THRESHOLD} کاربر تا تاریخ ${untilText} غیرفعال است.` + : 'حساب کاربری شما مسدود شده است، برای اطلاعات بیشتر با پشتیبانی تماس بگیرید!', + type: 'block', + blocked_until: user.blocked_until || null + } +} + +module.exports = { + BLOCK_THRESHOLD, + BLOCK_DURATION_MS, + applyAutoBlockSuspension, + clearAutoBlockSuspension, + isUserCurrentlyBlocked, + expireBlockSuspensionIfNeeded, + getBlockedAccountResponse +} diff --git a/utils/blockVisibility.js b/utils/blockVisibility.js new file mode 100644 index 0000000..6642052 --- /dev/null +++ b/utils/blockVisibility.js @@ -0,0 +1,49 @@ +function listIncludesUserId(list, userId) { + if (!userId || !list?.length) return false + return list.some((id) => String(id) === String(userId)) +} + +/** Viewer has blocked the profile owner */ +function viewerBlockedUser(user, viewerId) { + if (!user || !viewerId) return false + return listIncludesUserId(user.blocked_by, viewerId) +} + +/** Profile owner has blocked the viewer */ +function userBlockedViewer(user, viewerId) { + if (!user || !viewerId) return false + return listIncludesUserId(user.blocked_users, viewerId) +} + +/** Viewer (blocked person) cannot see profile/posts of user who blocked them */ +function viewerIsBlockedBy(user, viewerId) { + return userBlockedViewer(user, viewerId) +} + +function blockedProfilePayload(user) { + return { + _id: user._id, + user_name: user.user_name, + blocked_you: true, + is_self: false, + user_type: user.user_type, + postsCount: 0, + allProjectsCount: 0, + successfulProjectsCount: 0, + } +} + +/** User IDs who have blocked the viewer */ +async function getBlockerUserIds(UserModel, viewerId) { + if (!viewerId) return [] + return UserModel.find({ blocked_users: viewerId }).distinct('_id') +} + +module.exports = { + listIncludesUserId, + viewerBlockedUser, + userBlockedViewer, + viewerIsBlockedBy, + blockedProfilePayload, + getBlockerUserIds, +} diff --git a/utils/commentNotification.js b/utils/commentNotification.js new file mode 100644 index 0000000..fe1bf52 --- /dev/null +++ b/utils/commentNotification.js @@ -0,0 +1,57 @@ +const NotificationModel = require('../models/NotificationModel') +const { resolveCourseOwnerId } = require('./likeNotification') + +const COMMENT_TITLE = 'یک کامنت برای شما ثبت شد' +const COMMENT_DESCRIPTION = 'یک کامنت برای شما ثبت شد' +const RATING_TITLE = 'یک امتیاز برای شما ثبت شد' +const RATING_DESCRIPTION = 'یک امتیاز برای شما ثبت شد' + +const createCommentNotification = async ({ + ownerId, + commenterId, + entityId, + type = 'post_comment' +}) => { + try { + if (!ownerId || !commenterId || !entityId) return + if (String(ownerId) === String(commenterId)) return + + await NotificationModel.create({ + user_id: ownerId, + project_post_id: entityId, + type, + title: COMMENT_TITLE, + description: COMMENT_DESCRIPTION + }) + } catch (error) { + console.error('Error creating comment notification:', error) + } +} + +const createRatingNotification = async ({ + ownerId, + raterId, + entityId, + type = 'billboard_rating' +}) => { + try { + if (!ownerId || !raterId || !entityId) return + if (String(ownerId) === String(raterId)) return + + await NotificationModel.create({ + user_id: ownerId, + project_post_id: entityId, + type, + title: RATING_TITLE, + description: RATING_DESCRIPTION + }) + } catch (error) { + console.error('Error creating rating notification:', error) + } +} + +module.exports = { + createCommentNotification, + createRatingNotification, + resolveCourseOwnerId +} diff --git a/utils/commentRating.js b/utils/commentRating.js new file mode 100644 index 0000000..2c917ea --- /dev/null +++ b/utils/commentRating.js @@ -0,0 +1,84 @@ +const CommentModel = require('../models/CommentModel') +const UserModel = require('../models/UserModel') + +const RATED_FILTER = { $ne: null, $gt: 0 } + +const findExistingUserRating = (creatorId, targetUserId) => { + return CommentModel.findOne({ + creator: creatorId, + user: targetUserId, + rating: RATED_FILTER + }) +} + +const hasCreatorRatedUser = async (creatorId, targetUserId) => { + const existing = await findExistingUserRating(creatorId, targetUserId) + return !!existing +} + +const resolveRatingForComment = async ({ creatorId, targetUserId, rate }) => { + const existingRating = await findExistingUserRating(creatorId, targetUserId) + + if (existingRating) { + return { ratingValue: null, isNewRating: false } + } + + if (!rate || rate < 1 || rate > 5) { + return { ratingValue: null, isNewRating: false, requiresRating: true } + } + + return { ratingValue: rate, isNewRating: true } +} + +const getUserLevel = (user, totalRating) => { + if (user.expertise === 'مدل') { + if (totalRating <= 50) return 'تازه وارد' + if (totalRating <= 100) return 'استاندارد' + if (totalRating <= 300) return 'حرفه‌ای' + return 'استاد' + } + + if (['زیبایی', 'عکاس'].includes(user.expertise)) { + if (totalRating <= 50) return 'تازه وارد' + if (totalRating <= 100) return 'استاندارد' + if (totalRating <= 300) return 'حرفه‌ای' + return 'استاد' + } + + return user.user_level +} + +const recalculateUserRating = async (targetUserId) => { + const user = await UserModel.findById(targetUserId) + if (!user) return + + const ratedComments = await CommentModel.find({ + user: targetUserId, + rating: RATED_FILTER + }).sort({ createdAt: 1 }) + + const uniqueRatingsByCreator = new Map() + for (const comment of ratedComments) { + const creatorKey = comment.creator.toString() + if (!uniqueRatingsByCreator.has(creatorKey)) { + uniqueRatingsByCreator.set(creatorKey, comment.rating) + } + } + + const ratings = Array.from(uniqueRatingsByCreator.values()) + const totalRating = ratings.reduce((acc, rating) => acc + rating, 0) + const ratedCount = ratings.length + + user.user_score = totalRating + user.user_level = getUserLevel(user, totalRating) + user.rate = ratedCount > 0 ? (totalRating / ratedCount).toFixed(1) : '0' + await user.save() +} + +module.exports = { + RATED_FILTER, + findExistingUserRating, + hasCreatorRatedUser, + resolveRatingForComment, + recalculateUserRating +} diff --git a/utils/likeNotification.js b/utils/likeNotification.js new file mode 100644 index 0000000..87ad0a6 --- /dev/null +++ b/utils/likeNotification.js @@ -0,0 +1,47 @@ +const NotificationModel = require('../models/NotificationModel') +const AcademyModel = require('../models/AcademyModel') + +const LIKE_TITLE = 'پست شما لایک شد' +const LIKE_DESCRIPTION = 'پست شما لایک شد' + +const createLikeNotification = async ({ + ownerId, + likerId, + entityId, + type = 'post_like' +}) => { + try { + if (!ownerId || !likerId || !entityId) return + if (String(ownerId) === String(likerId)) return + + await NotificationModel.create({ + user_id: ownerId, + project_post_id: entityId, + type, + title: LIKE_TITLE, + description: LIKE_DESCRIPTION + }) + } catch (error) { + console.error('Error creating like notification:', error) + } +} + +const resolveCourseOwnerId = async (course) => { + if (course?.user_id) { + return course.user_id + } + + if (course?.academyId) { + const academy = await AcademyModel.findById(course.academyId) + if (academy?.userId) { + return academy.userId + } + } + + return null +} + +module.exports = { + createLikeNotification, + resolveCourseOwnerId +} diff --git a/utils/otpExpiry.js b/utils/otpExpiry.js index ff4e30a..f30e7fe 100644 --- a/utils/otpExpiry.js +++ b/utils/otpExpiry.js @@ -1,4 +1,4 @@ -const OTP_VALID_MS = 5 * 60 * 1000 +const OTP_VALID_MS = 3 * 60 * 1000 const isOtpExpired = (user) => { if (!user?.otp) return true diff --git a/utils/userLevelFilter.js b/utils/userLevelFilter.js new file mode 100644 index 0000000..1a95363 --- /dev/null +++ b/utils/userLevelFilter.js @@ -0,0 +1,48 @@ +const USER_LEVELS = { + NEW: 'تازه وارد', + STANDARD: 'استاندارد', + PRO: 'حرفه‌ای', + MASTER: 'استاد', +} + +/** Normalize level strings from URL/query (handles ی / ZWNJ variants). */ +function normalizeUserLevel(raw) { + if (raw == null || raw === '') return null + + const text = String(raw) + .trim() + .replace(/\u200c/g, '') + .replace(/\s+/g, ' ') + + if (text === USER_LEVELS.NEW) return USER_LEVELS.NEW + if (text === USER_LEVELS.STANDARD) return USER_LEVELS.STANDARD + if (text === USER_LEVELS.MASTER) return USER_LEVELS.MASTER + if (text === USER_LEVELS.PRO || text === 'حرفه ای') return USER_LEVELS.PRO + + return text +} + +/** Build Mongo filter for user_level (includes unset levels for تازه وارد). */ +function buildUserLevelMongoFilter(rawLevel) { + const level = normalizeUserLevel(rawLevel) + if (!level) return null + + if (level === USER_LEVELS.NEW) { + return { + $or: [ + { user_level: USER_LEVELS.NEW }, + { user_level: null }, + { user_level: '' }, + { user_level: { $exists: false } }, + ], + } + } + + return { user_level: level } +} + +module.exports = { + USER_LEVELS, + normalizeUserLevel, + buildUserLevelMongoFilter, +} diff --git a/utils/usernameSuggestions.js b/utils/usernameSuggestions.js new file mode 100644 index 0000000..51e26a7 --- /dev/null +++ b/utils/usernameSuggestions.js @@ -0,0 +1,56 @@ +const { validateUsername, USERNAME_MAX_LENGTH } = require('./usernameValidation') + +const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const isUsernameTaken = async (UserModel, userName, excludeUserId) => { + const existing = await UserModel.findOne({ + user_name: { $regex: new RegExp(`^${escapeRegex(userName)}$`, 'i') } + }) + + if (!existing) return false + if (excludeUserId && existing._id.toString() === excludeUserId) return false + return true +} + +const buildCandidates = (base, attempt) => { + const random2 = () => String(Math.floor(Math.random() * 90 + 10)) + const random3 = () => String(Math.floor(Math.random() * 900 + 100)) + const random4 = () => String(Math.floor(Math.random() * 9000 + 1000)) + + const builders = [ + () => `${base}_${random2()}`, + () => `${base}.${random2()}`, + () => `${base}${random3()}`, + () => `${base}-${random2()}`, + () => `${base}_${random3()}`, + () => `${base}${random4()}`, + ] + + return builders[attempt % builders.length]() +} + +const trimToMax = (value) => + value.length > USERNAME_MAX_LENGTH ? value.slice(0, USERNAME_MAX_LENGTH) : value + +const generateUsernameSuggestions = async (UserModel, baseUserName, excludeUserId) => { + const base = trimToMax(baseUserName.trim().toLowerCase()) + const suggestions = [] + let attempt = 0 + + while (suggestions.length < 3 && attempt < 30) { + const candidate = trimToMax(buildCandidates(base, attempt)) + attempt += 1 + + if (validateUsername(candidate)) continue + if (suggestions.includes(candidate)) continue + if (await isUsernameTaken(UserModel, candidate, excludeUserId)) continue + + suggestions.push(candidate) + } + + return suggestions +} + +module.exports = { + generateUsernameSuggestions, +} diff --git a/utils/usernameValidation.js b/utils/usernameValidation.js new file mode 100644 index 0000000..be99b38 --- /dev/null +++ b/utils/usernameValidation.js @@ -0,0 +1,33 @@ +/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */ +const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,99}$/ + +const USERNAME_MIN_LENGTH = 6 +const USERNAME_MAX_LENGTH = 100 + +const validateUsername = (user_name) => { + if (!user_name || typeof user_name !== 'string') { + return 'نام کاربری الزامی است' + } + + const trimmed = user_name.trim() + if (trimmed.length < USERNAME_MIN_LENGTH) { + return `نام کاربری باید حداقل ${USERNAME_MIN_LENGTH} کاراکتر باشد` + } + + if (trimmed.length > USERNAME_MAX_LENGTH) { + return `نام کاربری نمی‌تواند بیشتر از ${USERNAME_MAX_LENGTH} کاراکتر باشد` + } + + if (!USERNAME_REGEX.test(trimmed)) { + return 'نام کاربری فقط می‌تواند شامل حروف انگلیسی، اعداد و کاراکترهای . _ - باشد' + } + + return null +} + +module.exports = { + USERNAME_REGEX, + USERNAME_MIN_LENGTH, + USERNAME_MAX_LENGTH, + validateUsername +}