diff --git a/controllers/application/academy/academyControllers.js b/controllers/application/academy/academyControllers.js index 7520507..7493548 100644 --- a/controllers/application/academy/academyControllers.js +++ b/controllers/application/academy/academyControllers.js @@ -1,5132 +1,5131 @@ -const jwt = require('jsonwebtoken') -const fs = require('fs-extra') -const path = require('path') -const jMoment = require('moment-jalaali') -const AcademyContentModel = require('../../../models/AcademyContentModel') -const AcademyModel = require('../../../models/AcademyModel') -const AcademyPaymentModel = require('../../../models/AcademyPaymentModel') -const CourseModel = require('../../../models/CourseModel') -const CoursePaymentModel = require('../../../models/CoursePaymentModel') -const UserModel = require('../../../models/UserModel') -const PaymentModel = require('../../../models/PaymentModel') -const { default: axios } = require('axios') -const AcademyLikeModel = require('../../../models/AcademyLikeModel') -const CourseComment = require('../../../models/CourseComentModel') -const IsPaymentCourse = require('../../../models/IsPaymentCourseModel') -const Tax = require('../../../models/TaxModel') -const AcademyCategoryModel = require('../../../models/AcademyCategoryModel') -const { buildUnifiedInterestScore, blendFreshIntoRanked: blendInterestIntoRanked } = require('../../../utils/exploreAlgorithm') -const { resolveCommentModerationStatus } = require('../../../utils/contentModeration') -const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave') -const { resolveStoryFlags } = require('../../../utils/storyRingFlags') -const { isVerifiedBadge } = require('../../../utils/verifyBadge') -const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') -const { - coursePublicStatusFilter, - academyContentPublicStatusFilter, - commentStatusFilter, - PUBLIC_COMMENT_STATUSES -} = require('../../../utils/moderationStatus') -const { - createLikeNotification, - resolveCourseOwnerId -} = require('../../../utils/likeNotification') -const { - createCommentNotification, - createRatingNotification -} = require('../../../utils/commentNotification') -const { - createCoursePurchaseNotification -} = require('../../../utils/academyNotification') -const AdvertisingProfileModel = require('../../../models/AdvertisingProfile') -const WalletTransactionModel = require('../../../models/WalletTransactionModel') -const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') - -const ACADEMY_COMMISSION_RATE = 0.15 - -const onServerRestart = async () => { - const CoursePayment = await CoursePaymentModel.findOne({ - cuorse_type: 'pro' - }) - if (!CoursePayment) { - const newCoursePayment = new CoursePaymentModel({ - price: '60000', - cuorse_type: 'pro' - }) - await newCoursePayment.save() - - return console.log('اشتراک پرو ساخته شد') - } - - console.log('اشتراک پرو موجود هست') - const tax = await Tax.findOne({ - type: 'course' - }) - if (!tax) { - const newTax = new Tax({ - tax: 8, - type: 'course' - }) - await newTax.save() - - return console.log('تکس ساخته شد') - } - - console.log(' تکس موجود هست') -} -onServerRestart() - -const findAcademy = 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 userId = decodedToken.id - - const user = await UserModel.findById(userId) - if (!user) { - return res.status(422).json({ - error: true, - message: 'کاربر یافت نشد' - }) - } - const academy = await AcademyModel.findOne({ userId }) - if (!academy) { - const newAcademy = new AcademyModel({ - userId - }) - await newAcademy.save() - - return res.status(200).json({ - academy: newAcademy - }) - } - - const advertisingProfile = await AdvertisingProfileModel.findOne({ - user: userId - }).select('contactInfo') - - res.status(200).json({ - academy: { - ...academy.toObject(), - contactInfo: advertisingProfile?.contactInfo || null - } - }) - } catch (error) { - // در صورت بروز خطا، ارسال پیام خطا به کاربر - console.error('Error:', error) - res.status(500).json({ message: 'err in creat' }) - } -} -const findAcademyById = async (req, res, next) => { - try { - const { _id } = req.body - - const academy = await AcademyModel.findById(_id) - if (!academy) { - return res.status(404).json({ - success: false, - message: 'اکادمی موجود نیست' - }) - } - - const owner = await UserModel.findById(academy.userId).select( - 'user_name first_name last_name is_verified verify_badge is_Register profile_image user_score rate' - ) - - const packagesCount = await CourseModel.countDocuments({ - academyId: academy._id, - status: coursePublicStatusFilter() - }) - - const academyCourses = await CourseModel.find({ - academyId: academy._id, - status: coursePublicStatusFilter() - }).select('_id number_of_course_content') - - const courseIds = academyCourses.map((course) => course._id) - const soldCount = courseIds.length - ? await IsPaymentCourse.countDocuments({ course_id: { $in: courseIds } }) - : 0 - - const totalVideos = academyCourses.reduce( - (sum, course) => sum + (parseInt(course.number_of_course_content, 10) || 0), - 0 - ) - - const advertisingProfile = await AdvertisingProfileModel.findOne({ - user: academy.userId - }).select('contactInfo') - - res.status(200).json({ - academy: { - ...academy.toObject(), - stats: { - packagesCount, - soldCount, - totalVideos - }, - owner, - contactInfo: advertisingProfile?.contactInfo || null - } - }) - } catch (error) { - console.error('Error:', error) - res.status(500).json({ message: 'err in creat' }) - } -} -const academyProfile = 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 userId = decodedToken.id - - const user = await UserModel.findById(userId) - if (!user) { - return res.status(422).json({ - error: true, - message: 'کاربر یافت نشد' - }) - } - const academy = await AcademyModel.findOne({ userId }) - if (!academy) { - return res.status(200).json({ - message: 'اکادمی پیدا نشد' - }) - } - - const { profile_image } = req.files - const { name, sheba, bio, tag } = req.body - - if (!profile_image) { - return res.status(422).json({ - error: true, - message: 'اطلاعات ارسالی اشتباه است' - }) - } - - let shebaDigits = String(sheba || '') - .replace(/\s/g, '') - .toUpperCase() - if (shebaDigits.startsWith('IR')) { - shebaDigits = shebaDigits.slice(2) - } - shebaDigits = shebaDigits.replace(/\D/g, '') - if (!/^\d{24}$/.test(shebaDigits)) { - return res.status(422).json({ - error: true, - message: 'شماره شبا باید ۲۴ رقم باشد' - }) - } - - const uploadDir = path.join(__dirname, '../../../../storage/profiles') - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) - } - - const uniqueFileName = `${name}-${Date.now()}${path.extname( - profile_image.name - )}` - const filePath = path.join(uploadDir, uniqueFileName) - - await fs.move(profile_image.path, filePath) - - academy.academy_image = `/profiles/${uniqueFileName}` - academy.academy_name = name - academy.sheba = `IR${shebaDigits}` - academy.bio = maskPhoneNumbers(bio) - academy.tag = tag - await academy.save() - - const contactPayloadPresent = - req.body.contact_mobile !== undefined || - req.body.contact_telegram !== undefined || - req.body.contact_whatsapp !== undefined || - req.body.contact_instagram !== undefined - - if (contactPayloadPresent) { - const contactInfo = - parseCourseContactInfo(req.body) ?? { - mobile: null, - telegramLink: null, - whatsappNumber: null, - instagramLink: null - } - let advertisingProfile = await AdvertisingProfileModel.findOne({ - user: userId - }) - if (!advertisingProfile) { - advertisingProfile = new AdvertisingProfileModel({ - user: userId, - contactInfo - }) - await advertisingProfile.save() - } else { - advertisingProfile.contactInfo = contactInfo - await advertisingProfile.save() - } - } - - res.status(200).json({ - message: 'ثبت شد' - }) - } catch (error) { - // در صورت بروز خطا، ارسال پیام خطا به کاربر - console.error('Error:', error) - res.status(500).json({ message: 'err in update' }) - } -} - -const parseCourseContactInfo = (body) => { - const mobile = body.contact_mobile || body['contactInfo[mobile]'] || '' - const telegramLink = - body.contact_telegram || body['contactInfo[telegramLink]'] || '' - const whatsappNumber = - body.contact_whatsapp || body['contactInfo[whatsappNumber]'] || '' - const instagramLink = - body.contact_instagram || body['contactInfo[instagramLink]'] || '' - - const hasAny = [mobile, telegramLink, whatsappNumber, instagramLink].some( - Boolean - ) - if (!hasAny) return undefined - - return { - mobile: mobile || null, - telegramLink: telegramLink || null, - whatsappNumber: whatsappNumber || null, - instagramLink: instagramLink || null - } -} - -const creatCourse = async (req, res, next) => { - try { - // با blockCheck ست میشه (req.user) — برای کاربرِ زیرمجموعه، همون اکانتِ اصلیه - // (نه خودِ زیرمجموعه)، پس آکادمی درست پیدا میشه - const user = req.user - const userId = user._id - - const academy = await AcademyModel.findOne({ userId }) - if (!academy) { - return res.status(200).json({ - message: 'اکادمی پیدا نشد' - }) - } - const { - price, - cuorse_name, - category, - offer, - caption, - course_time, - number_of_course_content, - teacher_name, - is_free - } = req.body - - const isFreePackage = is_free === 'true' || is_free === true - const contactInfo = parseCourseContactInfo(req.body) - - const { course_image } = req.files - - if (!course_image) { - return res.status(422).json({ - error: true, - message: 'اطلاعات ارسالی اشتباه است' - }) - } - - const uploadDir = path.join(__dirname, '../../../../storage/profiles') - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) - } - - const uniqueFileName = `${cuorse_name}-${Date.now()}${path.extname( - course_image.name - )}` - const filePath = path.join(uploadDir, uniqueFileName) - - await fs.move(course_image.path, filePath) - - // ✅ ایجاد رکورد جدید - const newAcademy = new CourseModel({ - course_image: `/profiles/${uniqueFileName}`, - price: isFreePackage ? '0' : price, - cuorse_name, - category, - offer: offer || '0', - teacher_name, - academyId: academy._id, - caption: maskPhoneNumbers(caption), - course_time, - number_of_course_content, - is_free: isFreePackage, - ...(contactInfo ? { contactInfo } : {}), - status: isVerifiedBadge(user.verify_badge) ? 'temp_accept' : 'pending' - }) - - await newAcademy.save() - - res.status(200).json({ - message: 'ثبت شد' - }) - } catch (error) { - // در صورت بروز خطا، ارسال پیام خطا به کاربر - console.error('Error:', error) - res.status(500).json({ message: 'err in update' }) - } -} - -const updateCourse = async (req, res, next) => { - try { - // با blockCheck ست میشه (req.user) — برای کاربرِ زیرمجموعه، همون اکانتِ اصلیه - const user = req.user - const userId = user._id - - const { - price, - cuorse_name, - category, - offer, - courseId, - teacher_name, - caption, - course_time, - number_of_course_content, - is_free - } = req.body - - const isFreePackage = - is_free === 'true' || is_free === true - ? true - : is_free === 'false' || is_free === false - ? false - : undefined - - // اعتبارسنجی courseId - if (!courseId) { - return res.status(422).json({ - error: true, - message: 'آیدی دوره الزامی است' - }) - } - - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(422).json({ - error: true, - message: 'پکیجی یافت نشد' - }) - } - - // ========== آپلود عکس جدید (اختیاری) ========== - const { course_image } = req.files || {} - - if (course_image) { - // فقط اگه عکس جدید آپلود شده بود، آپلود کن - const uploadDir = path.join(__dirname, '../../../../storage/profiles') - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) - } - - // استفاده از id به جای نام دوره برای نام فایل - const safeFileName = `course-${courseId}-${Date.now()}${path.extname( - course_image.name - )}` - const filePath = path.join(uploadDir, safeFileName) - - await fs.move(course_image.path, filePath) - course.course_image = `/profiles/${safeFileName}` - } - - // ========== به روز رسانی فیلدها (فقط فیلدهایی که ارسال شده) ========== - if (price !== undefined) course.price = isFreePackage ? '0' : price - if (cuorse_name !== undefined) course.cuorse_name = cuorse_name - if (category !== undefined) course.category = category - if (offer !== undefined) course.offer = offer || '0' - if (caption !== undefined) course.caption = maskPhoneNumbers(caption) - if (course_time !== undefined) course.course_time = course_time - if (number_of_course_content !== undefined) { course.number_of_course_content = number_of_course_content } - if (teacher_name !== undefined) course.teacher_name = teacher_name - if (isFreePackage !== undefined) course.is_free = isFreePackage - if ( - req.body.contact_mobile !== undefined || - req.body.contact_telegram !== undefined || - req.body.contact_whatsapp !== undefined || - req.body.contact_instagram !== undefined - ) { - course.contactInfo = { - mobile: req.body.contact_mobile || null, - telegramLink: req.body.contact_telegram || null, - whatsappNumber: req.body.contact_whatsapp || null, - instagramLink: req.body.contact_instagram || null - } - } - - await course.save() - - res.status(200).json({ - success: true, - message: 'دوره با موفقیت به روز رسانی شد', - data: { - course - } - }) - } catch (error) { - console.error('Error in updateCourse:', error) - res.status(500).json({ - success: false, - message: 'خطا در به روز رسانی دوره', - error: error.message - }) - } -} -const deleteCourse = 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 userId = decodedToken.id - const { courseId } = req.body - - const user = await UserModel.findById(userId) - if (!user) { - return res.status(422).json({ - error: true, - message: 'کاربر یافت نشد' - }) - } - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(422).json({ - error: true, - message: 'پکیجی یافت نشد' - }) - } - - course.status = 'reject' - await course.save() - - res.status(200).json({ - message: 'ثبت شد' - }) - } catch (error) { - // در صورت بروز خطا، ارسال پیام خطا به کاربر - console.error('Error:', error) - res.status(500).json({ message: 'err in update' }) - } -} -const getCourse = 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 userId = decodedToken.id - - // گرفتن پارامترهای صفحهبندی - const page = parseInt(req.query.page) || 1 - const limit = parseInt(req.query.limit) || 10 - const skip = (page - 1) * limit - - const user = await UserModel.findById(userId) - if (!user) { - return res.status(422).json({ - error: true, - message: 'کاربر یافت نشد' - }) - } - - const academy = await AcademyModel.findOne({ userId }) - if (!academy) { - return res.status(404).json({ - success: false, - message: 'آکادمی پیدا نشد' - }) - } - - // ✅ شرط جستجو - فقط دورههای تایید شده - const query = { - academyId: academy._id, - status: coursePublicStatusFilter() - } - - // دریافت تعداد کل دورههای تایید شده - const totalCourses = await CourseModel.countDocuments(query) - - // دریافت دورههای تایید شده با صفحهبندی - const courses = await CourseModel.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - - // ✅ اگر دورهای وجود نداشت، خطا نده، بلکه آرایه خالی برگردون - if (!courses || courses.length === 0) { - return res.status(200).json({ - success: true, - message: 'هیچ دوره تایید شدهای یافت نشد', - data: { - courses: [], - pagination: { - currentPage: page, - totalPages: 0, - totalItems: 0, - itemsPerPage: limit, - hasNextPage: false, - hasPrevPage: false, - nextPage: null, - prevPage: null - } - } - }) - } - - // محاسبات صفحهبندی - const totalPages = Math.ceil(totalCourses / limit) - const hasNextPage = page < totalPages - const hasPrevPage = page > 1 - - res.status(200).json({ - success: true, - message: 'لیست پکیجها با موفقیت دریافت شد', - data: { - courses, - pagination: { - currentPage: page, - totalPages, - totalItems: totalCourses, - itemsPerPage: limit, - hasNextPage, - hasPrevPage, - nextPage: hasNextPage ? page + 1 : null, - prevPage: hasPrevPage ? page - 1 : null - } - } - }) - } catch (error) { - console.error('Error:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت پکیجها', - error: error.message - }) - } -} - -const getCourses = async (req, res, next) => { - try { - // ========== 3. دریافت پارامترهای صفحهبندی ========== - const page = parseInt(req.query.page) || 1 - const limit = parseInt(req.query.limit) || 10 - const skip = (page - 1) * limit - - // ========== 4. دریافت پارامترهای جستجو ========== - const search = req.query.search || '' - const category = req.query.category || '' - const minPrice = req.query.minPrice ? parseInt(req.query.minPrice) : 0 - const maxPrice = req.query.maxPrice - ? parseInt(req.query.maxPrice) - : 100000000 - const hasOffer = req.query.hasOffer === 'true' // تخفیف دار - const teacherName = req.query.teacherName || '' - const type = req.query.type || '' // normal, pro, legend - const isFree = req.query.isFree === 'true' - const status = req.query.status || 'all' // pending, accept, reject - const sortBy = req.query.sortBy || 'createdAt' - const sortOrder = req.query.sortOrder === 'asc' ? 1 : -1 - const _id = req.query._id || '' - // ========== 5. ساخت شرط جستجو (Query) ========== - const query = {} - - // جستجو در نام دوره و کپشن - if (search) { - query.$or = [ - { cuorse_name: { $regex: search, $options: 'i' } }, - { caption: { $regex: search, $options: 'i' } }, - { teacher_name: { $regex: search, $options: 'i' } } - ] - } - - // فیلتر بر اساس دسته بندی - if (category) { - query.category = category - } - if (_id) { - query._id = _id - } - // فیلتر بر اساس محدوده قیمت (تبدیل به عدد برای مقایسه) - if (minPrice > 0 || maxPrice < 100000000) { - query.price = { - $gte: minPrice.toString(), - $lte: maxPrice.toString() - } - } - - // فیلتر بر اساس تخفیف دار (offer وجود داشته باشد و از 0 بیشتر باشد) - if (hasOffer) { - query.offer = { $exists: true, $ne: '0', $ne: '' } - } - - // فیلتر بر اساس نام مدرس - if (teacherName) { - query.teacher_name = { $regex: teacherName, $options: 'i' } - } - - // فیلتر بر اساس نوع دوره - if (type) { - query.type = type - } - - if (isFree) { - query.is_free = true - } - - // فیلتر بر اساس وضعیت — در اپ، temp_accept هم نمایش داده میشود - if (status === 'accept') { - query.status = coursePublicStatusFilter() - } else if (status !== 'all') { - query.status = status - } else { - query.status = coursePublicStatusFilter() - } - - // ========== 6. دریافت آمار و اطلاعات ========== - // تعداد کل پکیجها (بدون صفحهبندی) - const totalCourses = await CourseModel.countDocuments(query) - - // مجموع قیمتها (تبدیل به عدد برای محاسبه) - const priceStats = await CourseModel.aggregate([ - { $match: query }, - { - $group: { - _id: null, - total: { $sum: { $toDouble: '$price' } }, - avg: { $avg: { $toDouble: '$price' } }, - min: { $min: { $toDouble: '$price' } }, - max: { $max: { $toDouble: '$price' } } - } - } - ]) - - // تعداد دورههای با تخفیف - const offeredCourses = await CourseModel.countDocuments({ - ...query, - offer: { $exists: true, $ne: '0', $ne: '' } - }) - - // تعداد دورهها بر اساس نوع - const typeStats = await CourseModel.aggregate([ - { $match: query }, - { - $group: { - _id: '$type', - count: { $sum: 1 } - } - } - ]) - - // ========== 7. دریافت پکیجها با صفحهبندی ========== - const courses = await CourseModel.find(query) - .populate('user_id', 'name email') // اطلاعات کاربر سازنده - .populate('academyId', 'academy_name academy_image') // برای نمایش عکس/نام آموزشگاه توی لیست - .populate('likes', 'name') // اطلاعات لایکها - .sort({ [sortBy]: sortOrder }) - .skip(skip) - .limit(limit) - .lean() - - // پردازش دادهها برای خروجی - let processedCourses = courses.map((course) => ({ - ...course, - // academy_name/academy_image رو صاف روی خود کورس میذاریم (نه فقط زیر academyId - // populate-شده) چون اپ موبایل/وب این دو فیلد رو مستقیم روی course انتظار دارن - academy_name: course.academyId?.academy_name || null, - academy_image: course.academyId?.academy_image || null, - // academyId رو بعد از populate به همون شناسهی رشتهای برمیگردونیم — وگرنه بهجای - // ObjectId، کل سابداکیومنت populate-شده (academy_name/academy_image) توی جواب میره؛ - // موبایل/وب همین academyId رو مستقیم به /academy/findAcademyById میفرستن، پس یه - // object بهجای رشته باعث خطای کست ObjectId میشه (نتیجه: پروفایل آموزشگاه لود نمیشه) - academyId: course.academyId?._id - ? String(course.academyId._id) - : course.academyId, - priceNumber: parseInt(course.price) || 0, - offerNumber: parseInt(course.offer) || 0, - finalPrice: course.is_free - ? 0 - : course.offer - ? (parseInt(course.price) * (100 - parseInt(course.offer))) / 100 - : parseInt(course.price), - likesCount: course.likes?.length || 0 - })) - - // بلند کردن دورههای مرتبط با علایق کاربر داخل نتایج پیشفرض (بدون مرتبسازی دستی کاربر) - const isDefaultBrowsePath = !req.query.sortBy && !category && !search && page === 1 - if (isDefaultBrowsePath) { - try { - const token = req.header('Authorization')?.split(' ')[1] - const viewerId = token ? jwt.verify(token, process.env.APP_SECRET)?.id : null - if (viewerId) { - const interestWeights = await buildUnifiedInterestScore(viewerId) - const tags = Object.keys(interestWeights).filter((tag) => interestWeights[tag] > 0) - if (tags.length) { - const interestCategories = await AcademyCategoryModel.find({ - relatedExpertise: { $in: tags } - }) - .select('title') - .lean() - const categoryTitles = interestCategories.map((c) => c.title) - if (categoryTitles.length) { - const existingIds = processedCourses.map((c) => c._id) - const interestCourses = await CourseModel.find({ - ...query, - category: { $in: categoryTitles }, - _id: { $nin: existingIds } - }) - .populate('user_id', 'name email') - .populate('academyId', 'academy_name academy_image') - .populate('likes', 'name') - .sort({ createdAt: -1 }) - .limit(5) - .lean() - const processedInterestCourses = interestCourses.map((course) => ({ - ...course, - academy_name: course.academyId?.academy_name || null, - academy_image: course.academyId?.academy_image || null, - // عین بلوک اصلی بالاتر — academyId بعد از populate باید رشته بمونه - academyId: course.academyId?._id - ? String(course.academyId._id) - : course.academyId, - priceNumber: parseInt(course.price) || 0, - offerNumber: parseInt(course.offer) || 0, - finalPrice: course.is_free - ? 0 - : course.offer - ? (parseInt(course.price) * (100 - parseInt(course.offer))) / 100 - : parseInt(course.price), - likesCount: course.likes?.length || 0 - })) - processedCourses = blendInterestIntoRanked( - processedCourses, - [...processedCourses, ...processedInterestCourses], - 0.25 - ).slice(0, limit) - } - } - } - } catch { - /* non-blocking: personalization is a bonus, never break the listing */ - } - } - - // ========== 8. محاسبات صفحهبندی ========== - const totalPages = Math.ceil(totalCourses / limit) - const hasNextPage = page < totalPages - const hasPrevPage = page > 1 - - // محاسبه محدوده نمایش - const startItem = totalCourses === 0 ? 0 : (page - 1) * limit + 1 - const endItem = Math.min(page * limit, totalCourses) - - // ========== 9. ارسال پاسخ ========== - res.status(200).json({ - success: true, - message: 'لیست پکیجها با موفقیت دریافت شد', - data: { - courses: processedCourses, - stats: { - totalCourses, - offeredCourses, - averagePrice: Math.round(priceStats[0]?.avg || 0), - minPrice: priceStats[0]?.min || 0, - maxPrice: priceStats[0]?.max || 0, - totalPrice: priceStats[0]?.total || 0, - typeStats - }, - pagination: { - currentPage: page, - totalPages, - totalItems: totalCourses, - itemsPerPage: limit, - hasNextPage, - hasPrevPage, - nextPage: hasNextPage ? page + 1 : null, - prevPage: hasPrevPage ? page - 1 : null, - startItem, - endItem - }, - filters: { - search, - category, - minPrice, - maxPrice, - hasOffer, - teacherName, - type, - sortBy, - sortOrder: sortOrder === 1 ? 'asc' : 'desc', - status - } - } - }) - } catch (error) { - console.error('Error in getCourses:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت پکیجها', - error: error.message - }) - } -} - -const proPayment = 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 userId = decodedToken.id - const { courseId } = req.body - - // ========== 1. بررسی وجود CoursePayment ========== - const coursePayment = await CoursePaymentModel.findOne({ - cuorse_type: 'pro' - }) - - if (!coursePayment) { - return res.status(404).json({ - error: true, - message: 'قیمتگذاری برای نوع پرو یافت نشد' - }) - } - - const price = coursePayment.price - - // ========== 2. اعتبارسنجی قیمت ========== - if (!price || isNaN(Number(price)) || Number(price) <= 0) { - return res.status(422).json({ - error: true, - message: 'مبلغ نامعتبر است' - }) - } - - // ========== 3. بررسی وجود دوره ========== - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(404).json({ - error: true, - message: 'پکیجی یافت نشد' - }) - } - - // ========== 4. محاسبه مبلغ ========== - const amount = Number(price) * 10 - - // مبلغ باید حداقل 1000 تومان باشد (طبق قوانین زرینپال) - if (amount < 1000) { - return res.status(422).json({ - error: true, - message: 'مبلغ باید حداقل 1000 تومان باشد' - }) - } - - const callbackUrl = `https://api.modstagram.com/api/v1/academy/academy/course/pro?courseId=${courseId}&userId=${userId}` - - // ========== 5. درخواست به زرینپال ========== - const zarinpalRes = await axios.post( - 'https://api.zarinpal.com/pg/v4/payment/request.json', - { - merchant_id: - process.env.ZARINPAL_MERCHANT_ID || - 'c7c41e8a-918f-4741-bcd5-58f3bc51db73', - amount: Math.round(amount), // حتماً عدد صحیح باشد - description: `خرید اشتراک پرو برای ${course.cuorse_name}`, - callback_url: callbackUrl, - metadata: { - user_id: userId, - course_id: courseId - } - }, - { - headers: { 'Content-Type': 'application/json' }, - timeout: 10000 // 10 ثانیه تایماوت - } - ) - - const result = zarinpalRes.data - - // ========== 6. بررسی پاسخ زرینپال ========== - if (result.data && result.data.code === 100) { - // ذخیره اطلاعات پرداخت در دیتابیس (اختیاری) - await PaymentModel.create({ - authority: result.data.authority, - amount, - user_id: userId, - course_id: courseId, - status: 'pending', - type: 'pro', - createdAt: new Date() - }) - - return res.status(200).json({ - success: true, - authority: result.data.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.data.authority}` - }) - } - - // خطای زرینپال - console.error('ZarinPal error:', result) - return res.status(422).json({ - error: true, - message: result.errors?.message || 'خطا در ارتباط با درگاه پرداخت', - details: result.errors - }) - } catch (error) { - console.error( - 'Error in proPayment:', - error.response?.data || error.message - ) - res.status(500).json({ - error: true, - message: 'خطا در پردازش پرداخت', - details: error.response?.data || error.message - }) - } -} - -const handleCoursePaymentCallback = async (req, res) => { - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') - - try { - const { Authority, Status, userId, courseId } = req.query - - if (Status !== 'OK') { - return res.redirect(`${process.env.APP_SITE}/settings/academy/course?payment=failed`) - } - - // ========== 1. پیدا کردن قیمت ========== - const coursePayment = await CoursePaymentModel.findOne({ - cuorse_type: 'pro' - }) - - if (!coursePayment) { - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&message=price_not_found` - ) - } - - const price = coursePayment.price - const amount = Number(price) * 10 - - // ========== 2. تایید پرداخت با زرینپال ========== - const verify = await axios.post( - 'https://api.zarinpal.com/pg/v4/payment/verify.json', - { - merchant_id: - process.env.ZARINPAL_MERCHANT_ID || - 'c7c41e8a-918f-4741-bcd5-58f3bc51db73', - authority: Authority, - amount: Math.round(amount) - }, - { - headers: { 'Content-Type': 'application/json' }, - timeout: 10000 - } - ) - - const data = verify.data.data - - // ========== 3. بررسی نتیجه تایید ========== - if (data.code === 100) { - // ثبت پرداخت موفق - await PaymentModel.create({ - amount: amount / 10, - status: 'successful', - authority: Authority, - ref_id: data.ref_id, - user_id: userId, - course_id: courseId, - type: 'course', - verifiedAt: new Date() - }) - - // به روز رسانی نوع دوره به pro - const course = await CourseModel.findById(courseId) - if (course) { - course.type = 'pro' - await course.save() - } - - // به روز رسانی وضعیت کاربر (اشتراک پرو) - await UserModel.findByIdAndUpdate(userId, { - $set: { - 'proSubscription.active': true, - 'proSubscription.courseId': courseId, - 'proSubscription.startDate': new Date(), - 'proSubscription.purchaseDate': new Date() - } - }) - - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=success` - ) - } - - // پرداخت ناموفق - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&code=${data.code}` - ) - } catch (err) { - console.error('Error in handleCoursePaymentCallback:', err) - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&message=verification_error` - ) - } -} - -const creatCourseVideo = async (req, res, next) => { - console.log('=== New request received ===') - console.log('Headers:', req.headers) - console.log('Body:', req.body) - console.log('File:', req.file) - - 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 userId = decodedToken.id - - const { courseId, is_free, file_name } = req.body - const videoFile = req.file - - if (!videoFile) { - return res.status(422).json({ - error: true, - message: 'فایل ویدیو ارسال نشده است' - }) - } - - if (!courseId) { - return res.status(422).json({ - error: true, - message: 'شناسه دوره الزامی است' - }) - } - - // مسیر ذخیرهسازی نهایی (مشابه سیستم پستها) - const finalVideoDir = path.join( - __dirname, - '../../../storage/courses/videos' - ) - const safeFileName = `course-${courseId}-${Date.now()}${path.extname( - videoFile.originalname - )}` - const filePath = path.join(finalVideoDir, safeFileName) - - // انتقال فایل از محل موقت به محل نهایی - await fs.rename(videoFile.path, filePath) - - const uploaderUser = await UserModel.findById(userId).select('verify_badge').lean() - - // ایجاد رکورد جدید در دیتابیس - const newAcademyContent = new AcademyContentModel({ - course_video: `/storage/courses/videos/${safeFileName}`, - type: 'video', - is_free: is_free === 'true' || is_free === true, - courseId, - file_name: file_name || videoFile.originalname, - status: isVerifiedBadge(uploaderUser?.verify_badge) ? 'temp_accept' : 'pending' - }) - - await newAcademyContent.save() - - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(404).json({ - success: false, - message: 'پکیج پیدا نشد' - }) - } - - course.number_of_course_content = `${ - Number(course.number_of_course_content) + 1 - }` - await course.save() - - res.status(200).json({ - success: true, - message: 'ویدیو با موفقیت ثبت شد', - data: newAcademyContent - }) - } catch (error) { - console.error('Error in creatCourseVideo:', error) - res.status(500).json({ - success: false, - message: 'خطا در ثبت ویدیو', - error: error.message - }) - } -} - -const creatCourseVideoBase64 = async (req, res, next) => { - console.log('=== Base64 Video Upload ===') - console.log('Body:', Object.keys(req.body)) - - 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 userId = decodedToken.id - - const { courseId, is_free, file_name, video_base64, mime_type } = req.body - - // اعتبارسنجی - if (!video_base64) { - return res.status(422).json({ - error: true, - message: 'فایل ویدیو ارسال نشده است' - }) - } - - if (!courseId) { - return res.status(422).json({ - error: true, - message: 'شناسه دوره الزامی است' - }) - } - - // تبدیل Base64 به بافر - const base64Data = video_base64.split(',')[1] || video_base64 - const buffer = Buffer.from(base64Data, 'base64') - - console.log('Buffer size:', buffer.length, 'bytes') - - // ایجاد دایرکتوری اگر وجود ندارد - const finalVideoDir = path.join( - process.cwd(), - 'storage', - 'courses', - 'videos' - ) - if (!fs.existsSync(finalVideoDir)) { - fs.mkdirSync(finalVideoDir, { recursive: true }) - } - - // تعیین پسوند فایل از mime_type - let extension = '.mp4' - if (mime_type === 'video/mp4') extension = '.mp4' - else if (mime_type === 'video/quicktime') extension = '.mov' - else if (mime_type === 'video/x-msvideo') extension = '.avi' - else if (mime_type === 'video/webm') extension = '.webm' - - const safeFileName = `course-${courseId}-${Date.now()}${extension}` - const filePath = path.join(finalVideoDir, safeFileName) - - // ذخیره فایل - await fs.writeFileSync(filePath, buffer) - - console.log('File saved:', filePath) - - const uploaderUser = await UserModel.findById(userId).select('verify_badge').lean() - - // ایجاد رکورد در دیتابیس - const newAcademyContent = new AcademyContentModel({ - course_video: `/storage/courses/videos/${safeFileName}`, - type: 'video', - is_free: is_free === 'true' || is_free === true, - courseId, - file_name, - status: isVerifiedBadge(uploaderUser?.verify_badge) ? 'temp_accept' : 'pending' - }) - - await newAcademyContent.save() - - // به روز رسانی تعداد محتواهای دوره - const course = await CourseModel.findById(courseId) - if (course) { - course.number_of_course_content = `${ - Number(course.number_of_course_content) + 1 - }` - await course.save() - } - - res.status(200).json({ - success: true, - message: 'ویدیو با موفقیت ثبت شد', - data: newAcademyContent - }) - } catch (error) { - console.error('Error in creatCourseVideoBase64:', error) - res.status(500).json({ - success: false, - message: 'خطا در ثبت ویدیو', - error: error.message - }) - } -} - -const getCourseContent = async (req, res, next) => { - try { - // ✅ اصلاح: دریافت courseId از query یا params - const courseId = req.query.courseId || req.params.courseId - - // اعتبارسنجی courseId - if (!courseId) { - return res.status(422).json({ - error: true, - message: 'شناسه دوره الزامی است' - }) - } - - // گرفتن پارامترهای صفحهبندی - const page = parseInt(req.query.page) || 1 - const limit = parseInt(req.query.limit) || 10 - const skip = (page - 1) * limit - - const academy = await CourseModel.findById(courseId) - if (!academy) { - return res.status(404).json({ - success: false, - message: 'پکیج پیدا نشد' - }) - } - - // ✅ شرط جستجو - محتوای تایید موقت و تایید کامل - const query = { - courseId: academy._id, - status: academyContentPublicStatusFilter() - } - - // دریافت تعداد کل دورههای تایید شده - const totalCourses = await AcademyContentModel.countDocuments(query) - - // دریافت دورههای تایید شده با صفحهبندی - const courses = await AcademyContentModel.find(query) - .skip(skip) - .limit(limit) - - // ✅ اگر دورهای وجود نداشت، خطا نده، بلکه آرایه خالی برگردون - if (!courses || courses.length === 0) { - return res.status(200).json({ - success: true, - message: 'هیچ محتوایی یافت نشد', - data: { - courses: [], - pagination: { - currentPage: page, - totalPages: 0, - totalItems: 0, - itemsPerPage: limit, - hasNextPage: false, - hasPrevPage: false - } - } - }) - } - - // محاسبات صفحهبندی - const totalPages = Math.ceil(totalCourses / limit) - const hasNextPage = page < totalPages - const hasPrevPage = page > 1 - - res.status(200).json({ - success: true, - message: 'لیست محتواها با موفقیت دریافت شد', - data: { - courses, - pagination: { - currentPage: page, - totalPages, - totalItems: totalCourses, - itemsPerPage: limit, - hasNextPage, - hasPrevPage, - nextPage: hasNextPage ? page + 1 : null, - prevPage: hasPrevPage ? page - 1 : null - } - } - }) - } catch (error) { - console.error('Error in getCourseContent:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت محتواها', - error: error.message - }) - } -} -// لایک کردن محتوا -const likeCourseContent = 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 userId = decodedToken.id - - if (!userId) { - return res.status(422).json({ - error: true, - message: 'برای لایک کردن وارد حساب کاربری خود شوید' - }) - } - - const { courseId } = req.body - - // بررسی وجود دوره - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(404).json({ - success: false, - message: 'پکیج پیدا نشد' - }) - } - - // بررسی اینکه آیا قبلاً لایک کرده است - const existingLike = await AcademyLikeModel.findOne({ - user_id: userId, - course_id: courseId - }) - - if (existingLike) { - return res.status(400).json({ - success: false, - message: 'شما قبلاً این دوره را لایک کردهاید' - }) - } - - // افزایش تعداد لایکهای دوره - course.likes = (course.likes || 0) + 1 - await course.save() - - // ایجاد رکورد لایک - await AcademyLikeModel.create({ - user_id: userId, - course_id: courseId, - createdAt: new Date() - }) - - const courseOwnerId = await resolveCourseOwnerId(course) - if (!courseOwnerId) { - console.warn('likeCourseContent: course owner not found for', courseId) - } - - await createLikeNotification({ - io: req.app.get('io'), - ownerId: courseOwnerId, - likerId: userId, - entityId: course._id, - type: 'academy_like' - }) - - res.status(200).json({ - success: true, - message: 'دوره با موفقیت لایک شد', - isLiked: true, - likesCount: course.likes - }) - } catch (error) { - console.error('Error in likeCourseContent:', error) - res.status(500).json({ - success: false, - message: 'خطا در لایک کردن محتوا', - error: error.message - }) - } -} - -// دیسلایک کردن محتوا (حذف لایک) -const dontLikeCourseContent = 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 userId = decodedToken.id - - const { courseId } = req.body - - // بررسی وجود دوره - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(404).json({ - success: false, - message: 'پکیج پیدا نشد' - }) - } - - // پیدا کردن و حذف لایک - const deletedLike = await AcademyLikeModel.findOneAndDelete({ - user_id: userId, - course_id: courseId - }) - - if (!deletedLike) { - return res.status(404).json({ - success: false, - message: 'شما این دوره را لایک نکردهاید' - }) - } - - // کاهش تعداد لایکهای دوره - course.likes = Math.max((course.likes || 0) - 1, 0) // جلوگیری از منفی شدن - await course.save() - - res.status(200).json({ - success: true, - message: 'لایک دوره با موفقیت حذف شد', - isLiked: false, - likesCount: course.likes - }) - } catch (error) { - console.error('Error in dontLikeCourseContent:', error) - res.status(500).json({ - success: false, - message: 'خطا در حذف لایک محتوا', - error: error.message - }) - } -} - -// بررسی وضعیت لایک (آیا کاربر لایک کرده است یا نه) -const isLikeCourseContent = 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 userId = decodedToken.id - - const { courseId } = req.body - - // پیدا کردن لایک - const academyLike = await AcademyLikeModel.findOne({ - user_id: userId, - course_id: courseId - }) - - res.status(200).json({ - success: true, - isLiked: !!academyLike // تبدیل به boolean - }) - } catch (error) { - console.error('Error in isLikeCourseContent:', error) - res.status(500).json({ - success: false, - message: 'خطا در بررسی وضعیت لایک', - error: error.message - }) - } -} - -// دریافت تعداد لایکهای یک دوره -const getCourseLikesCount = async (req, res, next) => { - try { - const { courseId } = req.params - - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(404).json({ - success: false, - message: 'دوره پیدا نشد' - }) - } - - const likesCount = await AcademyLikeModel.countDocuments({ - course_id: courseId - }) - - res.status(200).json({ - success: true, - likesCount, - courseLikes: course.likes || 0 - }) - } catch (error) { - console.error('Error in getCourseLikesCount:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت تعداد لایکها', - error: error.message - }) - } -} - -// دریافت همه دورههایی که کاربر لایک کرده است -const getUserLikedCourses = 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 userId = decodedToken.id - - const page = parseInt(req.query.page) || 1 - const limit = parseInt(req.query.limit) || 10 - const skip = (page - 1) * limit - - const likes = await AcademyLikeModel.find({ user_id: userId }) - .populate('course_id') - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - - const total = await AcademyLikeModel.countDocuments({ user_id: userId }) - - res.status(200).json({ - success: true, - data: likes, - pagination: { - currentPage: page, - totalPages: Math.ceil(total / limit), - totalItems: total, - itemsPerPage: limit - } - }) - } catch (error) { - console.error('Error in getUserLikedCourses:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت دورههای لایک شده', - error: error.message - }) - } -} - -// ==================== ساخت کامنت جدید ==================== -const createComment = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { course_id, comment, rate } = req.body - - // اعتبارسنجی - if (!course_id || !comment) { - return res.status(422).json({ - error: true, - message: 'شناسه دوره و متن کامنت الزامی است' - }) - } - - // بررسی وجود دوره - const course = await CourseModel.findById(course_id) - if (!course) { - return res.status(404).json({ - error: true, - message: 'دوره پیدا نشد' - }) - } - - const parsedRate = Number(rate) || 0 - const existingRatedComment = await CourseComment.findOne({ - user_id: userId, - course_id, - rate: { $gt: 0 } - }) - - if (existingRatedComment && parsedRate > 0) { - return res.status(400).json({ - error: true, - message: 'شما قبلاً به این دوره امتیاز دادهاید.' - }) - } - - const finalRate = existingRatedComment ? 0 : parsedRate - - const commentUser = await UserModel.findById(userId) - .select('first_name last_name user_name profile_image is_verified verify_badge') - .lean() - - // ایجاد کامنت جدید - const newComment = await saveCommentWithModerationStatus( - CourseComment, - { - user_id: userId, - course_id, - comment, - rate: finalRate - }, - 'comment', - commentUser?.verify_badge - ) - - // populate کردن اطلاعات کاربر - const commentDoc = await CourseComment.findById(newComment._id).lean() - const populatedComment = { - ...commentDoc, - user_id: commentUser - } - - const courseOwnerId = await resolveCourseOwnerId(course) - if (!courseOwnerId) { - console.warn('createComment: course owner not found for', course_id) - } - - await createCommentNotification({ - io: req.app.get('io'), - ownerId: courseOwnerId, - commenterId: userId, - entityId: course._id, - type: 'academy_comment' - }) - - if (finalRate > 0) { - await createRatingNotification({ - io: req.app.get('io'), - ownerId: courseOwnerId, - raterId: userId, - entityId: course._id, - type: 'academy_rating' - }) - } - - res.status(201).json({ - success: true, - message: 'کامنت با موفقیت ثبت شد', - data: populatedComment - }) - } catch (error) { - console.error('Error in createComment:', error) - if (error?.name === 'ValidationError') { - return res.status(500).json({ - success: false, - message: 'خطا در ثبت کامنت. مدل کامنت روی سرور بهروز نیست.' - }) - } - res.status(500).json({ - success: false, - message: 'خطا در ثبت کامنت', - error: error.message - }) - } -} - -// ==================== دریافت تعداد کل کامنتهای یک دوره ==================== -const getTotalCommentsCount = async (req, res) => { - try { - const { courseId } = req.params - const { status } = req.query // اختیاری: فقط کامنتهای با وضعیت خاص - - const query = { course_id: courseId } - if (status) { - query.status = status - } - - const totalComments = await CourseComment.countDocuments(query) - - // محاسبه میانگین امتیازات - const averageRate = await CourseComment.aggregate([ - { $match: { course_id: courseId, status: 'accepted' } }, - { $group: { _id: null, avgRate: { $avg: '$rate' } } } - ]) - - res.status(200).json({ - success: true, - data: { - totalComments, - averageRate: averageRate[0]?.avgRate || 0, - courseId - } - }) - } catch (error) { - console.error('Error in getTotalCommentsCount:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت تعداد کامنتها', - error: error.message - }) - } -} - -// ==================== دریافت کامنتها با فیلترهای مختلف ==================== -const getCourseComments = async (req, res) => { - try { - const { courseId } = req.params - const { - page = 1, - limit = 10, - status = 'accepted', // 'accepted', 'pending', 'rejected', 'all' - minRate, - maxRate, - sortBy = 'createdAt', - sortOrder = 'desc', - userId // اختیاری: کامنتهای یک کاربر خاص - } = req.query - - // ساخت شرط جستجو - const query = { course_id: courseId } - - // فیلتر بر اساس وضعیت - if (status === 'accepted') { - query.status = commentStatusFilter() - } else if (status !== 'all') { - query.status = status - } else { - query.status = commentStatusFilter() - } - - // فیلتر بر اساس امتیاز - if (minRate || maxRate) { - query.rate = {} - if (minRate) query.rate.$gte = parseInt(minRate) - if (maxRate) query.rate.$lte = parseInt(maxRate) - } - - // فیلتر بر اساس کاربر خاص - if (userId) { - query.user_id = userId - } - - // تنظیمات مرتبسازی - const sortOptions = {} - sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1 - - // دریافت کامنتها با pagination - const skip = (parseInt(page) - 1) * parseInt(limit) - - const [comments, totalComments] = await Promise.all([ - CourseComment.find(query) - .sort(sortOptions) - .skip(skip) - .limit(parseInt(limit)) - // فیلدهای قبلی (name/username/profileImage) روی این اسکیما اصلاً وجود نداشتن — - // یعنی اطلاعاتِ نویسندهی کامنت همیشه خالی برمیگشت - .populate('user_id', 'user_name first_name last_name profile_image verify_badge is_verified') - .lean(), - CourseComment.countDocuments(query) - ]) - - // بررسی وجود توکن (اختیاری — لیست کامنتها بدون لاگین هم قابل مشاهدهست) - let viewerId = null - const authHeader = req.header('Authorization') - if (authHeader) { - const tokenParts = authHeader.split(' ') - if (tokenParts.length === 2) { - try { - viewerId = jwt.verify(tokenParts[1], process.env.APP_SECRET).id - } catch (err) { - console.error('Invalid Token:', err.message) - } - } - } - - const commenterIds = comments.map((c) => c.user_id?._id).filter(Boolean) - const storyFlags = await resolveStoryFlags(commenterIds, viewerId) - const commentsWithStoryFlags = comments.map((c) => { - if (!c.user_id) return c - const flags = storyFlags[String(c.user_id._id)] || {} - return { - ...c, - user_id: { - ...c.user_id, - has_story: flags.has_story || false, - has_unviewed_story: flags.has_unviewed_story || false - } - } - }) - - // محاسبه آمار کامنتها - const stats = await CourseComment.aggregate([ - { $match: { course_id: courseId, status: 'accepted' } }, - { - $group: { - _id: null, - totalAccepted: { $sum: 1 }, - averageRate: { $avg: '$rate' }, - rateDistribution: { - $push: '$rate' - } - } - } - ]) - - const totalPages = Math.ceil(totalComments / parseInt(limit)) - - res.status(200).json({ - success: true, - message: 'لیست کامنتها با موفقیت دریافت شد', - data: { - comments: commentsWithStoryFlags, - pagination: { - currentPage: parseInt(page), - totalPages, - totalItems: totalComments, - itemsPerPage: parseInt(limit), - hasNextPage: parseInt(page) < totalPages, - hasPrevPage: parseInt(page) > 1 - }, - stats: { - totalComments, - acceptedComments: stats[0]?.totalAccepted || 0, - averageRate: Math.round((stats[0]?.averageRate || 0) * 10) / 10, - rateDistribution: stats[0]?.rateDistribution || [] - } - } - }) - } catch (error) { - console.error('Error in getCourseComments:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت کامنتها', - error: error.message - }) - } -} -// ==================== ویرایش وضعیت کامنت (برای همه قابل استفاده است) ==================== -const updateCommentStatus = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { commentId } = req.params - const { status } = req.body // 'pending', 'accepted', 'rejected' - - // اعتبارسنجی وضعیت - if (!['pending', 'accepted', 'rejected'].includes(status)) { - return res.status(422).json({ - error: true, - message: - 'وضعیت نامعتبر است. وضعیت باید یکی از این موارد باشد: pending, accepted, rejected' - }) - } - - // پیدا کردن کامنت - const comment = await CourseComment.findById(commentId) - if (!comment) { - return res.status(404).json({ - error: true, - message: 'کامنت پیدا نشد' - }) - } - - // بررسی دسترسی: یا خود صاحب کامنت است یا هر کاربر دیگری (طبق درخواست شما که همه بتوانند) - // اگر میخواهید فقط ادمین بتواند وضعیت را تغییر دهد، این بخش را تغییر دهید - const user = await UserModel.findById(userId) - const isOwner = comment.user_id.toString() === userId - const isAdmin = user?.role === 'admin' - - if (!isOwner && !isAdmin) { - return res.status(403).json({ - error: true, - message: 'شما اجازه تغییر وضعیت این کامنت را ندارید' - }) - } - - // به روز رسانی وضعیت - comment.status = status - await comment.save() - - // اگر کامنت تایید شد و امتیاز داشت، میانگین امتیاز دوره را به روز کن - if (status === 'accepted' && comment.rate > 0) { - const averageRate = await CourseComment.aggregate([ - { $match: { course_id: comment.course_id, status: 'accepted' } }, - { $group: { _id: null, avgRate: { $avg: '$rate' } } } - ]) - - await CourseModel.findByIdAndUpdate(comment.course_id, { - averageRate: averageRate[0]?.avgRate || 0 - }) - } - - res.status(200).json({ - success: true, - message: `وضعیت کامنت با موفقیت به ${ - status === 'accepted' - ? 'تایید شده' - : status === 'rejected' - ? 'رد شده' - : 'در انتظار' - } تغییر یافت`, - data: comment - }) - } catch (error) { - console.error('Error in updateCommentStatus:', error) - res.status(500).json({ - success: false, - message: 'خطا در تغییر وضعیت کامنت', - error: error.message - }) - } -} - -// ==================== دریافت کامنتهای یک کاربر خاص ==================== -const getUserComments = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { page = 1, limit = 10, status = 'all', courseId } = req.query - - const query = { user_id: userId } - - if (status !== 'all') { - query.status = status - } - - if (courseId) { - query.course_id = courseId - } - - const skip = (parseInt(page) - 1) * parseInt(limit) - - const [comments, totalComments] = await Promise.all([ - CourseComment.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(parseInt(limit)) - .populate('course_id', 'cuorse_name packageImage'), - CourseComment.countDocuments(query) - ]) - - const totalPages = Math.ceil(totalComments / parseInt(limit)) - - res.status(200).json({ - success: true, - data: { - comments, - pagination: { - currentPage: parseInt(page), - totalPages, - totalItems: totalComments, - itemsPerPage: parseInt(limit) - } - } - }) - } catch (error) { - console.error('Error in getUserComments:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت کامنتهای کاربر', - error: error.message - }) - } -} - -// ==================== حذف کامنت (اختیاری) ==================== -const deleteComment = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { commentId } = req.params - - const comment = await CourseComment.findById(commentId) - if (!comment) { - return res.status(404).json({ - error: true, - message: 'کامنت پیدا نشد' - }) - } - - // بررسی دسترسی: یا خود صاحب کامنت است یا ادمین - const user = await UserModel.findById(userId) - const isOwner = comment.user_id.toString() === userId - const isAdmin = user?.role === 'admin' - - if (!isOwner && !isAdmin) { - return res.status(403).json({ - error: true, - message: 'شما اجازه حذف این کامنت را ندارید' - }) - } - - await comment.deleteOne() - - res.status(200).json({ - success: true, - message: 'کامنت با موفقیت حذف شد' - }) - } catch (error) { - console.error('Error in deleteComment:', error) - res.status(500).json({ - success: false, - message: 'خطا در حذف کامنت', - error: error.message - }) - } -} -// academyController.js - -const coursePayment = async (req, res, next) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { - return res.status(401).json({ - error: true, - message: 'لطفا وارد حساب کاربری خود شوید' - }) - } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - const { courseId, useWallet } = req.body - - // ========== 1. بررسی وجود دوره ========== - const course = await CourseModel.findById(courseId) - if (!course) { - return res.status(404).json({ - error: true, - message: 'دوره مورد نظر یافت نشد' - }) - } - - // ========== 2. بررسی خرید قبلی ========== - const existingPurchase = await IsPaymentCourse.findOne({ - user_id: userId, - course_id: course._id - }) - - if (existingPurchase) { - return res.status(400).json({ - error: true, - message: 'شما قبلاً این دوره را خریداری کردهاید' - }) - } - - if (course.is_free) { - await IsPaymentCourse.create({ - user_id: userId, - course_id: course._id, - price_paid: 0, - purchased_at: new Date() - }) - - const courseOwnerId = await resolveCourseOwnerId(course) - await createCoursePurchaseNotification({ - ownerId: courseOwnerId, - buyerId: userId, - courseId: course._id - }) - - return res.status(200).json({ - success: true, - free: true, - message: 'پکیج رایگان با موفقیت ثبت شد' - }) - } - - // ========== 3. دریافت نرخ مالیات ========== - let taxRate = 9 // پیشفرض 9 درصد - const tax = await Tax.findOne({ type: 'course' }) - if (tax && tax.tax) { - taxRate = Number(tax.tax) - } - - // ========== 4. محاسبه قیمت ========== - const offerNum = Number(course.offer) || 0 - const priceNum = Number(course.price) - - // قیمت پس از تخفیف - const discountedPrice = (priceNum / 100) * (100 - offerNum) - - // محاسبه مالیات - const taxAmount = discountedPrice * (taxRate / 100) - - // قیمت نهایی با مالیات (تومان) - const finalPrice = discountedPrice - const finalTaxPrice = (discountedPrice / 100) * (100 - taxRate) - - // ========== 5. اعتبارسنجی قیمت ========== - if (!finalPrice || isNaN(finalPrice) || finalPrice <= 0) { - return res.status(422).json({ - error: true, - message: 'مبلغ نامعتبر است' - }) - } - - // ========== 5.۵ بررسی موجودی کیف پول (عین shopOrderPaymentController.js) ========== - const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit( - userId, - finalPrice, - useWallet - ) - - // کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست - if (fullyCovered) { - await debitWalletForPurchase(userId, walletAmount, { - referenceType: 'payment', - referenceId: course._id, - description: `خرید پکیج آموزشی «${course.cuorse_name}» از کیف پول` - }) - - const paymentRecord = await AcademyPaymentModel.create({ - price: finalPrice, - discountAmount: priceNum - discountedPrice, - taxAmount: finalTaxPrice, - taxRate, - course_name: course.cuorse_name, - course_id: course._id, - academy_id: course.academyId, - user_id: userId, - walletAmountUsed: walletAmount, - status: 'success' - }) - - await IsPaymentCourse.create({ - user_id: userId, - course_id: course._id, - price_paid: finalPrice, - purchased_at: new Date() - }) - - const courseOwnerId = await resolveCourseOwnerId(course) - await createCoursePurchaseNotification({ - ownerId: courseOwnerId, - buyerId: userId, - courseId: course._id - }) - - if (courseOwnerId) { - const sellerNetAmount = Math.round(finalPrice * (1 - ACADEMY_COMMISSION_RATE)) - if (sellerNetAmount > 0) { - await WalletTransactionModel.create({ - user: courseOwnerId, - bucket: 'academy', - type: 'credit', - amount: sellerNetAmount, - referenceType: 'payment', - referenceId: paymentRecord._id, - description: `درآمد فروش پکیج آموزشی «${course.cuorse_name}» (پس از کسر ۱۵٪ کارمزد)` - }) - } - } - - return res.status(200).json({ success: true, paidFromWallet: true }) - } - - // تبدیل به ریال (ضرب در 10 چون هر تومان = 10 ریال) - const amountInRials = Math.round(gatewayAmount * 10) - - // مبلغ باید حداقل 1000 تومان باشد (10000 ریال) - if (amountInRials < 10000) { - return res.status(422).json({ - error: true, - message: 'مبلغ باید حداقل 1000 تومان باشد' - }) - } - - // ========== 6. ساخت کالبک URL ========== - const callbackUrl = `https://api.modstagram.com/api/v1/academy/academy/course/payment/verify?courseId=${courseId}&userId=${userId}` - - // ========== 7. درخواست به زرینپال ========== - const zarinpalRes = await axios.post( - 'https://api.zarinpal.com/pg/v4/payment/request.json', - { - merchant_id: process.env.ZARINPAL_MERCHANT_ID, - amount: amountInRials, - description: `خرید دوره ${course.cuorse_name}`, - callback_url: callbackUrl, - metadata: { - user_id: userId, - course_id: courseId, - price: finalPrice, - tax_rate: taxRate - } - }, - { - headers: { 'Content-Type': 'application/json' }, - timeout: 90000 - } - ) - - const result = zarinpalRes.data - - // ========== 8. بررسی پاسخ زرینپال ========== - if (result.data && result.data.code === 100) { - // ذخیره اطلاعات پرداخت در حالت pending — walletAmountUsed فقط یادداشت میشه، - // کسرِ واقعی توی verify (بعد از تاییدِ قطعیِ پرداخت) انجام میشه - await AcademyPaymentModel.create({ - price: finalPrice, - discountAmount: priceNum - discountedPrice, - taxAmount: finalTaxPrice, - taxRate, - course_name: course.cuorse_name, - course_id: course._id, - academy_id: course.academyId, - user_id: userId, - payment_authority: result.data.authority, - walletAmountUsed: walletAmount, - status: 'pending' - }) - - return res.status(200).json({ - success: true, - authority: result.data.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.data.authority}` - }) - } - - // خطای زرینپال - console.error('ZarinPal error:', result) - return res.status(422).json({ - error: true, - message: result.errors?.message || 'خطا در ارتباط با درگاه پرداخت', - details: result.errors - }) - } catch (error) { - console.error( - 'Error in coursePayment:', - error.response?.data || error.message - ) - res.status(500).json({ - error: true, - message: 'خطا در پردازش پرداخت', - details: error.response?.data || error.message - }) - } -} - -const CoursePaymentCallback = async (req, res) => { - try { - const { Authority, Status, courseId, userId } = req.query - - // ========== 1. بررسی وضعیت پرداخت ========== - if (Status !== 'OK') { - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&message=payment_canceled` - ) - } - - // ========== 2. پیدا کردن دوره ========== - const course = await CourseModel.findById(courseId) - if (!course) { - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&message=course_not_found` - ) - } - - // ========== 3. پیدا کردن رکورد پرداخت ========== - const paymentRecord = await AcademyPaymentModel.findOne({ - payment_authority: Authority, - course_id: courseId, - user_id: userId - }) - - if (!paymentRecord) { - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&message=payment_not_found` - ) - } - - // ========== 4. محاسبه مبلغ — فقط سهمِ درگاه (walletAmountUsed تا همینجا فقط - // یادداشت شده، کیف پول دستنخورده مونده — عین shopOrderPaymentController.js) ========== - const walletAmount = paymentRecord.walletAmountUsed || 0 - const gatewayAmount = paymentRecord.price - walletAmount - const amountInRials = Math.round(gatewayAmount * 10) - - // ========== 5. تایید پرداخت با زرینپال ========== - const verify = await axios.post( - 'https://api.zarinpal.com/pg/v4/payment/verify.json', - { - merchant_id: process.env.ZARINPAL_MERCHANT_ID, - authority: Authority, - amount: amountInRials - }, - { - headers: { 'Content-Type': 'application/json' }, - timeout: 10000 - } - ) - - const data = verify.data.data - - // ========== 6. بررسی نتیجه تایید ========== - if (data.code === 100) { - // درگاه فقط بخشِ باقیمونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ - // کیف پول هم واقعاً کسر میشه - await debitWalletForPurchase(userId, walletAmount, { - referenceType: 'payment', - referenceId: paymentRecord._id, - description: `خرید پکیج آموزشی «${paymentRecord.course_name}» از کیف پول` - }) - - // بهروزرسانی رکورد پرداخت - await AcademyPaymentModel.findByIdAndUpdate(paymentRecord._id, { - status: 'success', - payment_ref_id: data.ref_id - }) - - // ثبت خرید کاربر - await IsPaymentCourse.create({ - user_id: userId, - course_id: course._id, - price_paid: paymentRecord.price, - purchased_at: new Date() - }) - - const courseOwnerId = await resolveCourseOwnerId(course) - await createCoursePurchaseNotification({ - ownerId: courseOwnerId, - buyerId: userId, - courseId: course._id - }) - - if (courseOwnerId) { - const sellerNetAmount = Math.round( - paymentRecord.price * (1 - ACADEMY_COMMISSION_RATE) - ) - if (sellerNetAmount > 0) { - await WalletTransactionModel.create({ - user: courseOwnerId, - bucket: 'academy', - type: 'credit', - amount: sellerNetAmount, - referenceType: 'payment', - referenceId: paymentRecord._id, - description: `درآمد فروش پکیج آموزشی «${course.title || ''}» (پس از کسر ۱۵٪ کارمزد)` - }) - } - } - - // هدایت به صفحه موفقیت - return res.redirect( - `${process.env.APP_SITE}/settings/academy/my-courses?payment=success` - ) - } - - // پرداخت ناموفق - await AcademyPaymentModel.findByIdAndUpdate(paymentRecord._id, { - status: 'failed' - }) - - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&code=${data.code}` - ) - } catch (err) { - console.error('Error in CoursePaymentCallback:', err) - return res.redirect( - `${process.env.APP_SITE}/settings/academy/course?payment=failed&message=verification_error` - ) - } -} -const getUserPurchasedCourses = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const user_id = decodedToken.id - - const { page = 1, limit = 10 } = req.query - - // پیدا کردن تمام رکوردهای پرداخت برای این کاربر - const payments = await IsPaymentCourse.find({ user_id }) - .sort({ createdAt: -1 }) // جدیدترین اول - .skip((page - 1) * limit) - .limit(parseInt(limit)) - - // گرفتن اطلاعات کامل دورهها - const courseIds = payments.map((payment) => payment.course_id) - - const courses = await CourseModel.find({ - _id: { $in: courseIds }, - status: coursePublicStatusFilter() // فقط دورههای تایید شده - }) - - // محاسبه تعداد کل - const total = await IsPaymentCourse.countDocuments({ user_id }) - - res.status(200).json({ - success: true, - data: { - courses, - pagination: { - total, - page: parseInt(page), - pages: Math.ceil(total / limit), - limit: parseInt(limit) - } - } - }) - } catch (error) { - console.error('Error in getUserPurchasedCourses:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت دورههای خریداری شده', - error: error.message - }) - } -} - -// API با populate (روش بهتر) -const getUserPurchasedCoursesWithPopulate = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const user_id = decodedToken.id - - const { page = 1, limit = 10 } = req.query - - const options = { - page: parseInt(page), - limit: parseInt(limit), - sort: { createdAt: -1 }, - populate: { - path: 'course_id', - model: 'Course', - match: { status: coursePublicStatusFilter() } - } - } - - const result = await IsPaymentCourse.paginate( - { user_id }, - options - ) - - // فیلتر کردن دورههایی که populate شدهاند و وجود دارند - const validCourses = result.docs - .filter((doc) => doc.course_id !== null) - .map((doc) => doc.course_id) - - res.status(200).json({ - success: true, - data: { - courses: validCourses, - pagination: { - total: result.totalDocs, - page: result.page, - totalPages: result.totalPages, - limit: result.limit, - hasNextPage: result.hasNextPage, - hasPrevPage: result.hasPrevPage - } - } - }) - } catch (error) { - console.error('Error:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت دورههای خریداری شده' - }) - } -} - -const getAllAcademyPayments = async (req, res) => { - try { - // 1. بررسی توکن - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { - return res.status(401).json({ - success: false, - message: 'دسترسی غیرمجاز' - }) - } - - // 2. verify توکن - let decodedToken - try { - decodedToken = jwt.verify(token, process.env.APP_SECRET) - } catch (jwtError) { - return res.status(401).json({ - success: false, - message: 'توکن نامعتبر است' - }) - } - - const userId = decodedToken.id - - // 3. پیدا کردن آکادمی - const academy = await AcademyModel.findOne({ userId }) - - if (!academy) { - return res.status(404).json({ - success: false, - message: 'آکادمی برای این کاربر یافت نشد' - }) - } - - const academy_id = academy._id - const { page = 1, limit = 10, status } = req.query - - // 4. ساخت فیلتر - const filter = { academy_id } - - if ( - status && - ['pending', 'success', 'failed', 'settled'].includes(status) - ) { - filter.status = status - } - - // 5. تنظیمات صفحهبندی - const pageNum = parseInt(page) - const limitNum = parseInt(limit) - const skip = (pageNum - 1) * limitNum - - const options = { - page: pageNum, - limit: limitNum, - sort: { createdAt: -1 }, - populate: [ - { - path: 'course_id', - model: 'Course', - select: 'cuorse_name price course_image category teacher_name' - }, - { - path: 'user_id', - model: 'User', - select: 'user_name profile_image email phone' - } - ] - } - - // 6. دریافت پرداختها - const result = await AcademyPaymentModel.paginate(filter, options) - - // 7. محاسبه آمار دقیقتر - let totalAmount = 0 - let pendingCount = 0 - let acceptedCount = 0 - - if (result.docs && Array.isArray(result.docs)) { - totalAmount = result.docs.reduce((sum, p) => { - const price = parseInt(p.taxAmount) || 0 - return sum + price - }, 0) - - pendingCount = result.docs.filter((p) => p.status === 'pending').length - acceptedCount = result.docs.filter((p) => p.status === 'accept').length - } - - // 8. پاسخ نهایی - res.status(200).json({ - success: true, - data: { - academy: { - _id: academy._id, - academy_name: academy.academy_name, - academy_image: academy.academy_image, - bio: academy.bio - }, - payments: result.docs || [], - pagination: { - total: result.totalDocs || 0, - page: result.page || pageNum, - totalPages: result.totalPages || 1, - limit: result.limit || limitNum, - hasNextPage: result.hasNextPage || false, - hasPrevPage: result.hasPrevPage || false - }, - stats: { - totalAmount, - pendingCount, - acceptedCount - } - } - }) - } catch (error) { - console.error('Error in getAllAcademyPayments:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت لیست پرداختها', - error: process.env.NODE_ENV === 'development' ? error.message : undefined - }) - } -} - -const ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null } - -/** Revenue-over-time + best-selling courses for the seller's Academy — backs the "Academy" analytics tab. */ -const getAcademyAnalytics = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) return res.status(401).json({ success: false, message: 'دسترسی غیرمجاز' }) - - let decodedToken - try { - decodedToken = jwt.verify(token, process.env.APP_SECRET) - } catch { - return res.status(401).json({ success: false, message: 'توکن نامعتبر است' }) - } - - const academy = await AcademyModel.findOne({ userId: decodedToken.id }) - if (!academy) { - return res.json({ - range: req.query.range || '30d', - series: [], - totals: { totalRevenue: 0, totalCount: 0 }, - topItems: [], - rating: { average: academy?.rate || 0, count: academy?.number_of_rate || 0 } - }) - } - - const range = ANALYTICS_RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '30d' - const days = ANALYTICS_RANGE_DAYS[range] - const since = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : new Date(0) - - const match = { - academy_id: academy._id, - status: { $in: ['success', 'settled'] }, - createdAt: { $gte: since } - } - - const [series, totalsAgg, bestCoursesAgg] = await Promise.all([ - AcademyPaymentModel.aggregate([ - { $match: match }, - { - $group: { - _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, - revenue: { $sum: '$price' }, - count: { $sum: 1 } - } - }, - { $sort: { _id: 1 } } - ]), - AcademyPaymentModel.aggregate([ - { $match: match }, - { $group: { _id: null, totalRevenue: { $sum: '$price' }, totalCount: { $sum: 1 } } } - ]), - AcademyPaymentModel.aggregate([ - { $match: match }, - { $group: { _id: '$course_id', revenue: { $sum: '$price' }, count: { $sum: 1 } } }, - { $sort: { revenue: -1 } }, - { $limit: 5 } - ]) - ]) - - const courseIds = bestCoursesAgg.map((row) => row._id).filter(Boolean) - const courses = courseIds.length - ? await CourseModel.find({ _id: { $in: courseIds } }).select('cuorse_name course_image') - : [] - const courseMap = new Map(courses.map((c) => [String(c._id), c])) - - const topItems = bestCoursesAgg - .filter((row) => row._id && courseMap.has(String(row._id))) - .map((row) => { - const course = courseMap.get(String(row._id)) - return { - id: String(row._id), - title: course.cuorse_name, - image: course.course_image || null, - metric: row.revenue - } - }) - - return res.json({ - range, - series, - totals: totalsAgg[0] || { totalRevenue: 0, totalCount: 0 }, - topItems, - rating: { average: academy.rate || 0, count: academy.number_of_rate || 0 } - }) - } catch (error) { - console.error('Error in getAcademyAnalytics:', error) - res.status(500).json({ success: false, message: 'خطا در دریافت آنالیتیکس آموزشگاه' }) - } -} - -const checkCoursePurchase = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - const { courseId } = req.params - - // بررسی وجود کاربر و دوره - if (!userId || !courseId) { - return res.status(400).json({ - success: false, - message: 'userId و courseId الزامی هستند' - }) - } - - // جستجو در دیتابیس - const purchase = await IsPaymentCourse.findOne({ - user_id: userId, - course_id: courseId - }) - - // نتیجه - if (purchase) { - return res.status(200).json({ - success: true, - isPurchased: true, - message: 'این دوره قبلاً خریداری شده است', - purchaseData: purchase - }) - } else { - return res.status(200).json({ - success: true, - isPurchased: false, - message: 'این دوره خریداری نشده است' - }) - } - } catch (error) { - console.error('Error in checkCoursePurchase:', error) - res.status(500).json({ - success: false, - message: 'خطا در بررسی خرید دوره', - error: error.message - }) - } -} - -const getAcademyCourse = async (req, res, next) => { - try { - const { Id } = req.params - - // گرفتن پارامترهای صفحهبندی - const page = parseInt(req.query.page) || 1 - const limit = parseInt(req.query.limit) || 10 - const skip = (page - 1) * limit - - const academy = await AcademyModel.findById(Id) - if (!academy) { - return res.status(404).json({ - success: false, - message: 'آکادمی پیدا نشد' - }) - } - - // ✅ شرط جستجو - فقط دورههای تایید شده - const query = { - academyId: academy._id, - status: coursePublicStatusFilter() - } - - // دریافت تعداد کل دورههای تایید شده - const totalCourses = await CourseModel.countDocuments(query) - - // دریافت دورههای تایید شده با صفحهبندی - const courses = await CourseModel.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - - // ✅ اگر دورهای وجود نداشت، خطا نده، بلکه آرایه خالی برگردون - if (!courses || courses.length === 0) { - return res.status(200).json({ - success: true, - message: 'هیچ دوره تایید شدهای یافت نشد', - data: { - courses: [], - pagination: { - currentPage: page, - totalPages: 0, - totalItems: 0, - itemsPerPage: limit, - hasNextPage: false, - hasPrevPage: false, - nextPage: null, - prevPage: null - } - } - }) - } - - // محاسبات صفحهبندی - const totalPages = Math.ceil(totalCourses / limit) - const hasNextPage = page < totalPages - const hasPrevPage = page > 1 - - res.status(200).json({ - success: true, - message: 'لیست پکیجها با موفقیت دریافت شد', - data: { - courses, - pagination: { - currentPage: page, - totalPages, - totalItems: totalCourses, - itemsPerPage: limit, - hasNextPage, - hasPrevPage, - nextPage: hasNextPage ? page + 1 : null, - prevPage: hasPrevPage ? page - 1 : null - } - } - }) - } catch (error) { - console.error('Error:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت پکیجها', - error: error.message - }) - } -} - -// ==================== مدیریت پرداختها ==================== -const getAllPayments = async (req, res) => { - try { - const { page = 1, limit = 20, status, startDate, endDate } = req.query - const skip = (parseInt(page) - 1) * parseInt(limit) - - const query = {} - if (status) query.status = status - if (startDate && endDate) { - query.createdAt = { - $gte: new Date(startDate), - $lte: new Date(endDate) - } - } - - const payments = await AcademyPaymentModel.find(query) - .populate('user_id', 'user_name profile_image email') - .populate('course_id', 'cuorse_name price course_image') - .populate('academy_id', 'academy_name academy_image sheba') - .sort({ createdAt: -1 }) - .skip(skip) - .limit(parseInt(limit)) - - const total = await AcademyPaymentModel.countDocuments(query) - - // آمار کلی پرداختها - const stats = await AcademyPaymentModel.aggregate([ - { $match: query }, - { - $group: { - _id: null, - totalAmount: { $sum: { $toDouble: '$price' } }, - totalCount: { $sum: 1 }, - pendingCount: { - $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } - }, - settledCount: { - $sum: { $cond: [{ $eq: ['$status', 'settled'] }, 1, 0] } - }, - successCount: { - $sum: { $cond: [{ $eq: ['$status', 'success'] }, 1, 0] } - } - } - } - ]) - - res.status(200).json({ - success: true, - data: { - payments, - stats: stats[0] || { - totalAmount: 0, - totalCount: 0, - pendingCount: 0, - settledCount: 0, - successCount: 0 - }, - pagination: { - currentPage: parseInt(page), - totalPages: Math.ceil(total / parseInt(limit)), - totalItems: total, - itemsPerPage: parseInt(limit) - } - } - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در دریافت پرداختها' }) - } -} - -const updatePaymentStatus = async (req, res) => { - try { - const { paymentId, status } = req.body - - const payment = await AcademyPaymentModel.findByIdAndUpdate( - paymentId, - { status, updatedAt: new Date() }, - { new: true } - ) - - if (!payment) { - return res - .status(404) - .json({ success: false, message: 'پرداخت یافت نشد' }) - } - - res.status(200).json({ - success: true, - message: 'وضعیت پرداخت با موفقیت تغییر کرد', - data: payment - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در تغییر وضعیت پرداخت' }) - } -} - -// ==================== مدیریت دورهها ==================== -const getAllCourses = async (req, res) => { - try { - const { page = 1, limit = 20, status, type, category, search } = req.query - const skip = (parseInt(page) - 1) * parseInt(limit) - - const query = {} - if (status) query.status = status - if (type) query.type = type - if (category) query.category = category - if (search) { - query.$or = [ - { cuorse_name: { $regex: search, $options: 'i' } }, - { caption: { $regex: search, $options: 'i' } }, - { teacher_name: { $regex: search, $options: 'i' } } - ] - } - - const courses = await CourseModel.find(query) - .populate('academyId', 'academy_name academy_image') - .populate('user_id', 'user_name profile_image') - .sort({ createdAt: -1 }) - .skip(skip) - .limit(parseInt(limit)) - - const total = await CourseModel.countDocuments(query) - - // آمار دورهها - const stats = await CourseModel.aggregate([ - { $match: query }, - { - $group: { - _id: null, - totalCourses: { $sum: 1 }, - totalPrice: { $sum: { $toDouble: '$price' } }, - normalCount: { - $sum: { $cond: [{ $eq: ['$type', 'normal'] }, 1, 0] } - }, - proCount: { $sum: { $cond: [{ $eq: ['$type', 'pro'] }, 1, 0] } }, - legendCount: { - $sum: { $cond: [{ $eq: ['$type', 'legend'] }, 1, 0] } - }, - pendingCount: { - $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } - }, - acceptCount: { - $sum: { $cond: [{ $eq: ['$status', 'accept'] }, 1, 0] } - } - } - } - ]) - - res.status(200).json({ - success: true, - data: { - courses, - stats: stats[0] || {}, - pagination: { - currentPage: parseInt(page), - totalPages: Math.ceil(total / parseInt(limit)), - totalItems: total, - itemsPerPage: parseInt(limit) - } - } - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در دریافت دورهها' }) - } -} - -const updateCourseStatus = async (req, res) => { - try { - const { courseId, status, type } = req.body - - console.log(courseId, status, type) - - const course = await CourseModel.findById(courseId) - - if (!course) { - return res.status(404).json({ success: false, message: 'دوره یافت نشد' }) - } - - if (status) course.status = status - if (type) course.type = type - await course.save() - - res.status(200).json({ - success: true, - message: 'دوره با موفقیت بهروزرسانی شد', - data: course, - courseId, - status, - type - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در بهروزرسانی دوره' }) - } -} - -const admindeleteCourse = async (req, res) => { - try { - const { courseId } = req.params - - const course = await CourseModel.findByIdAndDelete(courseId) - - if (!course) { - return res.status(404).json({ success: false, message: 'دوره یافت نشد' }) - } - - // حذف تمام پرداختهای مرتبط با این دوره - await AcademyPaymentModel.deleteMany({ course_id: courseId }) - - res.status(200).json({ - success: true, - message: 'دوره با موفقیت حذف شد' - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در حذف دوره' }) - } -} - -// ==================== مدیریت دورههای خریداری شده ==================== -const getAllPurchasedCourses = async (req, res) => { - try { - const { page = 1, limit = 20, userId, courseId, status } = req.query - const skip = (parseInt(page) - 1) * parseInt(limit) - - const query = {} - if (userId) query.user_id = userId - if (courseId) query.course_id = courseId - if (status) query.status = status - - const purchases = await IsPaymentCourse.find(query) - .populate('user_id', 'user_name profile_image email phone') - .populate('course_id', 'cuorse_name price course_image teacher_name') - .sort({ createdAt: -1 }) - .skip(skip) - .limit(parseInt(limit)) - - const total = await IsPaymentCourse.countDocuments(query) - - // آمار خریدها - const stats = await IsPaymentCourse.aggregate([ - { $match: query }, - { - $group: { - _id: null, - totalPurchases: { $sum: 1 }, - uniqueUsers: { $addToSet: '$user_id' } - } - } - ]) - - res.status(200).json({ - success: true, - data: { - purchases, - stats: { - totalPurchases: stats[0]?.totalPurchases || 0, - uniqueUsers: stats[0]?.uniqueUsers?.length || 0 - }, - pagination: { - currentPage: parseInt(page), - totalPages: Math.ceil(total / parseInt(limit)), - totalItems: total, - itemsPerPage: parseInt(limit) - } - } - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در دریافت دورههای خریداری شده' }) - } -} - -// ==================== مدیریت آکادمیها ==================== -const getAllAcademies = async (req, res) => { - try { - const { page = 1, limit = 20, search } = req.query - const skip = (parseInt(page) - 1) * parseInt(limit) - - const query = {} - if (search) { - query.academy_name = { $regex: search, $options: 'i' } - } - - const academies = await AcademyModel.find(query) - .populate('userId', 'user_name profile_image email') - .sort({ createdAt: -1 }) - .skip(skip) - .limit(parseInt(limit)) - - const total = await AcademyModel.countDocuments(query) - - // آمار آکادمیها - const stats = await AcademyModel.aggregate([ - { - $group: { - _id: null, - totalAcademies: { $sum: 1 }, - totalRate: { $sum: '$rate' }, - avgRate: { $avg: '$rate' } - } - } - ]) - - res.status(200).json({ - success: true, - data: { - academies, - stats: stats[0] || {}, - pagination: { - currentPage: parseInt(page), - totalPages: Math.ceil(total / parseInt(limit)), - totalItems: total, - itemsPerPage: parseInt(limit) - } - } - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در دریافت آکادمیها' }) - } -} - -const updateAcademyStatus = async (req, res) => { - try { - const { academyId, status, rate, sheba } = req.body - - const updateData = {} - if (status) updateData.status = status - if (rate) updateData.rate = rate - if (sheba) updateData.sheba = sheba - - const academy = await AcademyModel.findByIdAndUpdate( - academyId, - updateData, - { new: true } - ) - - if (!academy) { - return res - .status(404) - .json({ success: false, message: 'آکادمی یافت نشد' }) - } - - res.status(200).json({ - success: true, - message: 'آکادمی با موفقیت بهروزرسانی شد', - data: academy - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در بهروزرسانی آکادمی' }) - } -} - -// ==================== مدیریت تنظیمات (تکس و قیمت پرو) ==================== -const getSettings = async (req, res) => { - try { - let settings = await SettingModel.findOne() - - if (!settings) { - settings = await SettingModel.create({ - taxRate: 9, - proPrice: 99000, - legendPrice: 199000, - minWithdrawAmount: 50000, - maxWithdrawAmount: 10000000 - }) - } - - res.status(200).json({ - success: true, - data: settings - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در دریافت تنظیمات' }) - } -} - -const updateSettings = async (req, res) => { - try { - const { - taxRate, - proPrice, - legendPrice, - minWithdrawAmount, - maxWithdrawAmount - } = req.body - - const settings = await SettingModel.findOneAndUpdate( - {}, - { - taxRate, - proPrice, - legendPrice, - minWithdrawAmount, - maxWithdrawAmount, - updatedAt: new Date() - }, - { new: true, upsert: true } - ) - - res.status(200).json({ - success: true, - message: 'تنظیمات با موفقیت بهروزرسانی شد', - data: settings - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در بهروزرسانی تنظیمات' }) - } -} - -const getCoursePrices = async (req, res) => { - try { - const proPrice = await CoursePaymentModel.findOne({ cuorse_type: 'pro' }) - - const legendPrice = await CoursePaymentModel.findOne({ - cuorse_type: 'legend' - }) - - const normalPrice = await CoursePaymentModel.findOne({ - cuorse_type: 'normal' - }) - - res.status(200).json({ - success: true, - data: { - pro: proPrice - ? { price: proPrice.price, _id: proPrice._id } - : { price: '60000' }, - legend: legendPrice - ? { price: legendPrice.price, _id: legendPrice._id } - : { price: '199000' }, - normal: normalPrice - ? { price: normalPrice.price, _id: normalPrice._id } - : { price: '0' } - } - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در دریافت قیمت دورهها' }) - } -} - -const updateCoursePrice = async (req, res) => { - try { - const { courseType, price } = req.body - - if (!courseType || !price) { - return res - .status(400) - .json({ success: false, message: 'نوع دوره و قیمت الزامی است' }) - } - - let coursePayment = await CoursePaymentModel.findOne({ - cuorse_type: courseType - }) - - if (coursePayment) { - coursePayment.price = price.toString() - await coursePayment.save() - } else { - coursePayment = new CoursePaymentModel({ - price: price.toString(), - cuorse_type: courseType - }) - await coursePayment.save() - } - - res.status(200).json({ - success: true, - message: `قیمت دوره ${ - courseType === 'pro' - ? 'پرو' - : courseType === 'legend' - ? 'لجند' - : 'معمولی' - } با موفقیت بهروزرسانی شد`, - data: coursePayment - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در بهروزرسانی قیمت دوره' }) - } -} - -const getTax = async (req, res) => { - try { - let tax = await Tax.findOne({ type: 'course' }) - - if (!tax) { - tax = new Tax({ tax: 8, type: 'course' }) - await tax.save() - } - - res.status(200).json({ - success: true, - data: { - tax: tax.tax, - _id: tax._id, - type: tax.type - } - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در دریافت مالیات' }) - } -} - -const updateTax = async (req, res) => { - try { - const { tax } = req.body - - if (tax === undefined || tax === null) { - return res - .status(400) - .json({ success: false, message: 'مقدار مالیات الزامی است' }) - } - - let taxRecord = await Tax.findOne({ type: 'course' }) - - if (taxRecord) { - taxRecord.tax = tax - await taxRecord.save() - } else { - taxRecord = new Tax({ tax, type: 'course' }) - await taxRecord.save() - } - - res.status(200).json({ - success: true, - message: 'مالیات با موفقیت بهروزرسانی شد', - data: taxRecord - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در بهروزرسانی مالیات' }) - } -} - -const getAllSettings = async (req, res) => { - try { - const proPrice = await CoursePaymentModel.findOne({ cuorse_type: 'pro' }) - const legendPrice = await CoursePaymentModel.findOne({ - cuorse_type: 'legend' - }) - - let tax = await Tax.findOne({ type: 'course' }) - if (!tax) { - tax = new Tax({ tax: 8, type: 'course' }) - await tax.save() - } - - res.status(200).json({ - success: true, - data: { - tax: tax.tax, - prices: { - pro: proPrice ? proPrice.price : '60000', - legend: legendPrice ? legendPrice.price : '199000' - } - } - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در دریافت تنظیمات' }) - } -} - -const updateAllSettings = async (req, res) => { - try { - const { tax, proPrice, legendPrice } = req.body - - if (tax !== undefined) { - let taxRecord = await Tax.findOne({ type: 'course' }) - if (taxRecord) { - taxRecord.tax = tax - await taxRecord.save() - } else { - taxRecord = new Tax({ tax, type: 'course' }) - await taxRecord.save() - } - } - - if (proPrice !== undefined) { - let proRecord = await CoursePaymentModel.findOne({ cuorse_type: 'pro' }) - if (proRecord) { - proRecord.price = proPrice.toString() - await proRecord.save() - } else { - proRecord = new CoursePaymentModel({ - price: proPrice.toString(), - cuorse_type: 'pro' - }) - await proRecord.save() - } - } - - if (legendPrice !== undefined) { - let legendRecord = await CoursePaymentModel.findOne({ - cuorse_type: 'legend' - }) - if (legendRecord) { - legendRecord.price = legendPrice.toString() - await legendRecord.save() - } else { - legendRecord = new CoursePaymentModel({ - price: legendPrice.toString(), - cuorse_type: 'legend' - }) - await legendRecord.save() - } - } - - res.status(200).json({ - success: true, - message: 'تنظیمات با موفقیت بهروزرسانی شد' - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در بهروزرسانی تنظیمات' }) - } -} - -const getPaymentStats = async (req, res) => { - try { - const stats = await AcademyPaymentModel.aggregate([ - { - $group: { - _id: null, - totalAmount: { $sum: { $toDouble: '$price' } }, - totalCount: { $sum: 1 }, - pendingCount: { - $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } - }, - settledCount: { - $sum: { $cond: [{ $eq: ['$status', 'settled'] }, 1, 0] } - }, - successCount: { - $sum: { $cond: [{ $eq: ['$status', 'success'] }, 1, 0] } - } - } - } - ]) - - const result = stats[0] || { - totalAmount: 0, - totalCount: 0, - pendingCount: 0, - settledCount: 0, - successCount: 0 - } - - result.averageAmount = - result.totalCount > 0 - ? Math.round(result.totalAmount / result.totalCount) - : 0 - - res.status(200).json({ - success: true, - data: result - }) - } catch (error) { - console.error('Error in getPaymentStats:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت آمار پرداختها', - error: process.env.NODE_ENV === 'development' ? error.message : undefined - }) - } -} - -const getCourseById = async (req, res) => { - try { - const { id } = req.params - const course = await CourseModel.findById(id) - .populate('academyId', 'academy_name academy_image') - .populate('user_id', 'user_name profile_image') - - if (!course) { - return res.status(404).json({ success: false, message: 'دوره یافت نشد' }) - } - - res.status(200).json({ - success: true, - data: course - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در دریافت دوره' }) - } -} - -const createCourse = async (req, res) => { - try { - const courseData = req.body - const newCourse = new CourseModel(courseData) - await newCourse.save() - - res.status(201).json({ - success: true, - message: 'دوره با موفقیت ایجاد شد', - data: newCourse - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در ایجاد دوره' }) - } -} - -const getCourseStats = async (req, res) => { - try { - const stats = await CourseModel.aggregate([ - { - $group: { - _id: null, - totalCourses: { $sum: 1 }, - normalCount: { - $sum: { $cond: [{ $eq: ['$type', 'normal'] }, 1, 0] } - }, - proCount: { $sum: { $cond: [{ $eq: ['$type', 'pro'] }, 1, 0] } }, - legendCount: { - $sum: { $cond: [{ $eq: ['$type', 'legend'] }, 1, 0] } - }, - pendingCount: { - $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } - }, - acceptCount: { - $sum: { $cond: [{ $eq: ['$status', 'accept'] }, 1, 0] } - }, - rejectCount: { - $sum: { $cond: [{ $eq: ['$status', 'reject'] }, 1, 0] } - } - } - } - ]) - - res.status(200).json({ - success: true, - data: stats[0] || {} - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در دریافت آمار دورهها' }) - } -} - -const getPurchasedStats = async (req, res) => { - try { - const stats = await IsPaymentCourse.aggregate([ - { - $group: { - _id: null, - totalPurchases: { $sum: 1 }, - uniqueUsers: { $addToSet: '$user_id' } - } - } - ]) - - res.status(200).json({ - success: true, - data: { - totalPurchases: stats[0]?.totalPurchases || 0, - uniqueUsers: stats[0]?.uniqueUsers?.length || 0 - } - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در دریافت آمار خریدها' }) - } -} - -const getAcademyById = async (req, res) => { - try { - const { id } = req.params - const academy = await AcademyModel.findById(id).populate( - 'userId', - 'user_name profile_image email' - ) - - if (!academy) { - return res - .status(404) - .json({ success: false, message: 'آکادمی یافت نشد' }) - } - - res.status(200).json({ - success: true, - data: academy - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در دریافت آکادمی' }) - } -} - -const createAcademy = async (req, res) => { - try { - const academyData = req.body - const newAcademy = new AcademyModel(academyData) - await newAcademy.save() - - res.status(201).json({ - success: true, - message: 'آکادمی با موفقیت ایجاد شد', - data: newAcademy - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در ایجاد آکادمی' }) - } -} - -const updateAcademy = async (req, res) => { - try { - const { id } = req.params - const updateData = req.body - - const academy = await AcademyModel.findByIdAndUpdate(id, updateData, { - new: true - }) - - if (!academy) { - return res - .status(404) - .json({ success: false, message: 'آکادمی یافت نشد' }) - } - - res.status(200).json({ - success: true, - message: 'آکادمی با موفقیت بهروزرسانی شد', - data: academy - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در بهروزرسانی آکادمی' }) - } -} - -const deleteAcademy = async (req, res) => { - try { - const { id } = req.params - const academy = await AcademyModel.findByIdAndDelete(id) - - if (!academy) { - return res - .status(404) - .json({ success: false, message: 'آکادمی یافت نشد' }) - } - - await CourseModel.deleteMany({ academyId: id }) - - res.status(200).json({ - success: true, - message: 'آکادمی با موفقیت حذف شد' - }) - } catch (error) { - console.error(error) - res.status(500).json({ success: false, message: 'خطا در حذف آکادمی' }) - } -} - -const getAcademyStats = async (req, res) => { - try { - const stats = await AcademyModel.aggregate([ - { - $group: { - _id: null, - totalAcademies: { $sum: 1 }, - totalRate: { $sum: '$rate' }, - avgRate: { $avg: '$rate' } - } - } - ]) - - res.status(200).json({ - success: true, - data: stats[0] || { - totalAcademies: 0, - totalRate: 0, - avgRate: 0 - } - }) - } catch (error) { - console.error(error) - res - .status(500) - .json({ success: false, message: 'خطا در دریافت آمار آکادمیها' }) - } -} -const deleteCourseVideo = 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 userId = decodedToken.id - - const { courseContentId, courseId } = req.body - const courseContent = await AcademyContentModel.findById(courseContentId) - - if (!courseContent) { - return res.status(404).json({ - error: true, - message: 'ویدعو یافت نشد' - }) - } - - courseContent.status = 'reject' - - await courseContent.save() - - const course = await CourseModel.findById(courseId) - if (course) { - course.number_of_course_content = `${ - Number(course.number_of_course_content) - 1 - }` - await course.save() - } - - res.status(200).json({ - success: true, - message: 'ویدیو با موفقیت پاک شد' - }) - } catch (error) { - console.error('Error:', error) - res.status(500).json({ - success: false, - message: 'خطا در حذف ویدیو', - error: error.message - }) - } -} - -const getChunkDir = (uploadKey) => { - return path.join(process.cwd(), 'storage', 'temp-chunks', uploadKey) -} - -setInterval(async () => { - const tempDir = path.join(process.cwd(), 'storage', 'temp-chunks') - if (await fs.pathExists(tempDir)) { - const dirs = await fs.readdir(tempDir) - const now = Date.now() - for (const dir of dirs) { - const dirPath = path.join(tempDir, dir) - const stat = await fs.stat(dirPath) - if (now - stat.mtimeMs > 30 * 60 * 1000) { - // 30 دقیقه - await fs.remove(dirPath) - console.log(`Cleaned up old upload: ${dir}`) - } - } - } -}, 60 * 60 * 1000) - -const creatCourseVideoChunk = async (req, res, next) => { - console.log('=== Chunk Video Upload ===') - console.log('Chunk index:', req.body.chunkIndex) - - 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 userId = decodedToken.id - - const { - courseId, - is_free, - file_name, - chunk, - chunkIndex, - totalChunks, - originalFileName, - fileSize, - mime_type - } = req.body - - if (!chunk) { - return res.status(422).json({ - error: true, - message: 'تکه فایل ارسال نشده است' - }) - } - - if (!courseId) { - return res.status(422).json({ - error: true, - message: 'شناسه دوره الزامی است' - }) - } - - const base64Data = chunk.split(',')[1] || chunk - const buffer = Buffer.from(base64Data, 'base64') - - const uploadKey = `${courseId}_${originalFileName}` - const chunkDir = getChunkDir(uploadKey) - - await fs.ensureDir(chunkDir) - - const metaPath = path.join(chunkDir, 'meta.json') - if (chunkIndex === '0' || chunkIndex === 0) { - const metaData = { - totalChunks: parseInt(totalChunks), - file_name, - originalFileName, - fileSize, - mime_type, - courseId, - is_free, - createdAt: Date.now() - } - await fs.writeJson(metaPath, metaData) - } - - if (!(await fs.pathExists(metaPath))) { - return res.status(404).json({ - error: true, - message: 'جلسه آپلود یافت نشد' - }) - } - - const chunkPath = path.join(chunkDir, `chunk_${chunkIndex}`) - await fs.writeFile(chunkPath, buffer) - - console.log( - `✅ Chunk ${parseInt(chunkIndex) + 1}/${totalChunks} saved to disk (${ - buffer.length - } bytes)` - ) - - res.status(200).json({ - success: true, - message: `تکه ${ - parseInt(chunkIndex) + 1 - } از ${totalChunks} با موفقیت دریافت شد`, - chunkIndex, - totalChunks - }) - } catch (error) { - console.error('Error in creatCourseVideoChunk:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت تکه ویدیو', - error: error.message - }) - } -} - -const mergeVideoChunks = async (req, res, next) => { - console.log('=== Merge Video Chunks ===') - - 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 userId = decodedToken.id - - const { courseId, originalFileName, totalChunks } = req.body - - const uploadKey = `${courseId}_${originalFileName}` - const chunkDir = getChunkDir(uploadKey) - const metaPath = path.join(chunkDir, 'meta.json') - - if (!(await fs.pathExists(chunkDir)) || !(await fs.pathExists(metaPath))) { - return res.status(404).json({ - error: true, - message: 'دادههای آپلود یافت نشد' - }) - } - - const metaData = await fs.readJson(metaPath) - - const existingChunks = await fs.readdir(chunkDir) - const chunkFiles = existingChunks.filter((f) => f.startsWith('chunk_')) - - if (chunkFiles.length !== parseInt(totalChunks)) { - return res.status(422).json({ - error: true, - message: `تعداد تکهها کامل نیست. دریافت شده: ${chunkFiles.length}، مورد نیاز: ${totalChunks}` - }) - } - - const chunks = [] - for (let i = 0; i < parseInt(totalChunks); i++) { - const chunkPath = path.join(chunkDir, `chunk_${i}`) - if (!(await fs.pathExists(chunkPath))) { - return res.status(422).json({ - error: true, - message: `تکه ${i + 1} پیدا نشد` - }) - } - const chunkBuffer = await fs.readFile(chunkPath) - chunks.push(chunkBuffer) - } - - const completeBuffer = Buffer.concat(chunks) - console.log('Original file size:', completeBuffer.length, 'bytes') - - const finalVideoDir = path.join( - process.cwd(), - 'storage', - 'courses', - 'videos' - ) - await fs.ensureDir(finalVideoDir) - - let extension = '.mp4' - if (metaData.mime_type === 'video/mp4') extension = '.mp4' - else if (metaData.mime_type === 'video/quicktime') extension = '.mov' - else if (metaData.mime_type === 'video/x-msvideo') extension = '.avi' - else if (metaData.mime_type === 'video/webm') extension = '.webm' - - const safeFileName = `course-${courseId}-${Date.now()}${extension}` - const filePath = path.join(finalVideoDir, safeFileName) - - await fs.writeFile(filePath, completeBuffer) - console.log('Original file saved:', filePath) - - const uploaderUser = await UserModel.findById(userId).select('verify_badge').lean() - - const newAcademyContent = new AcademyContentModel({ - course_video: `/storage/courses/videos/${safeFileName}`, - type: 'video', - is_free: metaData.is_free === 'true' || metaData.is_free === true, - courseId, - file_name: metaData.file_name || metaData.originalFileName, - status: isVerifiedBadge(uploaderUser?.verify_badge) ? 'temp_accept' : 'pending' - }) - - await newAcademyContent.save() - - const course = await CourseModel.findById(courseId) - if (course) { - course.number_of_course_content = `${ - Number(course.number_of_course_content) + 1 - }` - await course.save() - } - - await fs.remove(chunkDir) - - res.status(200).json({ - success: true, - message: 'ویدیو با موفقیت ثبت شد (در حال پردازش...)', - data: newAcademyContent - }) - - if (completeBuffer.length > 20 * 1024 * 1024) { - // اجرای غیرهمزمان بدون await - compressVideoInBackground(filePath, safeFileName, newAcademyContent._id) - } - } catch (error) { - console.error('Error in mergeVideoChunks:', error) - res.status(500).json({ - success: false, - message: 'خطا در ترکیب ویدیو', - error: error.message - }) - } -} - -const compressVideoInBackground = async (filePath, fileName, contentId) => { - console.log(`Starting background compression for ${fileName}`) - - const dir = path.dirname(filePath) - const tempPath = path.join(dir, `temp-${fileName}`) - const { exec } = require('child_process') - const util = require('util') - const execPromise = util.promisify(exec) - - try { - await execPromise( - `ffmpeg -i "${filePath}" -c:v libx264 -crf 28 -preset veryfast -c:a aac -b:a 128k -movflags +faststart "${tempPath}" -y` - ) - - const compressedStat = await fs.stat(tempPath) - const originalStat = await fs.stat(filePath) - - if (compressedStat.size < originalStat.size) { - await fs.rename(tempPath, filePath) - console.log( - `✅ Background compression done: ${( - originalStat.size / - 1024 / - 1024 - ).toFixed(2)}MB → ${(compressedStat.size / 1024 / 1024).toFixed(2)}MB` - ) - } else { - await fs.remove(tempPath) - console.log("Compression didn't reduce size, keeping original") - } - } catch (error) { - console.error('Background compression failed:', error.message) - if (await fs.pathExists(tempPath)) { - await fs.remove(tempPath) - } - } -} - -const getUploadStatus = async (req, res) => { - try { - const { courseId, originalFileName } = req.query - const uploadKey = `${courseId}_${originalFileName}` - const chunkDir = getChunkDir(uploadKey) - - if (!(await fs.pathExists(chunkDir))) { - return res.status(200).json({ - success: true, - uploadedChunks: [], - totalChunks: 0 - }) - } - - const files = await fs.readdir(chunkDir) - const chunkFiles = files - .filter((f) => f.startsWith('chunk_')) - .map((f) => parseInt(f.replace('chunk_', ''))) - .sort((a, b) => a - b) - - const metaPath = path.join(chunkDir, 'meta.json') - let totalChunks = 0 - if (await fs.pathExists(metaPath)) { - const metaData = await fs.readJson(metaPath) - totalChunks = metaData.totalChunks - } - - res.status(200).json({ - success: true, - uploadedChunks: chunkFiles, - totalChunks - }) - } catch (error) { - res.status(500).json({ success: false, error: error.message }) - } -} - -// ایجاد slug یکتا -const generateUniqueSlug = async (title, excludeId = null) => { - const slug = title - .replace(/[^\u0600-\u06FF\uFB8A\u067E\u0686\u06AF\u200C\uFB8E\u0698a-zA-Z0-9\s]/g, '') - .trim() - .replace(/\s+/g, '-') - .toLowerCase() - - let uniqueSlug = slug - let counter = 1 - - while (true) { - const query = { slug: uniqueSlug } - if (excludeId) query._id = { $ne: excludeId } - const exists = await AcademyCategoryModel.findOne(query) - if (!exists) break - uniqueSlug = `${slug}-${counter}` - counter++ - } - - return uniqueSlug -} - -// ==================== GET - دریافت لیست دستهبندیها ==================== -const getAll = async (req, res) => { - try { - const { - page = 1, - limit = 20, - sortBy = 'order', - sortOrder = 'asc', - status, - parent, - search, - is_featured, - withChildren = 'false' - } = req.query - - const query = {} - - if (status) query.status = status - if (parent !== undefined) query.parent = parent === 'null' ? null : parent - if (is_featured === 'true') query.is_featured = true - - if (search) { - query.$or = [ - { title: { $regex: search, $options: 'i' } }, - { description: { $regex: search, $options: 'i' } } - ] - } - - const sortOptions = {} - sortOptions[sortBy] = sortOrder === 'asc' ? 1 : -1 - - const options = { - page: parseInt(page), - limit: parseInt(limit), - sort: sortOptions, - populate: [ - { path: 'parent', select: 'title slug' }, - { path: 'created_by', select: 'user_name name' }, - { path: 'updated_by', select: 'user_name name' } - ] - } - - let categories = await AcademyCategoryModel.paginate(query, options) - - // دریافت درخت دستهبندی (اختیاری) - if (withChildren === 'true' && !parent) { - const tree = await AcademyCategoryModel.getTree() - categories = { ...categories, tree } - } - - res.status(200).json({ - success: true, - message: 'لیست دستهبندیها با موفقیت دریافت شد', - data: categories - }) - } catch (error) { - console.error('Error in getAll:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت دستهبندیها', - error: error.message - }) - } -} - -// ==================== GET - دریافت یک دستهبندی ==================== -const getOne = async (req, res) => { - try { - const { id } = req.params - const { includeCourses = 'false' } = req.query - - let query = AcademyCategoryModel.findById(id) - - query = query.populate('parent', 'title slug icon color') - query = query.populate('children', 'title slug icon color course_count order') - query = query.populate('created_by', 'user_name name') - - if (includeCourses === 'true') { - query = query.populate({ - path: 'courses', - select: 'cuorse_name course_image price offer', - options: { limit: 10 } - }) - } - - const category = await query - - if (!category) { - return res.status(404).json({ - success: false, - message: 'دستهبندی یافت نشد' - }) - } - - res.status(200).json({ - success: true, - message: 'دستهبندی با موفقیت دریافت شد', - data: category - }) - } catch (error) { - console.error('Error in getOne:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت دستهبندی', - error: error.message - }) - } -} - -// ==================== GET - دریافت درخت دستهبندی ==================== -const getTree = async (req, res) => { - try { - const tree = await AcademyCategoryModel.getTree() - - res.status(200).json({ - success: true, - message: 'درخت دستهبندی با موفقیت دریافت شد', - data: tree - }) - } catch (error) { - console.error('Error in getTree:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت درخت دستهبندی', - error: error.message - }) - } -} - -// ==================== GET - دریافت توسط اسلاگ ==================== -const getBySlug = async (req, res) => { - try { - const { slug } = req.params - const { page = 1, limit = 20 } = req.query - - const category = await AcademyCategoryModel.getBySlug(slug) - - if (!category) { - return res.status(404).json({ - success: false, - message: 'دستهبندی یافت نشد' - }) - } - - // دریافت دورههای این دسته - const courses = await CourseModel.find({ - category_id: category._id, - status: 'accept' - }) - .sort({ createdAt: -1 }) - .skip((page - 1) * limit) - .limit(parseInt(limit)) - - const totalCourses = await CourseModel.countDocuments({ - category_id: category._id, - status: 'accept' - }) - - res.status(200).json({ - success: true, - message: 'دستهبندی با موفقیت دریافت شد', - data: { - category, - courses: { - data: courses, - pagination: { - page: parseInt(page), - limit: parseInt(limit), - total: totalCourses, - pages: Math.ceil(totalCourses / limit) - } - } - } - }) - } catch (error) { - console.error('Error in getBySlug:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت دستهبندی', - error: error.message - }) - } -} - -// ==================== POST - ایجاد دستهبندی جدید ==================== -const create = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { - title, - description, - icon, - color, - parent, - order, - image, - is_featured, - seo_title, - seo_description - } = req.body - - // اعتبارسنجی - if (!title || title.trim().length < 2) { - return res.status(422).json({ - success: false, - message: 'عنوان دستهبندی باید حداقل ۲ کاراکتر باشد' - }) - } - - // بررسی تکراری نبودن عنوان - const existingTitle = await AcademyCategoryModel.findOne({ title }) - if (existingTitle) { - return res.status(409).json({ - success: false, - message: 'این عنوان قبلاً ثبت شده است' - }) - } - - // تولید slug یکتا - const slug = await generateUniqueSlug(title) - - // بررسی وجود parent - let level = 0 - if (parent) { - const parentCategory = await AcademyCategoryModel.findById(parent) - if (!parentCategory) { - return res.status(404).json({ - success: false, - message: 'دستهبندی والد یافت نشد' - }) - } - level = parentCategory.level + 1 - - if (level > 3) { - return res.status(422).json({ - success: false, - message: 'عمق دستهبندی نمیتواند بیشتر از ۳ سطح باشد' - }) - } - } - - const newCategory = new AcademyCategoryModel({ - title, - slug, - description: description || null, - icon: icon || null, - color: color || '#3B82F6', - parent: parent || null, - level, - order: order || 0, - image: image || null, - is_featured: is_featured || false, - seo_title: seo_title || title, - seo_description: seo_description || description, - created_by: userId, - status: 'active' - }) - - await newCategory.save() - - res.status(201).json({ - success: true, - message: 'دستهبندی با موفقیت ایجاد شد', - data: newCategory - }) - } catch (error) { - console.error('Error in create:', error) - res.status(500).json({ - success: false, - message: 'خطا در ایجاد دستهبندی', - error: error.message - }) - } -} - -// ==================== PUT/PATCH - ویرایش دستهبندی ==================== -const update = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { id } = req.params - const updateData = req.body - - const category = await AcademyCategoryModel.findById(id) - if (!category) { - return res.status(404).json({ - success: false, - message: 'دستهبندی یافت نشد' - }) - } - - // بررسی تکراری نبودن عنوان - if (updateData.title && updateData.title !== category.title) { - const existingTitle = await AcademyCategoryModel.findOne({ - title: updateData.title, - _id: { $ne: id } - }) - if (existingTitle) { - return res.status(409).json({ - success: false, - message: 'این عنوان قبلاً ثبت شده است' - }) - } - - // آپدیت slug - updateData.slug = await generateUniqueSlug(updateData.title, id) - } - - // بررسی parent - if (updateData.parent) { - if (updateData.parent === id) { - return res.status(422).json({ - success: false, - message: 'یک دستهبندی نمیتواند والد خودش باشد' - }) - } - - const parentCategory = await AcademyCategoryModel.findById(updateData.parent) - if (!parentCategory) { - return res.status(404).json({ - success: false, - message: 'دستهبندی والد یافت نشد' - }) - } - - updateData.level = parentCategory.level + 1 - - if (updateData.level > 3) { - return res.status(422).json({ - success: false, - message: 'عمق دستهبندی نمیتواند بیشتر از ۳ سطح باشد' - }) - } - } - - updateData.updated_by = userId - updateData.updatedAt = new Date() - - const updatedCategory = await AcademyCategoryModel.findByIdAndUpdate( - id, - updateData, - { new: true, runValidators: true } - ) - - res.status(200).json({ - success: true, - message: 'دستهبندی با موفقیت بهروزرسانی شد', - data: updatedCategory - }) - } catch (error) { - console.error('Error in update:', error) - res.status(500).json({ - success: false, - message: 'خطا در بهروزرسانی دستهبندی', - error: error.message - }) - } -} - -// ==================== DELETE - حذف (سافت دیلیت) ==================== -const remove = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { id } = req.params - const { permanent = 'false' } = req.query - - const category = await AcademyCategoryModel.findById(id) - if (!category) { - return res.status(404).json({ - success: false, - message: 'دستهبندی یافت نشد' - }) - } - - // بررسی وجود زیردستهها - const hasChildren = await AcademyCategoryModel.exists({ parent: id }) - if (hasChildren) { - return res.status(422).json({ - success: false, - message: 'این دستهبندی دارای زیردسته است. ابتدا زیردستهها را حذف کنید' - }) - } - - // بررسی وجود دوره در این دسته - const hasCourses = await CourseModel.exists({ category_id: id }) - if (hasCourses && permanent === 'true') { - return res.status(422).json({ - success: false, - message: 'این دستهبندی دارای دوره است. ابتدا دورهها را جابهجا کنید' - }) - } - - if (permanent === 'true') { - // حذف فیزیکی - await category.deleteOne() - res.status(200).json({ - success: true, - message: 'دستهبندی با موفقیت حذف شد' - }) - } else { - // سافت دیلیت - category.status = 'deleted' - category.updated_by = userId - await category.save() - - res.status(200).json({ - success: true, - message: 'دستهبندی با موفقیت غیرفعال شد' - }) - } - } catch (error) { - console.error('Error in remove:', error) - res.status(500).json({ - success: false, - message: 'خطا در حذف دستهبندی', - error: error.message - }) - } -} - -// ==================== POST - تغییر وضعیت (اکتیو/غیراکتیو) ==================== -const toggleStatus = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) - - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userId = decodedToken.id - - const { id } = req.params - const { status } = req.body // 'active' or 'inactive' - - if (!['active', 'inactive'].includes(status)) { - return res.status(422).json({ - success: false, - message: 'وضعیت نامعتبر است' - }) - } - - const category = await AcademyCategoryModel.findByIdAndUpdate( - id, - { - status, - updated_by: userId, - updatedAt: new Date() - }, - { new: true } - ) - - if (!category) { - return res.status(404).json({ - success: false, - message: 'دستهبندی یافت نشد' - }) - } - - res.status(200).json({ - success: true, - message: `وضعیت دستهبندی با موفقیت به ${status === 'active' ? 'فعال' : 'غیرفعال'} تغییر کرد`, - data: category - }) - } catch (error) { - console.error('Error in toggleStatus:', error) - res.status(500).json({ - success: false, - message: 'خطا در تغییر وضعیت دستهبندی', - error: error.message - }) - } -} - -// ==================== POST - افزایش تعداد دورهها ==================== -const incrementCourseCount = async (req, res) => { - try { - const { id } = req.params - const { increment = 1 } = req.body - - const category = await AcademyCategoryModel.incrementCourseCount(id, increment) - - if (!category) { - return res.status(404).json({ - success: false, - message: 'دستهبندی یافت نشد' - }) - } - - res.status(200).json({ - success: true, - message: 'تعداد دورهها بهروزرسانی شد', - data: { course_count: category.course_count } - }) - } catch (error) { - console.error('Error in incrementCourseCount:', error) - res.status(500).json({ - success: false, - message: 'خطا در بهروزرسانی تعداد دورهها', - error: error.message - }) - } -} - -// ==================== GET - دریافت دستهبندیهای ویژه (featured) ==================== -const getFeatured = async (req, res) => { - try { - const { limit = 10 } = req.query - - const categories = await AcademyCategoryModel.find({ - is_featured: true, - status: 'active' - }) - .sort({ order: 1 }) - .limit(parseInt(limit)) - .populate('parent', 'title slug') - - res.status(200).json({ - success: true, - message: 'دستهبندیهای ویژه با موفقیت دریافت شد', - data: categories - }) - } catch (error) { - console.error('Error in getFeatured:', error) - res.status(500).json({ - success: false, - message: 'خطا در دریافت دستهبندیهای ویژه', - error: error.message - }) - } -} - -const getFreeAcademyExploreContent = async (req, res, next) => { - try { - const page = parseInt(req.query.page, 10) || 1 - const limit = parseInt(req.query.limit, 10) || 15 - const skip = (page - 1) * limit - - const freeCourses = await CourseModel.find({ is_free: true }) - .select('_id') - .lean() - const freeCourseIds = freeCourses.map((course) => String(course._id)) - - const query = { - status: academyContentPublicStatusFilter(), - $or: [ - { is_free: true }, - { type: 'video', courseId: { $in: freeCourseIds } } - ] - } - - const totalItems = await AcademyContentModel.countDocuments(query) - const contents = await AcademyContentModel.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .lean() - - const courseIds = [ - ...new Set(contents.map((item) => item.courseId).filter(Boolean)) - ] - const courses = courseIds.length - ? await CourseModel.find({ _id: { $in: courseIds } }) - .select( - 'cuorse_name course_image teacher_name academyId user_id caption likes category' - ) - .lean() - : [] - const courseMap = Object.fromEntries( - courses.map((course) => [String(course._id), course]) - ) - - const userIds = [ - ...new Set(courses.map((course) => String(course.user_id)).filter(Boolean)) - ] - const users = userIds.length - ? await UserModel.find({ _id: { $in: userIds } }) - .select('user_name first_name last_name profile_image') - .lean() - : [] - const userMap = Object.fromEntries( - users.map((user) => [String(user._id), user]) - ) - - const mapContentItem = (content) => { - const course = courseMap[String(content.courseId)] || {} - const user = userMap[String(course.user_id)] || {} - return { - ...content, - course_name: course.cuorse_name || content.file_name || '', - course_image: course.course_image || '', - teacher_name: course.teacher_name || '', - academyId: course.academyId || '', - user_name: user.user_name || '', - first_name: user.first_name || '', - last_name: user.last_name || '', - profile_image: user.profile_image || '', - likesCount: Array.isArray(course.likes) ? course.likes.length : 0 - } - } - - let items = contents.map(mapContentItem) - - // بلند کردن محتوای مرتبط با علایق کاربر (فقط صفحه اول، بدون فیلتر دستی) - if (page === 1) { - try { - const token = req.header('Authorization')?.split(' ')[1] - const viewerId = token ? jwt.verify(token, process.env.APP_SECRET)?.id : null - if (viewerId) { - const interestWeights = await buildUnifiedInterestScore(viewerId) - const tags = Object.keys(interestWeights).filter((tag) => interestWeights[tag] > 0) - if (tags.length) { - const interestCategories = await AcademyCategoryModel.find({ - relatedExpertise: { $in: tags } - }) - .select('title') - .lean() - const categoryTitles = interestCategories.map((c) => c.title) - if (categoryTitles.length) { - const interestCourseIds = ( - await CourseModel.find({ category: { $in: categoryTitles } }).select('_id').lean() - ).map((c) => String(c._id)) - if (interestCourseIds.length) { - const existingContentIds = items.map((i) => i._id) - const interestContents = await AcademyContentModel.find({ - ...query, - courseId: { $in: interestCourseIds }, - _id: { $nin: existingContentIds } - }) - .sort({ createdAt: -1 }) - .limit(5) - .lean() - if (interestContents.length) { - const extraCourseIds = interestContents - .map((c) => String(c.courseId)) - .filter((id) => !courseMap[id]) - if (extraCourseIds.length) { - const extraCourses = await CourseModel.find({ _id: { $in: extraCourseIds } }) - .select('cuorse_name course_image teacher_name academyId user_id caption likes category') - .lean() - extraCourses.forEach((course) => { - courseMap[String(course._id)] = course - }) - const extraUserIds = extraCourses - .map((c) => String(c.user_id)) - .filter((id) => id && !userMap[id]) - if (extraUserIds.length) { - const extraUsers = await UserModel.find({ _id: { $in: extraUserIds } }) - .select('user_name first_name last_name profile_image') - .lean() - extraUsers.forEach((user) => { - userMap[String(user._id)] = user - }) - } - } - const mappedInterestContents = interestContents.map(mapContentItem) - items = blendInterestIntoRanked( - items, - [...items, ...mappedInterestContents], - 0.25 - ).slice(0, limit) - } - } - } - } - } - } catch { - /* non-blocking: personalization is a bonus, never break the listing */ - } - } - - const totalPages = Math.ceil(totalItems / limit) || 1 - - return res.status(200).json({ - success: true, - data: { - items, - pagination: { - currentPage: page, - totalPages, - totalItems, - hasNextPage: page < totalPages - } - } - }) - } catch (error) { - console.error('Error in getFreeAcademyExploreContent:', error) - next(error) - } -} - -module.exports = { - // ========================================== - // 🏢 ماژول آکادمی (Academy) - // ========================================== - - // پیدا کردن آکادمی کاربر جاری - findAcademy, - - // پیدا کردن آکادمی با شناسه - findAcademyById, - - // تکمیل و ویرایش پروفایل آکادمی - academyProfile, - - // تغییر وضعیت آکادمی (فعال/غیرفعال - فقط ادمین) - updateAcademyStatus, - - // دریافت اطلاعات یک آکادمی با شناسه - getAcademyById, - - // ایجاد آکادمی جدید (دستی - معمولاً خودکار ایجاد میشود) - createAcademy, - - // ویرایش اطلاعات آکادمی - updateAcademy, - - // حذف آکادمی (به همراه تمام دورههای آن) - deleteAcademy, - - // دریافت آمار کلی آکادمیها (تعداد، میانگین امتیاز و...) - getAcademyStats, - - // ========================================== - // 📚 ماژول دورهها (Course) - // ========================================== - - // ایجاد دوره جدید - creatCourse, - - // ویرایش اطلاعات دوره - updateCourse, - - // حذف دوره (تغییر وضعیت به reject) - deleteCourse, - - // دریافت لیست دورههای آکادمی جاری - getCourse, - - // دریافت لیست همه دورهها با فیلتر و جستجو - getCourses, - - // محتوای رایگان آموزشگاه برای اکسپلور - getFreeAcademyExploreContent, - - // دریافت دورههای یک آکادمی خاص - getAcademyCourse, - - // آپلود ویدیو با روش Multipart - creatCourseVideo, - - // آپلود ویدیو با روش Base64 (برای فایلهای کوچک) - creatCourseVideoBase64, - - // ========================================== - // 💳 ماژول پرداخت (Payment) - // ========================================== - - // پرداخت برای اشتراک پرو - proPayment, - - // کالبک درگاه پرداخت زرینپال (اشتراک پرو) - handleCoursePaymentCallback, - - // پرداخت برای خرید دوره - coursePayment, - - // کالبک درگاه پرداخت زرینپال (خرید دوره) - CoursePaymentCallback, - - // ========================================== - // 🎬 ماژول محتوای دوره (Course Content) - // ========================================== - - // دریافت محتوای دوره (لیست ویدیوها و فایلها) - getCourseContent, - - // حذف ویدیو از دوره - deleteCourseVideo, - - // لایک کردن محتوا - likeCourseContent, - - // حذف لایک (دیسلایک) - dontLikeCourseContent, - - // دریافت تعداد لایکهای یک دوره - getCourseLikesCount, - - // دریافت لیست دورههای لایک شده توسط کاربر - getUserLikedCourses, - - // بررسی وضعیت لایک کاربر (آیا لایک کرده یا نه) - isLikeCourseContent, - - // ========================================== - // 💬 ماژول کامنتها (Comments) - // ========================================== - - // ایجاد کامنت جدید - createComment, - - // دریافت تعداد کل کامنتهای یک دوره - getTotalCommentsCount, - - // دریافت لیست کامنتهای یک دوره (با فیلتر و صفحهبندی) - getCourseComments, - - // تغییر وضعیت کامنت (تایید/رد/در انتظار) - updateCommentStatus, - - // دریافت کامنتهای یک کاربر خاص - getUserComments, - - // حذف کامنت - deleteComment, - - // ========================================== - // 🛒 ماژول دورههای خریداری شده (Purchased) - // ========================================== - - // دریافت دورههای خریداری شده توسط کاربر جاری - getUserPurchasedCourses, - - // دریافت دورههای خریداری شده (با populate - روش بهینه) - getUserPurchasedCoursesWithPopulate, - - // دریافت همه پرداختهای آکادمی - getAllAcademyPayments, - getAcademyAnalytics, - - // بررسی خرید یک دوره توسط کاربر (برای صفحه دوره) - checkCoursePurchase, - - // ========================================== - // 👑 ماژول مدیریتی (Admin Panel) - // ========================================== - - // ------ مدیریت پرداختها ------ - - // دریافت همه پرداختهای سیستم (ادمین) - getAllPayments, - - // تغییر وضعیت پرداخت (pending/success/settled/failed) - updatePaymentStatus, - - // ------ مدیریت دورهها ------ - - // دریافت همه دورههای سیستم (ادمین) - getAllCourses, - - // تغییر وضعیت دوره (تایید/رد/در انتظار) - updateCourseStatus, - - // حذف کامل دوره از سیستم (ادمین) - admindeleteCourse, - - // ------ مدیریت دورههای خریداری شده ------ - - // دریافت همه خریدهای سیستم (ادمین) - getAllPurchasedCourses, - - // ------ مدیریت آکادمیها ------ - - // دریافت همه آکادمیهای سیستم (ادمین) - getAllAcademies, - - // ------ مدیریت تنظیمات ------ - - // دریافت تنظیمات (تکس، قیمتها و...) - getSettings, - - // بهروزرسانی تنظیمات - updateSettings, - - // دریافت قیمت دورهها (پرو، لجند، معمولی) - getCoursePrices, - - // بهروزرسانی قیمت یک نوع دوره - updateCoursePrice, - - // دریافت درصد مالیات - getTax, - - // بهروزرسانی درصد مالیات - updateTax, - - // دریافت همه تنظیمات یکجا - getAllSettings, - - // بهروزرسانی همه تنظیمات یکجا - updateAllSettings, - - // ========================================== - // 📊 ماژول آمار (Stats) - // ========================================== - - // دریافت آمار پرداختها - getPaymentStats, - - // دریافت اطلاعات یک دوره با شناسه - getCourseById, - - // ایجاد دوره جدید (ادمین) - createCourse, - - // ویرایش دوره (ادمین) - updateCourse, - - // دریافت آمار کلی دورهها - getCourseStats, - - // دریافت آمار دورههای خریداری شده - getPurchasedStats, - - // ========================================== - // 📹 ماژول آپلود تکهتکه (Chunk Upload) - // ========================================== - - // دریافت تکه ویدیو (هر بار یک تکه) - creatCourseVideoChunk, - - // ادغام تکهها بعد از اتمام آپلود - mergeVideoChunks, - - // بررسی وضعیت آپلود (برای ادامه از جای قطع شده) - getUploadStatus, - - // ========================================== - // 🏷️ ماژول دستهبندی آکادمی (Category) - // ========================================== - - // دریافت لیست دستهبندیها (با فیلتر و صفحهبندی) - getAll, - - // دریافت یک دستهبندی با شناسه - getOne, - - // دریافت درخت دستهبندی (ساختار سلسلهمراتبی والد-فرزند) - getTree, - - // دریافت دستهبندی با اسلاگ (برای سئو و لینکهای زیبا) - getBySlug, - - // ایجاد دستهبندی جدید - create, - - // ویرایش دستهبندی - update, - - // حذف دستهبندی (سافت دیلیت - فقط غیرفعال میشود) - remove, - - // تغییر وضعیت دستهبندی (فعال/غیرفعال) - toggleStatus, - - // افزایش تعداد دورههای دستهبندی - incrementCourseCount, - - // دریافت دستهبندیهای ویژه (برای نمایش در صفحه اصلی) - getFeatured -} +const jwt = require('jsonwebtoken') +const fs = require('fs-extra') +const path = require('path') +const jMoment = require('moment-jalaali') +const AcademyContentModel = require('../../../models/AcademyContentModel') +const AcademyModel = require('../../../models/AcademyModel') +const AcademyPaymentModel = require('../../../models/AcademyPaymentModel') +const CourseModel = require('../../../models/CourseModel') +const CoursePaymentModel = require('../../../models/CoursePaymentModel') +const UserModel = require('../../../models/UserModel') +const PaymentModel = require('../../../models/PaymentModel') +const { default: axios } = require('axios') +const AcademyLikeModel = require('../../../models/AcademyLikeModel') +const CourseComment = require('../../../models/CourseComentModel') +const IsPaymentCourse = require('../../../models/IsPaymentCourseModel') +const Tax = require('../../../models/TaxModel') +const AcademyCategoryModel = require('../../../models/AcademyCategoryModel') +const { buildUnifiedInterestScore, blendFreshIntoRanked: blendInterestIntoRanked } = require('../../../utils/exploreAlgorithm') +const { resolveCommentModerationStatus } = require('../../../utils/contentModeration') +const { saveCommentWithModerationStatus } = require('../../../utils/commentStatusSave') +const { resolveStoryFlags } = require('../../../utils/storyRingFlags') +const { isVerifiedBadge } = require('../../../utils/verifyBadge') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') +const { + coursePublicStatusFilter, + academyContentPublicStatusFilter, + commentStatusFilter, + PUBLIC_COMMENT_STATUSES +} = require('../../../utils/moderationStatus') +const { + createLikeNotification, + resolveCourseOwnerId +} = require('../../../utils/likeNotification') +const { + createCommentNotification, + createRatingNotification +} = require('../../../utils/commentNotification') +const { + createCoursePurchaseNotification +} = require('../../../utils/academyNotification') +const AdvertisingProfileModel = require('../../../models/AdvertisingProfile') +const WalletTransactionModel = require('../../../models/WalletTransactionModel') +const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') +const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage') + +const ACADEMY_COMMISSION_RATE = 0.15 + +const onServerRestart = async () => { + const CoursePayment = await CoursePaymentModel.findOne({ + cuorse_type: 'pro' + }) + if (!CoursePayment) { + const newCoursePayment = new CoursePaymentModel({ + price: '60000', + cuorse_type: 'pro' + }) + await newCoursePayment.save() + + return console.log('اشتراک پرو ساخته شد') + } + + console.log('اشتراک پرو موجود هست') + const tax = await Tax.findOne({ + type: 'course' + }) + if (!tax) { + const newTax = new Tax({ + tax: 8, + type: 'course' + }) + await newTax.save() + + return console.log('تکس ساخته شد') + } + + console.log(' تکس موجود هست') +} +onServerRestart() + +const findAcademy = 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 userId = decodedToken.id + + const user = await UserModel.findById(userId) + if (!user) { + return res.status(422).json({ + error: true, + message: 'کاربر یافت نشد' + }) + } + const academy = await AcademyModel.findOne({ userId }) + if (!academy) { + const newAcademy = new AcademyModel({ + userId + }) + await newAcademy.save() + + return res.status(200).json({ + academy: newAcademy + }) + } + + const advertisingProfile = await AdvertisingProfileModel.findOne({ + user: userId + }).select('contactInfo') + + res.status(200).json({ + academy: { + ...academy.toObject(), + contactInfo: advertisingProfile?.contactInfo || null + } + }) + } catch (error) { + // در صورت بروز خطا، ارسال پیام خطا به کاربر + console.error('Error:', error) + res.status(500).json({ message: 'err in creat' }) + } +} +const findAcademyById = async (req, res, next) => { + try { + const { _id } = req.body + + const academy = await AcademyModel.findById(_id) + if (!academy) { + return res.status(404).json({ + success: false, + message: 'اکادمی موجود نیست' + }) + } + + const owner = await UserModel.findById(academy.userId).select( + 'user_name first_name last_name is_verified verify_badge is_Register profile_image user_score rate' + ) + + const packagesCount = await CourseModel.countDocuments({ + academyId: academy._id, + status: coursePublicStatusFilter() + }) + + const academyCourses = await CourseModel.find({ + academyId: academy._id, + status: coursePublicStatusFilter() + }).select('_id number_of_course_content') + + const courseIds = academyCourses.map((course) => course._id) + const soldCount = courseIds.length + ? await IsPaymentCourse.countDocuments({ course_id: { $in: courseIds } }) + : 0 + + const totalVideos = academyCourses.reduce( + (sum, course) => sum + (parseInt(course.number_of_course_content, 10) || 0), + 0 + ) + + const advertisingProfile = await AdvertisingProfileModel.findOne({ + user: academy.userId + }).select('contactInfo') + + res.status(200).json({ + academy: { + ...academy.toObject(), + stats: { + packagesCount, + soldCount, + totalVideos + }, + owner, + contactInfo: advertisingProfile?.contactInfo || null + } + }) + } catch (error) { + console.error('Error:', error) + res.status(500).json({ message: 'err in creat' }) + } +} +const academyProfile = 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 userId = decodedToken.id + + const user = await UserModel.findById(userId) + if (!user) { + return res.status(422).json({ + error: true, + message: 'کاربر یافت نشد' + }) + } + const academy = await AcademyModel.findOne({ userId }) + if (!academy) { + return res.status(200).json({ + message: 'اکادمی پیدا نشد' + }) + } + + const { profile_image } = req.files + const { name, sheba, bio, tag } = req.body + + if (!profile_image) { + return res.status(422).json({ + error: true, + message: 'اطلاعات ارسالی اشتباه است' + }) + } + + let shebaDigits = String(sheba || '') + .replace(/\s/g, '') + .toUpperCase() + if (shebaDigits.startsWith('IR')) { + shebaDigits = shebaDigits.slice(2) + } + shebaDigits = shebaDigits.replace(/\D/g, '') + if (!/^\d{24}$/.test(shebaDigits)) { + return res.status(422).json({ + error: true, + message: 'شماره شبا باید ۲۴ رقم باشد' + }) + } + + const uploadDir = path.join(__dirname, '../../../../storage/profiles') + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + + const uniqueFileName = `${name}-${Date.now()}${path.extname( + profile_image.name + )}` + const filePath = path.join(uploadDir, uniqueFileName) + + await fs.move(profile_image.path, filePath) + + academy.academy_image = `/profiles/${uniqueFileName}` + academy.academy_name = name + academy.sheba = `IR${shebaDigits}` + academy.bio = maskPhoneNumbers(bio) + academy.tag = tag + await academy.save() + + const contactPayloadPresent = + req.body.contact_mobile !== undefined || + req.body.contact_telegram !== undefined || + req.body.contact_whatsapp !== undefined || + req.body.contact_instagram !== undefined + + if (contactPayloadPresent) { + const contactInfo = + parseCourseContactInfo(req.body) ?? { + mobile: null, + telegramLink: null, + whatsappNumber: null, + instagramLink: null + } + let advertisingProfile = await AdvertisingProfileModel.findOne({ + user: userId + }) + if (!advertisingProfile) { + advertisingProfile = new AdvertisingProfileModel({ + user: userId, + contactInfo + }) + await advertisingProfile.save() + } else { + advertisingProfile.contactInfo = contactInfo + await advertisingProfile.save() + } + } + + res.status(200).json({ + message: 'ثبت شد' + }) + } catch (error) { + // در صورت بروز خطا، ارسال پیام خطا به کاربر + console.error('Error:', error) + res.status(500).json({ message: 'err in update' }) + } +} + +const parseCourseContactInfo = (body) => { + const mobile = body.contact_mobile || body['contactInfo[mobile]'] || '' + const telegramLink = + body.contact_telegram || body['contactInfo[telegramLink]'] || '' + const whatsappNumber = + body.contact_whatsapp || body['contactInfo[whatsappNumber]'] || '' + const instagramLink = + body.contact_instagram || body['contactInfo[instagramLink]'] || '' + + const hasAny = [mobile, telegramLink, whatsappNumber, instagramLink].some( + Boolean + ) + if (!hasAny) return undefined + + return { + mobile: mobile || null, + telegramLink: telegramLink || null, + whatsappNumber: whatsappNumber || null, + instagramLink: instagramLink || null + } +} + +const creatCourse = async (req, res, next) => { + try { + // با blockCheck ست میشه (req.user) — برای کاربرِ زیرمجموعه، همون اکانتِ اصلیه + // (نه خودِ زیرمجموعه)، پس آکادمی درست پیدا میشه + const user = req.user + const userId = user._id + + const academy = await AcademyModel.findOne({ userId }) + if (!academy) { + return res.status(200).json({ + message: 'اکادمی پیدا نشد' + }) + } + const { + price, + cuorse_name, + category, + offer, + caption, + course_time, + number_of_course_content, + teacher_name, + is_free + } = req.body + + const isFreePackage = is_free === 'true' || is_free === true + const contactInfo = parseCourseContactInfo(req.body) + + const { course_image } = req.files + + if (!course_image) { + return res.status(422).json({ + error: true, + message: 'اطلاعات ارسالی اشتباه است' + }) + } + + const uploadDir = path.join(__dirname, '../../../../storage/profiles') + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + + const uniqueFileName = `${cuorse_name}-${Date.now()}${path.extname( + course_image.name + )}` + const filePath = path.join(uploadDir, uniqueFileName) + + await fs.move(course_image.path, filePath) + + // ✅ ایجاد رکورد جدید + const newAcademy = new CourseModel({ + course_image: `/profiles/${uniqueFileName}`, + price: isFreePackage ? '0' : price, + cuorse_name, + category, + offer: offer || '0', + teacher_name, + academyId: academy._id, + caption: maskPhoneNumbers(caption), + course_time, + number_of_course_content, + is_free: isFreePackage, + ...(contactInfo ? { contactInfo } : {}), + status: isVerifiedBadge(user.verify_badge) ? 'temp_accept' : 'pending' + }) + + await newAcademy.save() + + res.status(200).json({ + message: 'ثبت شد' + }) + } catch (error) { + // در صورت بروز خطا، ارسال پیام خطا به کاربر + console.error('Error:', error) + res.status(500).json({ message: 'err in update' }) + } +} + +const updateCourse = async (req, res, next) => { + try { + // با blockCheck ست میشه (req.user) — برای کاربرِ زیرمجموعه، همون اکانتِ اصلیه + const user = req.user + const userId = user._id + + const { + price, + cuorse_name, + category, + offer, + courseId, + teacher_name, + caption, + course_time, + number_of_course_content, + is_free + } = req.body + + const isFreePackage = + is_free === 'true' || is_free === true + ? true + : is_free === 'false' || is_free === false + ? false + : undefined + + // اعتبارسنجی courseId + if (!courseId) { + return res.status(422).json({ + error: true, + message: 'آیدی دوره الزامی است' + }) + } + + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(422).json({ + error: true, + message: 'پکیجی یافت نشد' + }) + } + + // ========== آپلود عکس جدید (اختیاری) ========== + const { course_image } = req.files || {} + + if (course_image) { + // فقط اگه عکس جدید آپلود شده بود، آپلود کن + const uploadDir = path.join(__dirname, '../../../../storage/profiles') + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }) + } + + // استفاده از id به جای نام دوره برای نام فایل + const safeFileName = `course-${courseId}-${Date.now()}${path.extname( + course_image.name + )}` + const filePath = path.join(uploadDir, safeFileName) + + await fs.move(course_image.path, filePath) + course.course_image = `/profiles/${safeFileName}` + } + + // ========== به روز رسانی فیلدها (فقط فیلدهایی که ارسال شده) ========== + if (price !== undefined) course.price = isFreePackage ? '0' : price + if (cuorse_name !== undefined) course.cuorse_name = cuorse_name + if (category !== undefined) course.category = category + if (offer !== undefined) course.offer = offer || '0' + if (caption !== undefined) course.caption = maskPhoneNumbers(caption) + if (course_time !== undefined) course.course_time = course_time + if (number_of_course_content !== undefined) { course.number_of_course_content = number_of_course_content } + if (teacher_name !== undefined) course.teacher_name = teacher_name + if (isFreePackage !== undefined) course.is_free = isFreePackage + if ( + req.body.contact_mobile !== undefined || + req.body.contact_telegram !== undefined || + req.body.contact_whatsapp !== undefined || + req.body.contact_instagram !== undefined + ) { + course.contactInfo = { + mobile: req.body.contact_mobile || null, + telegramLink: req.body.contact_telegram || null, + whatsappNumber: req.body.contact_whatsapp || null, + instagramLink: req.body.contact_instagram || null + } + } + + await course.save() + + res.status(200).json({ + success: true, + message: 'دوره با موفقیت به روز رسانی شد', + data: { + course + } + }) + } catch (error) { + console.error('Error in updateCourse:', error) + res.status(500).json({ + success: false, + message: 'خطا در به روز رسانی دوره', + error: error.message + }) + } +} +const deleteCourse = 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 userId = decodedToken.id + const { courseId } = req.body + + const user = await UserModel.findById(userId) + if (!user) { + return res.status(422).json({ + error: true, + message: 'کاربر یافت نشد' + }) + } + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(422).json({ + error: true, + message: 'پکیجی یافت نشد' + }) + } + + course.status = 'reject' + await course.save() + + res.status(200).json({ + message: 'ثبت شد' + }) + } catch (error) { + // در صورت بروز خطا، ارسال پیام خطا به کاربر + console.error('Error:', error) + res.status(500).json({ message: 'err in update' }) + } +} +const getCourse = 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 userId = decodedToken.id + + // گرفتن پارامترهای صفحهبندی + const page = parseInt(req.query.page) || 1 + const limit = parseInt(req.query.limit) || 10 + const skip = (page - 1) * limit + + const user = await UserModel.findById(userId) + if (!user) { + return res.status(422).json({ + error: true, + message: 'کاربر یافت نشد' + }) + } + + const academy = await AcademyModel.findOne({ userId }) + if (!academy) { + return res.status(404).json({ + success: false, + message: 'آکادمی پیدا نشد' + }) + } + + // ✅ شرط جستجو - فقط دورههای تایید شده + const query = { + academyId: academy._id, + status: coursePublicStatusFilter() + } + + // دریافت تعداد کل دورههای تایید شده + const totalCourses = await CourseModel.countDocuments(query) + + // دریافت دورههای تایید شده با صفحهبندی + const courses = await CourseModel.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + + // ✅ اگر دورهای وجود نداشت، خطا نده، بلکه آرایه خالی برگردون + if (!courses || courses.length === 0) { + return res.status(200).json({ + success: true, + message: 'هیچ دوره تایید شدهای یافت نشد', + data: { + courses: [], + pagination: { + currentPage: page, + totalPages: 0, + totalItems: 0, + itemsPerPage: limit, + hasNextPage: false, + hasPrevPage: false, + nextPage: null, + prevPage: null + } + } + }) + } + + // محاسبات صفحهبندی + const totalPages = Math.ceil(totalCourses / limit) + const hasNextPage = page < totalPages + const hasPrevPage = page > 1 + + res.status(200).json({ + success: true, + message: 'لیست پکیجها با موفقیت دریافت شد', + data: { + courses, + pagination: { + currentPage: page, + totalPages, + totalItems: totalCourses, + itemsPerPage: limit, + hasNextPage, + hasPrevPage, + nextPage: hasNextPage ? page + 1 : null, + prevPage: hasPrevPage ? page - 1 : null + } + } + }) + } catch (error) { + console.error('Error:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت پکیجها', + error: error.message + }) + } +} + +const getCourses = async (req, res, next) => { + try { + // ========== 3. دریافت پارامترهای صفحهبندی ========== + const page = parseInt(req.query.page) || 1 + const limit = parseInt(req.query.limit) || 10 + const skip = (page - 1) * limit + + // ========== 4. دریافت پارامترهای جستجو ========== + const search = req.query.search || '' + const category = req.query.category || '' + const minPrice = req.query.minPrice ? parseInt(req.query.minPrice) : 0 + const maxPrice = req.query.maxPrice + ? parseInt(req.query.maxPrice) + : 100000000 + const hasOffer = req.query.hasOffer === 'true' // تخفیف دار + const teacherName = req.query.teacherName || '' + const type = req.query.type || '' // normal, pro, legend + const isFree = req.query.isFree === 'true' + const status = req.query.status || 'all' // pending, accept, reject + const sortBy = req.query.sortBy || 'createdAt' + const sortOrder = req.query.sortOrder === 'asc' ? 1 : -1 + const _id = req.query._id || '' + // ========== 5. ساخت شرط جستجو (Query) ========== + const query = {} + + // جستجو در نام دوره و کپشن + if (search) { + query.$or = [ + { cuorse_name: { $regex: search, $options: 'i' } }, + { caption: { $regex: search, $options: 'i' } }, + { teacher_name: { $regex: search, $options: 'i' } } + ] + } + + // فیلتر بر اساس دسته بندی + if (category) { + query.category = category + } + if (_id) { + query._id = _id + } + // فیلتر بر اساس محدوده قیمت (تبدیل به عدد برای مقایسه) + if (minPrice > 0 || maxPrice < 100000000) { + query.price = { + $gte: minPrice.toString(), + $lte: maxPrice.toString() + } + } + + // فیلتر بر اساس تخفیف دار (offer وجود داشته باشد و از 0 بیشتر باشد) + if (hasOffer) { + query.offer = { $exists: true, $ne: '0', $ne: '' } + } + + // فیلتر بر اساس نام مدرس + if (teacherName) { + query.teacher_name = { $regex: teacherName, $options: 'i' } + } + + // فیلتر بر اساس نوع دوره + if (type) { + query.type = type + } + + if (isFree) { + query.is_free = true + } + + // فیلتر بر اساس وضعیت — در اپ، temp_accept هم نمایش داده میشود + if (status === 'accept') { + query.status = coursePublicStatusFilter() + } else if (status !== 'all') { + query.status = status + } else { + query.status = coursePublicStatusFilter() + } + + // ========== 6. دریافت آمار و اطلاعات ========== + // تعداد کل پکیجها (بدون صفحهبندی) + const totalCourses = await CourseModel.countDocuments(query) + + // مجموع قیمتها (تبدیل به عدد برای محاسبه) + const priceStats = await CourseModel.aggregate([ + { $match: query }, + { + $group: { + _id: null, + total: { $sum: { $toDouble: '$price' } }, + avg: { $avg: { $toDouble: '$price' } }, + min: { $min: { $toDouble: '$price' } }, + max: { $max: { $toDouble: '$price' } } + } + } + ]) + + // تعداد دورههای با تخفیف + const offeredCourses = await CourseModel.countDocuments({ + ...query, + offer: { $exists: true, $ne: '0', $ne: '' } + }) + + // تعداد دورهها بر اساس نوع + const typeStats = await CourseModel.aggregate([ + { $match: query }, + { + $group: { + _id: '$type', + count: { $sum: 1 } + } + } + ]) + + // ========== 7. دریافت پکیجها با صفحهبندی ========== + const courses = await CourseModel.find(query) + .populate('user_id', 'name email') // اطلاعات کاربر سازنده + .populate('academyId', 'academy_name academy_image') // برای نمایش عکس/نام آموزشگاه توی لیست + .populate('likes', 'name') // اطلاعات لایکها + .sort({ [sortBy]: sortOrder }) + .skip(skip) + .limit(limit) + .lean() + + // پردازش دادهها برای خروجی + let processedCourses = courses.map((course) => ({ + ...course, + // academy_name/academy_image رو صاف روی خود کورس میذاریم (نه فقط زیر academyId + // populate-شده) چون اپ موبایل/وب این دو فیلد رو مستقیم روی course انتظار دارن + academy_name: course.academyId?.academy_name || null, + academy_image: course.academyId?.academy_image || null, + // academyId رو بعد از populate به همون شناسهی رشتهای برمیگردونیم — وگرنه بهجای + // ObjectId، کل سابداکیومنت populate-شده (academy_name/academy_image) توی جواب میره؛ + // موبایل/وب همین academyId رو مستقیم به /academy/findAcademyById میفرستن، پس یه + // object بهجای رشته باعث خطای کست ObjectId میشه (نتیجه: پروفایل آموزشگاه لود نمیشه) + academyId: course.academyId?._id + ? String(course.academyId._id) + : course.academyId, + priceNumber: parseInt(course.price) || 0, + offerNumber: parseInt(course.offer) || 0, + finalPrice: course.is_free + ? 0 + : course.offer + ? (parseInt(course.price) * (100 - parseInt(course.offer))) / 100 + : parseInt(course.price), + likesCount: course.likes?.length || 0 + })) + + // بلند کردن دورههای مرتبط با علایق کاربر داخل نتایج پیشفرض (بدون مرتبسازی دستی کاربر) + const isDefaultBrowsePath = !req.query.sortBy && !category && !search && page === 1 + if (isDefaultBrowsePath) { + try { + const token = req.header('Authorization')?.split(' ')[1] + const viewerId = token ? jwt.verify(token, process.env.APP_SECRET)?.id : null + if (viewerId) { + const interestWeights = await buildUnifiedInterestScore(viewerId) + const tags = Object.keys(interestWeights).filter((tag) => interestWeights[tag] > 0) + if (tags.length) { + const interestCategories = await AcademyCategoryModel.find({ + relatedExpertise: { $in: tags } + }) + .select('title') + .lean() + const categoryTitles = interestCategories.map((c) => c.title) + if (categoryTitles.length) { + const existingIds = processedCourses.map((c) => c._id) + const interestCourses = await CourseModel.find({ + ...query, + category: { $in: categoryTitles }, + _id: { $nin: existingIds } + }) + .populate('user_id', 'name email') + .populate('academyId', 'academy_name academy_image') + .populate('likes', 'name') + .sort({ createdAt: -1 }) + .limit(5) + .lean() + const processedInterestCourses = interestCourses.map((course) => ({ + ...course, + academy_name: course.academyId?.academy_name || null, + academy_image: course.academyId?.academy_image || null, + // عین بلوک اصلی بالاتر — academyId بعد از populate باید رشته بمونه + academyId: course.academyId?._id + ? String(course.academyId._id) + : course.academyId, + priceNumber: parseInt(course.price) || 0, + offerNumber: parseInt(course.offer) || 0, + finalPrice: course.is_free + ? 0 + : course.offer + ? (parseInt(course.price) * (100 - parseInt(course.offer))) / 100 + : parseInt(course.price), + likesCount: course.likes?.length || 0 + })) + processedCourses = blendInterestIntoRanked( + processedCourses, + [...processedCourses, ...processedInterestCourses], + 0.25 + ).slice(0, limit) + } + } + } + } catch { + /* non-blocking: personalization is a bonus, never break the listing */ + } + } + + // ========== 8. محاسبات صفحهبندی ========== + const totalPages = Math.ceil(totalCourses / limit) + const hasNextPage = page < totalPages + const hasPrevPage = page > 1 + + // محاسبه محدوده نمایش + const startItem = totalCourses === 0 ? 0 : (page - 1) * limit + 1 + const endItem = Math.min(page * limit, totalCourses) + + // ========== 9. ارسال پاسخ ========== + res.status(200).json({ + success: true, + message: 'لیست پکیجها با موفقیت دریافت شد', + data: { + courses: processedCourses, + stats: { + totalCourses, + offeredCourses, + averagePrice: Math.round(priceStats[0]?.avg || 0), + minPrice: priceStats[0]?.min || 0, + maxPrice: priceStats[0]?.max || 0, + totalPrice: priceStats[0]?.total || 0, + typeStats + }, + pagination: { + currentPage: page, + totalPages, + totalItems: totalCourses, + itemsPerPage: limit, + hasNextPage, + hasPrevPage, + nextPage: hasNextPage ? page + 1 : null, + prevPage: hasPrevPage ? page - 1 : null, + startItem, + endItem + }, + filters: { + search, + category, + minPrice, + maxPrice, + hasOffer, + teacherName, + type, + sortBy, + sortOrder: sortOrder === 1 ? 'asc' : 'desc', + status + } + } + }) + } catch (error) { + console.error('Error in getCourses:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت پکیجها', + error: error.message + }) + } +} + +const proPayment = 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 userId = decodedToken.id + const { courseId } = req.body + + // ========== 1. بررسی وجود CoursePayment ========== + const coursePayment = await CoursePaymentModel.findOne({ + cuorse_type: 'pro' + }) + + if (!coursePayment) { + return res.status(404).json({ + error: true, + message: 'قیمتگذاری برای نوع پرو یافت نشد' + }) + } + + const price = coursePayment.price + + // ========== 2. اعتبارسنجی قیمت ========== + if (!price || isNaN(Number(price)) || Number(price) <= 0) { + return res.status(422).json({ + error: true, + message: 'مبلغ نامعتبر است' + }) + } + + // ========== 3. بررسی وجود دوره ========== + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(404).json({ + error: true, + message: 'پکیجی یافت نشد' + }) + } + + // ========== 4. محاسبه مبلغ ========== + const amount = Number(price) * 10 + + // مبلغ باید حداقل 1000 تومان باشد (طبق قوانین زرینپال) + if (amount < 1000) { + return res.status(422).json({ + error: true, + message: 'مبلغ باید حداقل 1000 تومان باشد' + }) + } + + const callbackUrl = `https://api.modstagram.com/api/v1/academy/academy/course/pro?courseId=${courseId}&userId=${userId}` + + // ========== 5. درخواست به زرینپال ========== + const zarinpalRes = await axios.post( + 'https://api.zarinpal.com/pg/v4/payment/request.json', + { + merchant_id: + process.env.ZARINPAL_MERCHANT_ID || + 'c7c41e8a-918f-4741-bcd5-58f3bc51db73', + amount: Math.round(amount), // حتماً عدد صحیح باشد + description: `خرید اشتراک پرو برای ${course.cuorse_name}`, + callback_url: callbackUrl, + metadata: { + user_id: userId, + course_id: courseId + } + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000 // 10 ثانیه تایماوت + } + ) + + const result = zarinpalRes.data + + // ========== 6. بررسی پاسخ زرینپال ========== + if (result.data && result.data.code === 100) { + // ذخیره اطلاعات پرداخت در دیتابیس (اختیاری) + await PaymentModel.create({ + authority: result.data.authority, + amount, + user_id: userId, + course_id: courseId, + status: 'pending', + type: 'pro', + createdAt: new Date() + }) + + return res.status(200).json({ + success: true, + authority: result.data.authority, + paymentUrl: `${buildPaymentPageUrl(result.data.authority)}` + }) + } + + // خطای زرینپال + console.error('ZarinPal error:', result) + return res.status(422).json({ + error: true, + message: result.errors?.message || 'خطا در ارتباط با درگاه پرداخت', + details: result.errors + }) + } catch (error) { + console.error( + 'Error in proPayment:', + error.response?.data || error.message + ) + res.status(500).json({ + error: true, + message: 'خطا در پردازش پرداخت', + details: error.response?.data || error.message + }) + } +} + +const handleCoursePaymentCallback = async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization') + + try { + const { Authority, Status, userId, courseId } = req.query + + if (Status !== 'OK') { + return res.redirect(`${process.env.APP_SITE}/academy/${courseId || ''}/course?payment=failed`) + } + + // ========== 1. پیدا کردن قیمت ========== + const coursePayment = await CoursePaymentModel.findOne({ + cuorse_type: 'pro' + }) + + if (!coursePayment) { + return res.redirect( + `${process.env.APP_SITE}/academy/${courseId || ''}/course?payment=failed&message=price_not_found` + ) + } + + const price = coursePayment.price + const amount = Number(price) * 10 + + // ========== 2. تایید پرداخت با زرینپال ========== + const verify = await axios.post( + 'https://api.zarinpal.com/pg/v4/payment/verify.json', + { + merchant_id: + process.env.ZARINPAL_MERCHANT_ID || + 'c7c41e8a-918f-4741-bcd5-58f3bc51db73', + authority: Authority, + amount: Math.round(amount) + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000 + } + ) + + const data = verify.data.data + + // ========== 3. بررسی نتیجه تایید ========== + if (data.code === 100) { + // ثبت پرداخت موفق + await PaymentModel.create({ + amount: amount / 10, + status: 'successful', + authority: Authority, + ref_id: data.ref_id, + user_id: userId, + course_id: courseId, + type: 'course', + verifiedAt: new Date() + }) + + // به روز رسانی نوع دوره به pro + const course = await CourseModel.findById(courseId) + if (course) { + course.type = 'pro' + await course.save() + } + + // به روز رسانی وضعیت کاربر (اشتراک پرو) + await UserModel.findByIdAndUpdate(userId, { + $set: { + 'proSubscription.active': true, + 'proSubscription.courseId': courseId, + 'proSubscription.startDate': new Date(), + 'proSubscription.purchaseDate': new Date() + } + }) + + return res.redirect( + `${process.env.APP_SITE}/academy/${courseId || ''}/course?payment=success` + ) + } + + // پرداخت ناموفق + return res.redirect( + `${process.env.APP_SITE}/academy/${courseId || ''}/course?payment=failed&code=${data.code}` + ) + } catch (err) { + console.error('Error in handleCoursePaymentCallback:', err) + return res.redirect( + `${process.env.APP_SITE}/academy/${courseId || ''}/course?payment=failed&message=verification_error` + ) + } +} + +const creatCourseVideo = async (req, res, next) => { + console.log('=== New request received ===') + console.log('Headers:', req.headers) + console.log('Body:', req.body) + console.log('File:', req.file) + + 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 userId = decodedToken.id + + const { courseId, is_free, file_name } = req.body + const videoFile = req.file + + if (!videoFile) { + return res.status(422).json({ + error: true, + message: 'فایل ویدیو ارسال نشده است' + }) + } + + if (!courseId) { + return res.status(422).json({ + error: true, + message: 'شناسه دوره الزامی است' + }) + } + + // مسیر ذخیرهسازی نهایی (مشابه سیستم پستها) + const finalVideoDir = path.join( + __dirname, + '../../../storage/courses/videos' + ) + const safeFileName = `course-${courseId}-${Date.now()}${path.extname( + videoFile.originalname + )}` + const filePath = path.join(finalVideoDir, safeFileName) + + // انتقال فایل از محل موقت به محل نهایی + await fs.rename(videoFile.path, filePath) + + const uploaderUser = await UserModel.findById(userId).select('verify_badge').lean() + + // ایجاد رکورد جدید در دیتابیس + const newAcademyContent = new AcademyContentModel({ + course_video: `/storage/courses/videos/${safeFileName}`, + type: 'video', + is_free: is_free === 'true' || is_free === true, + courseId, + file_name: file_name || videoFile.originalname, + status: isVerifiedBadge(uploaderUser?.verify_badge) ? 'temp_accept' : 'pending' + }) + + await newAcademyContent.save() + + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(404).json({ + success: false, + message: 'پکیج پیدا نشد' + }) + } + + course.number_of_course_content = `${ + Number(course.number_of_course_content) + 1 + }` + await course.save() + + res.status(200).json({ + success: true, + message: 'ویدیو با موفقیت ثبت شد', + data: newAcademyContent + }) + } catch (error) { + console.error('Error in creatCourseVideo:', error) + res.status(500).json({ + success: false, + message: 'خطا در ثبت ویدیو', + error: error.message + }) + } +} + +const creatCourseVideoBase64 = async (req, res, next) => { + console.log('=== Base64 Video Upload ===') + console.log('Body:', Object.keys(req.body)) + + 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 userId = decodedToken.id + + const { courseId, is_free, file_name, video_base64, mime_type } = req.body + + // اعتبارسنجی + if (!video_base64) { + return res.status(422).json({ + error: true, + message: 'فایل ویدیو ارسال نشده است' + }) + } + + if (!courseId) { + return res.status(422).json({ + error: true, + message: 'شناسه دوره الزامی است' + }) + } + + // تبدیل Base64 به بافر + const base64Data = video_base64.split(',')[1] || video_base64 + const buffer = Buffer.from(base64Data, 'base64') + + console.log('Buffer size:', buffer.length, 'bytes') + + // ایجاد دایرکتوری اگر وجود ندارد + const finalVideoDir = path.join( + process.cwd(), + 'storage', + 'courses', + 'videos' + ) + if (!fs.existsSync(finalVideoDir)) { + fs.mkdirSync(finalVideoDir, { recursive: true }) + } + + // تعیین پسوند فایل از mime_type + let extension = '.mp4' + if (mime_type === 'video/mp4') extension = '.mp4' + else if (mime_type === 'video/quicktime') extension = '.mov' + else if (mime_type === 'video/x-msvideo') extension = '.avi' + else if (mime_type === 'video/webm') extension = '.webm' + + const safeFileName = `course-${courseId}-${Date.now()}${extension}` + const filePath = path.join(finalVideoDir, safeFileName) + + // ذخیره فایل + await fs.writeFileSync(filePath, buffer) + + console.log('File saved:', filePath) + + const uploaderUser = await UserModel.findById(userId).select('verify_badge').lean() + + // ایجاد رکورد در دیتابیس + const newAcademyContent = new AcademyContentModel({ + course_video: `/storage/courses/videos/${safeFileName}`, + type: 'video', + is_free: is_free === 'true' || is_free === true, + courseId, + file_name, + status: isVerifiedBadge(uploaderUser?.verify_badge) ? 'temp_accept' : 'pending' + }) + + await newAcademyContent.save() + + // به روز رسانی تعداد محتواهای دوره + const course = await CourseModel.findById(courseId) + if (course) { + course.number_of_course_content = `${ + Number(course.number_of_course_content) + 1 + }` + await course.save() + } + + res.status(200).json({ + success: true, + message: 'ویدیو با موفقیت ثبت شد', + data: newAcademyContent + }) + } catch (error) { + console.error('Error in creatCourseVideoBase64:', error) + res.status(500).json({ + success: false, + message: 'خطا در ثبت ویدیو', + error: error.message + }) + } +} + +const getCourseContent = async (req, res, next) => { + try { + // ✅ اصلاح: دریافت courseId از query یا params + const courseId = req.query.courseId || req.params.courseId + + // اعتبارسنجی courseId + if (!courseId) { + return res.status(422).json({ + error: true, + message: 'شناسه دوره الزامی است' + }) + } + + // گرفتن پارامترهای صفحهبندی + const page = parseInt(req.query.page) || 1 + const limit = parseInt(req.query.limit) || 10 + const skip = (page - 1) * limit + + const academy = await CourseModel.findById(courseId) + if (!academy) { + return res.status(404).json({ + success: false, + message: 'پکیج پیدا نشد' + }) + } + + // ✅ شرط جستجو - محتوای تایید موقت و تایید کامل + const query = { + courseId: academy._id, + status: academyContentPublicStatusFilter() + } + + // دریافت تعداد کل دورههای تایید شده + const totalCourses = await AcademyContentModel.countDocuments(query) + + // دریافت دورههای تایید شده با صفحهبندی + const courses = await AcademyContentModel.find(query) + .skip(skip) + .limit(limit) + + // ✅ اگر دورهای وجود نداشت، خطا نده، بلکه آرایه خالی برگردون + if (!courses || courses.length === 0) { + return res.status(200).json({ + success: true, + message: 'هیچ محتوایی یافت نشد', + data: { + courses: [], + pagination: { + currentPage: page, + totalPages: 0, + totalItems: 0, + itemsPerPage: limit, + hasNextPage: false, + hasPrevPage: false + } + } + }) + } + + // محاسبات صفحهبندی + const totalPages = Math.ceil(totalCourses / limit) + const hasNextPage = page < totalPages + const hasPrevPage = page > 1 + + res.status(200).json({ + success: true, + message: 'لیست محتواها با موفقیت دریافت شد', + data: { + courses, + pagination: { + currentPage: page, + totalPages, + totalItems: totalCourses, + itemsPerPage: limit, + hasNextPage, + hasPrevPage, + nextPage: hasNextPage ? page + 1 : null, + prevPage: hasPrevPage ? page - 1 : null + } + } + }) + } catch (error) { + console.error('Error in getCourseContent:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت محتواها', + error: error.message + }) + } +} +// لایک کردن محتوا +const likeCourseContent = 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 userId = decodedToken.id + + if (!userId) { + return res.status(422).json({ + error: true, + message: 'برای لایک کردن وارد حساب کاربری خود شوید' + }) + } + + const { courseId } = req.body + + // بررسی وجود دوره + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(404).json({ + success: false, + message: 'پکیج پیدا نشد' + }) + } + + // بررسی اینکه آیا قبلاً لایک کرده است + const existingLike = await AcademyLikeModel.findOne({ + user_id: userId, + course_id: courseId + }) + + if (existingLike) { + return res.status(400).json({ + success: false, + message: 'شما قبلاً این دوره را لایک کردهاید' + }) + } + + // افزایش تعداد لایکهای دوره + course.likes = (course.likes || 0) + 1 + await course.save() + + // ایجاد رکورد لایک + await AcademyLikeModel.create({ + user_id: userId, + course_id: courseId, + createdAt: new Date() + }) + + const courseOwnerId = await resolveCourseOwnerId(course) + if (!courseOwnerId) { + console.warn('likeCourseContent: course owner not found for', courseId) + } + + await createLikeNotification({ + io: req.app.get('io'), + ownerId: courseOwnerId, + likerId: userId, + entityId: course._id, + type: 'academy_like' + }) + + res.status(200).json({ + success: true, + message: 'دوره با موفقیت لایک شد', + isLiked: true, + likesCount: course.likes + }) + } catch (error) { + console.error('Error in likeCourseContent:', error) + res.status(500).json({ + success: false, + message: 'خطا در لایک کردن محتوا', + error: error.message + }) + } +} + +// دیسلایک کردن محتوا (حذف لایک) +const dontLikeCourseContent = 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 userId = decodedToken.id + + const { courseId } = req.body + + // بررسی وجود دوره + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(404).json({ + success: false, + message: 'پکیج پیدا نشد' + }) + } + + // پیدا کردن و حذف لایک + const deletedLike = await AcademyLikeModel.findOneAndDelete({ + user_id: userId, + course_id: courseId + }) + + if (!deletedLike) { + return res.status(404).json({ + success: false, + message: 'شما این دوره را لایک نکردهاید' + }) + } + + // کاهش تعداد لایکهای دوره + course.likes = Math.max((course.likes || 0) - 1, 0) // جلوگیری از منفی شدن + await course.save() + + res.status(200).json({ + success: true, + message: 'لایک دوره با موفقیت حذف شد', + isLiked: false, + likesCount: course.likes + }) + } catch (error) { + console.error('Error in dontLikeCourseContent:', error) + res.status(500).json({ + success: false, + message: 'خطا در حذف لایک محتوا', + error: error.message + }) + } +} + +// بررسی وضعیت لایک (آیا کاربر لایک کرده است یا نه) +const isLikeCourseContent = 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 userId = decodedToken.id + + const { courseId } = req.body + + // پیدا کردن لایک + const academyLike = await AcademyLikeModel.findOne({ + user_id: userId, + course_id: courseId + }) + + res.status(200).json({ + success: true, + isLiked: !!academyLike // تبدیل به boolean + }) + } catch (error) { + console.error('Error in isLikeCourseContent:', error) + res.status(500).json({ + success: false, + message: 'خطا در بررسی وضعیت لایک', + error: error.message + }) + } +} + +// دریافت تعداد لایکهای یک دوره +const getCourseLikesCount = async (req, res, next) => { + try { + const { courseId } = req.params + + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(404).json({ + success: false, + message: 'دوره پیدا نشد' + }) + } + + const likesCount = await AcademyLikeModel.countDocuments({ + course_id: courseId + }) + + res.status(200).json({ + success: true, + likesCount, + courseLikes: course.likes || 0 + }) + } catch (error) { + console.error('Error in getCourseLikesCount:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت تعداد لایکها', + error: error.message + }) + } +} + +// دریافت همه دورههایی که کاربر لایک کرده است +const getUserLikedCourses = 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 userId = decodedToken.id + + const page = parseInt(req.query.page) || 1 + const limit = parseInt(req.query.limit) || 10 + const skip = (page - 1) * limit + + const likes = await AcademyLikeModel.find({ user_id: userId }) + .populate('course_id') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + + const total = await AcademyLikeModel.countDocuments({ user_id: userId }) + + res.status(200).json({ + success: true, + data: likes, + pagination: { + currentPage: page, + totalPages: Math.ceil(total / limit), + totalItems: total, + itemsPerPage: limit + } + }) + } catch (error) { + console.error('Error in getUserLikedCourses:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت دورههای لایک شده', + error: error.message + }) + } +} + +// ==================== ساخت کامنت جدید ==================== +const createComment = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { course_id, comment, rate } = req.body + + // اعتبارسنجی + if (!course_id || !comment) { + return res.status(422).json({ + error: true, + message: 'شناسه دوره و متن کامنت الزامی است' + }) + } + + // بررسی وجود دوره + const course = await CourseModel.findById(course_id) + if (!course) { + return res.status(404).json({ + error: true, + message: 'دوره پیدا نشد' + }) + } + + const parsedRate = Number(rate) || 0 + const existingRatedComment = await CourseComment.findOne({ + user_id: userId, + course_id, + rate: { $gt: 0 } + }) + + if (existingRatedComment && parsedRate > 0) { + return res.status(400).json({ + error: true, + message: 'شما قبلاً به این دوره امتیاز دادهاید.' + }) + } + + const finalRate = existingRatedComment ? 0 : parsedRate + + const commentUser = await UserModel.findById(userId) + .select('first_name last_name user_name profile_image is_verified verify_badge') + .lean() + + // ایجاد کامنت جدید + const newComment = await saveCommentWithModerationStatus( + CourseComment, + { + user_id: userId, + course_id, + comment, + rate: finalRate + }, + 'comment', + commentUser?.verify_badge + ) + + // populate کردن اطلاعات کاربر + const commentDoc = await CourseComment.findById(newComment._id).lean() + const populatedComment = { + ...commentDoc, + user_id: commentUser + } + + const courseOwnerId = await resolveCourseOwnerId(course) + if (!courseOwnerId) { + console.warn('createComment: course owner not found for', course_id) + } + + await createCommentNotification({ + io: req.app.get('io'), + ownerId: courseOwnerId, + commenterId: userId, + entityId: course._id, + type: 'academy_comment' + }) + + if (finalRate > 0) { + await createRatingNotification({ + io: req.app.get('io'), + ownerId: courseOwnerId, + raterId: userId, + entityId: course._id, + type: 'academy_rating' + }) + } + + res.status(201).json({ + success: true, + message: 'کامنت با موفقیت ثبت شد', + data: populatedComment + }) + } catch (error) { + console.error('Error in createComment:', error) + if (error?.name === 'ValidationError') { + return res.status(500).json({ + success: false, + message: 'خطا در ثبت کامنت. مدل کامنت روی سرور بهروز نیست.' + }) + } + res.status(500).json({ + success: false, + message: 'خطا در ثبت کامنت', + error: error.message + }) + } +} + +// ==================== دریافت تعداد کل کامنتهای یک دوره ==================== +const getTotalCommentsCount = async (req, res) => { + try { + const { courseId } = req.params + const { status } = req.query // اختیاری: فقط کامنتهای با وضعیت خاص + + const query = { course_id: courseId } + if (status) { + query.status = status + } + + const totalComments = await CourseComment.countDocuments(query) + + // محاسبه میانگین امتیازات + const averageRate = await CourseComment.aggregate([ + { $match: { course_id: courseId, status: 'accepted' } }, + { $group: { _id: null, avgRate: { $avg: '$rate' } } } + ]) + + res.status(200).json({ + success: true, + data: { + totalComments, + averageRate: averageRate[0]?.avgRate || 0, + courseId + } + }) + } catch (error) { + console.error('Error in getTotalCommentsCount:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت تعداد کامنتها', + error: error.message + }) + } +} + +// ==================== دریافت کامنتها با فیلترهای مختلف ==================== +const getCourseComments = async (req, res) => { + try { + const { courseId } = req.params + const { + page = 1, + limit = 10, + status = 'accepted', // 'accepted', 'pending', 'rejected', 'all' + minRate, + maxRate, + sortBy = 'createdAt', + sortOrder = 'desc', + userId // اختیاری: کامنتهای یک کاربر خاص + } = req.query + + // ساخت شرط جستجو + const query = { course_id: courseId } + + // فیلتر بر اساس وضعیت + if (status === 'accepted') { + query.status = commentStatusFilter() + } else if (status !== 'all') { + query.status = status + } else { + query.status = commentStatusFilter() + } + + // فیلتر بر اساس امتیاز + if (minRate || maxRate) { + query.rate = {} + if (minRate) query.rate.$gte = parseInt(minRate) + if (maxRate) query.rate.$lte = parseInt(maxRate) + } + + // فیلتر بر اساس کاربر خاص + if (userId) { + query.user_id = userId + } + + // تنظیمات مرتبسازی + const sortOptions = {} + sortOptions[sortBy] = sortOrder === 'desc' ? -1 : 1 + + // دریافت کامنتها با pagination + const skip = (parseInt(page) - 1) * parseInt(limit) + + const [comments, totalComments] = await Promise.all([ + CourseComment.find(query) + .sort(sortOptions) + .skip(skip) + .limit(parseInt(limit)) + // فیلدهای قبلی (name/username/profileImage) روی این اسکیما اصلاً وجود نداشتن — + // یعنی اطلاعاتِ نویسندهی کامنت همیشه خالی برمیگشت + .populate('user_id', 'user_name first_name last_name profile_image verify_badge is_verified') + .lean(), + CourseComment.countDocuments(query) + ]) + + // بررسی وجود توکن (اختیاری — لیست کامنتها بدون لاگین هم قابل مشاهدهست) + let viewerId = null + const authHeader = req.header('Authorization') + if (authHeader) { + const tokenParts = authHeader.split(' ') + if (tokenParts.length === 2) { + try { + viewerId = jwt.verify(tokenParts[1], process.env.APP_SECRET).id + } catch (err) { + console.error('Invalid Token:', err.message) + } + } + } + + const commenterIds = comments.map((c) => c.user_id?._id).filter(Boolean) + const storyFlags = await resolveStoryFlags(commenterIds, viewerId) + const commentsWithStoryFlags = comments.map((c) => { + if (!c.user_id) return c + const flags = storyFlags[String(c.user_id._id)] || {} + return { + ...c, + user_id: { + ...c.user_id, + has_story: flags.has_story || false, + has_unviewed_story: flags.has_unviewed_story || false + } + } + }) + + // محاسبه آمار کامنتها + const stats = await CourseComment.aggregate([ + { $match: { course_id: courseId, status: 'accepted' } }, + { + $group: { + _id: null, + totalAccepted: { $sum: 1 }, + averageRate: { $avg: '$rate' }, + rateDistribution: { + $push: '$rate' + } + } + } + ]) + + const totalPages = Math.ceil(totalComments / parseInt(limit)) + + res.status(200).json({ + success: true, + message: 'لیست کامنتها با موفقیت دریافت شد', + data: { + comments: commentsWithStoryFlags, + pagination: { + currentPage: parseInt(page), + totalPages, + totalItems: totalComments, + itemsPerPage: parseInt(limit), + hasNextPage: parseInt(page) < totalPages, + hasPrevPage: parseInt(page) > 1 + }, + stats: { + totalComments, + acceptedComments: stats[0]?.totalAccepted || 0, + averageRate: Math.round((stats[0]?.averageRate || 0) * 10) / 10, + rateDistribution: stats[0]?.rateDistribution || [] + } + } + }) + } catch (error) { + console.error('Error in getCourseComments:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت کامنتها', + error: error.message + }) + } +} +// ==================== ویرایش وضعیت کامنت (برای همه قابل استفاده است) ==================== +const updateCommentStatus = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { commentId } = req.params + const { status } = req.body // 'pending', 'accepted', 'rejected' + + // اعتبارسنجی وضعیت + if (!['pending', 'accepted', 'rejected'].includes(status)) { + return res.status(422).json({ + error: true, + message: + 'وضعیت نامعتبر است. وضعیت باید یکی از این موارد باشد: pending, accepted, rejected' + }) + } + + // پیدا کردن کامنت + const comment = await CourseComment.findById(commentId) + if (!comment) { + return res.status(404).json({ + error: true, + message: 'کامنت پیدا نشد' + }) + } + + // بررسی دسترسی: یا خود صاحب کامنت است یا هر کاربر دیگری (طبق درخواست شما که همه بتوانند) + // اگر میخواهید فقط ادمین بتواند وضعیت را تغییر دهد، این بخش را تغییر دهید + const user = await UserModel.findById(userId) + const isOwner = comment.user_id.toString() === userId + const isAdmin = user?.role === 'admin' + + if (!isOwner && !isAdmin) { + return res.status(403).json({ + error: true, + message: 'شما اجازه تغییر وضعیت این کامنت را ندارید' + }) + } + + // به روز رسانی وضعیت + comment.status = status + await comment.save() + + // اگر کامنت تایید شد و امتیاز داشت، میانگین امتیاز دوره را به روز کن + if (status === 'accepted' && comment.rate > 0) { + const averageRate = await CourseComment.aggregate([ + { $match: { course_id: comment.course_id, status: 'accepted' } }, + { $group: { _id: null, avgRate: { $avg: '$rate' } } } + ]) + + await CourseModel.findByIdAndUpdate(comment.course_id, { + averageRate: averageRate[0]?.avgRate || 0 + }) + } + + res.status(200).json({ + success: true, + message: `وضعیت کامنت با موفقیت به ${ + status === 'accepted' + ? 'تایید شده' + : status === 'rejected' + ? 'رد شده' + : 'در انتظار' + } تغییر یافت`, + data: comment + }) + } catch (error) { + console.error('Error in updateCommentStatus:', error) + res.status(500).json({ + success: false, + message: 'خطا در تغییر وضعیت کامنت', + error: error.message + }) + } +} + +// ==================== دریافت کامنتهای یک کاربر خاص ==================== +const getUserComments = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { page = 1, limit = 10, status = 'all', courseId } = req.query + + const query = { user_id: userId } + + if (status !== 'all') { + query.status = status + } + + if (courseId) { + query.course_id = courseId + } + + const skip = (parseInt(page) - 1) * parseInt(limit) + + const [comments, totalComments] = await Promise.all([ + CourseComment.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)) + .populate('course_id', 'cuorse_name packageImage'), + CourseComment.countDocuments(query) + ]) + + const totalPages = Math.ceil(totalComments / parseInt(limit)) + + res.status(200).json({ + success: true, + data: { + comments, + pagination: { + currentPage: parseInt(page), + totalPages, + totalItems: totalComments, + itemsPerPage: parseInt(limit) + } + } + }) + } catch (error) { + console.error('Error in getUserComments:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت کامنتهای کاربر', + error: error.message + }) + } +} + +// ==================== حذف کامنت (اختیاری) ==================== +const deleteComment = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { commentId } = req.params + + const comment = await CourseComment.findById(commentId) + if (!comment) { + return res.status(404).json({ + error: true, + message: 'کامنت پیدا نشد' + }) + } + + // بررسی دسترسی: یا خود صاحب کامنت است یا ادمین + const user = await UserModel.findById(userId) + const isOwner = comment.user_id.toString() === userId + const isAdmin = user?.role === 'admin' + + if (!isOwner && !isAdmin) { + return res.status(403).json({ + error: true, + message: 'شما اجازه حذف این کامنت را ندارید' + }) + } + + await comment.deleteOne() + + res.status(200).json({ + success: true, + message: 'کامنت با موفقیت حذف شد' + }) + } catch (error) { + console.error('Error in deleteComment:', error) + res.status(500).json({ + success: false, + message: 'خطا در حذف کامنت', + error: error.message + }) + } +} +// academyController.js + +const coursePayment = async (req, res, next) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { + return res.status(401).json({ + error: true, + message: 'لطفا وارد حساب کاربری خود شوید' + }) + } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + const { courseId, useWallet } = req.body + + // ========== 1. بررسی وجود دوره ========== + const course = await CourseModel.findById(courseId) + if (!course) { + return res.status(404).json({ + error: true, + message: 'دوره مورد نظر یافت نشد' + }) + } + + // ========== 2. بررسی خرید قبلی ========== + const existingPurchase = await IsPaymentCourse.findOne({ + user_id: userId, + course_id: course._id + }) + + if (existingPurchase) { + return res.status(400).json({ + error: true, + message: 'شما قبلاً این دوره را خریداری کردهاید' + }) + } + + if (course.is_free) { + await IsPaymentCourse.create({ + user_id: userId, + course_id: course._id, + price_paid: 0, + purchased_at: new Date() + }) + + const courseOwnerId = await resolveCourseOwnerId(course) + await createCoursePurchaseNotification({ + ownerId: courseOwnerId, + buyerId: userId, + courseId: course._id + }) + + return res.status(200).json({ + success: true, + free: true, + message: 'پکیج رایگان با موفقیت ثبت شد' + }) + } + + // ========== 3. دریافت نرخ مالیات ========== + let taxRate = 9 // پیشفرض 9 درصد + const tax = await Tax.findOne({ type: 'course' }) + if (tax && tax.tax) { + taxRate = Number(tax.tax) + } + + // ========== 4. محاسبه قیمت ========== + const offerNum = Number(course.offer) || 0 + const priceNum = Number(course.price) + + // قیمت پس از تخفیف + const discountedPrice = (priceNum / 100) * (100 - offerNum) + + // محاسبه مالیات + const taxAmount = discountedPrice * (taxRate / 100) + + // قیمت نهایی با مالیات (تومان) + const finalPrice = discountedPrice + const finalTaxPrice = (discountedPrice / 100) * (100 - taxRate) + + // ========== 5. اعتبارسنجی قیمت ========== + if (!finalPrice || isNaN(finalPrice) || finalPrice <= 0) { + return res.status(422).json({ + error: true, + message: 'مبلغ نامعتبر است' + }) + } + + // ========== 5.۵ بررسی موجودی کیف پول (عین shopOrderPaymentController.js) ========== + const { walletAmount, gatewayAmount, fullyCovered } = await resolveWalletSplit( + userId, + finalPrice, + useWallet + ) + + // کاملاً از کیف پول پوشش داده شد — اصلاً نیازی به درگاه نیست + if (fullyCovered) { + await debitWalletForPurchase(userId, walletAmount, { + referenceType: 'payment', + referenceId: course._id, + description: `خرید پکیج آموزشی «${course.cuorse_name}» از کیف پول` + }) + + const paymentRecord = await AcademyPaymentModel.create({ + price: finalPrice, + discountAmount: priceNum - discountedPrice, + taxAmount: finalTaxPrice, + taxRate, + course_name: course.cuorse_name, + course_id: course._id, + academy_id: course.academyId, + user_id: userId, + walletAmountUsed: walletAmount, + status: 'success' + }) + + await IsPaymentCourse.create({ + user_id: userId, + course_id: course._id, + price_paid: finalPrice, + purchased_at: new Date() + }) + + const courseOwnerId = await resolveCourseOwnerId(course) + await createCoursePurchaseNotification({ + ownerId: courseOwnerId, + buyerId: userId, + courseId: course._id + }) + + if (courseOwnerId) { + const sellerNetAmount = Math.round(finalPrice * (1 - ACADEMY_COMMISSION_RATE)) + if (sellerNetAmount > 0) { + await WalletTransactionModel.create({ + user: courseOwnerId, + bucket: 'academy', + type: 'credit', + amount: sellerNetAmount, + referenceType: 'payment', + referenceId: paymentRecord._id, + description: `درآمد فروش پکیج آموزشی «${course.cuorse_name}» (پس از کسر ۱۵٪ کارمزد)` + }) + } + } + + return res.status(200).json({ success: true, paidFromWallet: true }) + } + + // تبدیل به ریال (ضرب در 10 چون هر تومان = 10 ریال) + const amountInRials = Math.round(gatewayAmount * 10) + + // مبلغ باید حداقل 1000 تومان باشد (10000 ریال) + if (amountInRials < 10000) { + return res.status(422).json({ + error: true, + message: 'مبلغ باید حداقل 1000 تومان باشد' + }) + } + + // ========== 6. ساخت کالبک URL ========== + const platformQs = req.body?.platform === 'android' ? '&platform=android' : '' + const callbackUrl = `https://api.modstagram.com/api/v1/academy/academy/course/payment/verify?courseId=${courseId}&userId=${userId}${platformQs}` + + // ========== 7. درخواست به زرینپال ========== + const zarinpalRes = await axios.post( + 'https://api.zarinpal.com/pg/v4/payment/request.json', + { + merchant_id: process.env.ZARINPAL_MERCHANT_ID, + amount: amountInRials, + description: `خرید دوره ${course.cuorse_name}`, + callback_url: callbackUrl, + metadata: { + user_id: userId, + course_id: courseId, + price: finalPrice, + tax_rate: taxRate + } + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: 90000 + } + ) + + const result = zarinpalRes.data + + // ========== 8. بررسی پاسخ زرینپال ========== + if (result.data && result.data.code === 100) { + // ذخیره اطلاعات پرداخت در حالت pending — walletAmountUsed فقط یادداشت میشه، + // کسرِ واقعی توی verify (بعد از تاییدِ قطعیِ پرداخت) انجام میشه + await AcademyPaymentModel.create({ + price: finalPrice, + discountAmount: priceNum - discountedPrice, + taxAmount: finalTaxPrice, + taxRate, + course_name: course.cuorse_name, + course_id: course._id, + academy_id: course.academyId, + user_id: userId, + payment_authority: result.data.authority, + walletAmountUsed: walletAmount, + status: 'pending' + }) + + return res.status(200).json({ + success: true, + authority: result.data.authority, + paymentUrl: `${buildPaymentPageUrl(result.data.authority)}` + }) + } + + // خطای زرینپال + console.error('ZarinPal error:', result) + return res.status(422).json({ + error: true, + message: result.errors?.message || 'خطا در ارتباط با درگاه پرداخت', + details: result.errors + }) + } catch (error) { + console.error( + 'Error in coursePayment:', + error.response?.data || error.message + ) + res.status(500).json({ + error: true, + message: 'خطا در پردازش پرداخت', + details: error.response?.data || error.message + }) + } +} + +// نتیجهی پرداختِ پکیج: اندروید به دیپلینکِ اپ برمیگرده، وب به صفحهی پکیج +const redirectAcademyResult = (req, res, courseId, payment, rest = '') => { + if (req.query.platform === 'android') { + const host = payment === 'success' ? 'SuccessPay' : 'FailedPay' + return res.redirect(`modstagram://${host}?type=academy&courseId=${encodeURIComponent(courseId || '')}`) + } + return res.redirect(`${process.env.APP_SITE}/academy/${courseId || ''}/course?payment=${payment}${rest}`) +} + +const CoursePaymentCallback = async (req, res) => { + try { + const { Authority, Status, courseId, userId } = req.query + + // ========== 1. بررسی وضعیت پرداخت ========== + if (Status !== 'OK') { + return redirectAcademyResult(req, res, courseId, 'failed', '&message=payment_canceled') + } + + // ========== 2. پیدا کردن دوره ========== + const course = await CourseModel.findById(courseId) + if (!course) { + return redirectAcademyResult(req, res, courseId, 'failed', '&message=course_not_found') + } + + // ========== 3. پیدا کردن رکورد پرداخت ========== + const paymentRecord = await AcademyPaymentModel.findOne({ + payment_authority: Authority, + course_id: courseId, + user_id: userId + }) + + if (!paymentRecord) { + return redirectAcademyResult(req, res, courseId, 'failed', '&message=payment_not_found') + } + + // ========== 4. محاسبه مبلغ — فقط سهمِ درگاه (walletAmountUsed تا همینجا فقط + // یادداشت شده، کیف پول دستنخورده مونده — عین shopOrderPaymentController.js) ========== + const walletAmount = paymentRecord.walletAmountUsed || 0 + const gatewayAmount = paymentRecord.price - walletAmount + const amountInRials = Math.round(gatewayAmount * 10) + + // ========== 5. تایید پرداخت با زرینپال ========== + const verify = await axios.post( + 'https://api.zarinpal.com/pg/v4/payment/verify.json', + { + merchant_id: process.env.ZARINPAL_MERCHANT_ID, + authority: Authority, + amount: amountInRials + }, + { + headers: { 'Content-Type': 'application/json' }, + timeout: 10000 + } + ) + + const data = verify.data.data + + // ========== 6. بررسی نتیجه تایید ========== + if (data.code === 100) { + // درگاه فقط بخشِ باقیمونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ + // کیف پول هم واقعاً کسر میشه + await debitWalletForPurchase(userId, walletAmount, { + referenceType: 'payment', + referenceId: paymentRecord._id, + description: `خرید پکیج آموزشی «${paymentRecord.course_name}» از کیف پول` + }) + + // بهروزرسانی رکورد پرداخت + await AcademyPaymentModel.findByIdAndUpdate(paymentRecord._id, { + status: 'success', + payment_ref_id: data.ref_id + }) + + // ثبت خرید کاربر + await IsPaymentCourse.create({ + user_id: userId, + course_id: course._id, + price_paid: paymentRecord.price, + purchased_at: new Date() + }) + + const courseOwnerId = await resolveCourseOwnerId(course) + await createCoursePurchaseNotification({ + ownerId: courseOwnerId, + buyerId: userId, + courseId: course._id + }) + + if (courseOwnerId) { + const sellerNetAmount = Math.round( + paymentRecord.price * (1 - ACADEMY_COMMISSION_RATE) + ) + if (sellerNetAmount > 0) { + await WalletTransactionModel.create({ + user: courseOwnerId, + bucket: 'academy', + type: 'credit', + amount: sellerNetAmount, + referenceType: 'payment', + referenceId: paymentRecord._id, + description: `درآمد فروش پکیج آموزشی «${course.title || ''}» (پس از کسر ۱۵٪ کارمزد)` + }) + } + } + + // هدایت به صفحه موفقیت + return redirectAcademyResult(req, res, courseId, 'success', '') + } + + // پرداخت ناموفق + await AcademyPaymentModel.findByIdAndUpdate(paymentRecord._id, { + status: 'failed' + }) + + return redirectAcademyResult(req, res, courseId, 'failed', `&code=${data.code}`) + } catch (err) { + console.error('Error in CoursePaymentCallback:', err) + return redirectAcademyResult(req, res, courseId, 'failed', '&message=verification_error') + } +} +const getUserPurchasedCourses = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const user_id = decodedToken.id + + const { page = 1, limit = 10 } = req.query + + // پیدا کردن تمام رکوردهای پرداخت برای این کاربر + const payments = await IsPaymentCourse.find({ user_id }) + .sort({ createdAt: -1 }) // جدیدترین اول + .skip((page - 1) * limit) + .limit(parseInt(limit)) + + // گرفتن اطلاعات کامل دورهها + const courseIds = payments.map((payment) => payment.course_id) + + const courses = await CourseModel.find({ + _id: { $in: courseIds }, + status: coursePublicStatusFilter() // فقط دورههای تایید شده + }) + + // محاسبه تعداد کل + const total = await IsPaymentCourse.countDocuments({ user_id }) + + res.status(200).json({ + success: true, + data: { + courses, + pagination: { + total, + page: parseInt(page), + pages: Math.ceil(total / limit), + limit: parseInt(limit) + } + } + }) + } catch (error) { + console.error('Error in getUserPurchasedCourses:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت دورههای خریداری شده', + error: error.message + }) + } +} + +// API با populate (روش بهتر) +const getUserPurchasedCoursesWithPopulate = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const user_id = decodedToken.id + + const { page = 1, limit = 10 } = req.query + + const options = { + page: parseInt(page), + limit: parseInt(limit), + sort: { createdAt: -1 }, + populate: { + path: 'course_id', + model: 'Course', + match: { status: coursePublicStatusFilter() } + } + } + + const result = await IsPaymentCourse.paginate( + { user_id }, + options + ) + + // فیلتر کردن دورههایی که populate شدهاند و وجود دارند + const validCourses = result.docs + .filter((doc) => doc.course_id !== null) + .map((doc) => doc.course_id) + + res.status(200).json({ + success: true, + data: { + courses: validCourses, + pagination: { + total: result.totalDocs, + page: result.page, + totalPages: result.totalPages, + limit: result.limit, + hasNextPage: result.hasNextPage, + hasPrevPage: result.hasPrevPage + } + } + }) + } catch (error) { + console.error('Error:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت دورههای خریداری شده' + }) + } +} + +const getAllAcademyPayments = async (req, res) => { + try { + // 1. بررسی توکن + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { + return res.status(401).json({ + success: false, + message: 'دسترسی غیرمجاز' + }) + } + + // 2. verify توکن + let decodedToken + try { + decodedToken = jwt.verify(token, process.env.APP_SECRET) + } catch (jwtError) { + return res.status(401).json({ + success: false, + message: 'توکن نامعتبر است' + }) + } + + const userId = decodedToken.id + + // 3. پیدا کردن آکادمی + const academy = await AcademyModel.findOne({ userId }) + + if (!academy) { + return res.status(404).json({ + success: false, + message: 'آکادمی برای این کاربر یافت نشد' + }) + } + + const academy_id = academy._id + const { page = 1, limit = 10, status } = req.query + + // 4. ساخت فیلتر + const filter = { academy_id } + + if ( + status && + ['pending', 'success', 'failed', 'settled'].includes(status) + ) { + filter.status = status + } + + // 5. تنظیمات صفحهبندی + const pageNum = parseInt(page) + const limitNum = parseInt(limit) + const skip = (pageNum - 1) * limitNum + + const options = { + page: pageNum, + limit: limitNum, + sort: { createdAt: -1 }, + populate: [ + { + path: 'course_id', + model: 'Course', + select: 'cuorse_name price course_image category teacher_name' + }, + { + path: 'user_id', + model: 'User', + select: 'user_name profile_image email phone' + } + ] + } + + // 6. دریافت پرداختها + const result = await AcademyPaymentModel.paginate(filter, options) + + // 7. محاسبه آمار دقیقتر + let totalAmount = 0 + let pendingCount = 0 + let acceptedCount = 0 + + if (result.docs && Array.isArray(result.docs)) { + totalAmount = result.docs.reduce((sum, p) => { + const price = parseInt(p.taxAmount) || 0 + return sum + price + }, 0) + + pendingCount = result.docs.filter((p) => p.status === 'pending').length + acceptedCount = result.docs.filter((p) => p.status === 'accept').length + } + + // 8. پاسخ نهایی + res.status(200).json({ + success: true, + data: { + academy: { + _id: academy._id, + academy_name: academy.academy_name, + academy_image: academy.academy_image, + bio: academy.bio + }, + payments: result.docs || [], + pagination: { + total: result.totalDocs || 0, + page: result.page || pageNum, + totalPages: result.totalPages || 1, + limit: result.limit || limitNum, + hasNextPage: result.hasNextPage || false, + hasPrevPage: result.hasPrevPage || false + }, + stats: { + totalAmount, + pendingCount, + acceptedCount + } + } + }) + } catch (error) { + console.error('Error in getAllAcademyPayments:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت لیست پرداختها', + error: process.env.NODE_ENV === 'development' ? error.message : undefined + }) + } +} + +const ANALYTICS_RANGE_DAYS = { '7d': 7, '30d': 30, '90d': 90, all: null } + +/** Revenue-over-time + best-selling courses for the seller's Academy — backs the "Academy" analytics tab. */ +const getAcademyAnalytics = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) return res.status(401).json({ success: false, message: 'دسترسی غیرمجاز' }) + + let decodedToken + try { + decodedToken = jwt.verify(token, process.env.APP_SECRET) + } catch { + return res.status(401).json({ success: false, message: 'توکن نامعتبر است' }) + } + + const academy = await AcademyModel.findOne({ userId: decodedToken.id }) + if (!academy) { + return res.json({ + range: req.query.range || '30d', + series: [], + totals: { totalRevenue: 0, totalCount: 0 }, + topItems: [], + rating: { average: academy?.rate || 0, count: academy?.number_of_rate || 0 } + }) + } + + const range = ANALYTICS_RANGE_DAYS[req.query.range] !== undefined ? req.query.range : '30d' + const days = ANALYTICS_RANGE_DAYS[range] + const since = days ? new Date(Date.now() - days * 24 * 60 * 60 * 1000) : new Date(0) + + const match = { + academy_id: academy._id, + status: { $in: ['success', 'settled'] }, + createdAt: { $gte: since } + } + + const [series, totalsAgg, bestCoursesAgg] = await Promise.all([ + AcademyPaymentModel.aggregate([ + { $match: match }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, + revenue: { $sum: '$price' }, + count: { $sum: 1 } + } + }, + { $sort: { _id: 1 } } + ]), + AcademyPaymentModel.aggregate([ + { $match: match }, + { $group: { _id: null, totalRevenue: { $sum: '$price' }, totalCount: { $sum: 1 } } } + ]), + AcademyPaymentModel.aggregate([ + { $match: match }, + { $group: { _id: '$course_id', revenue: { $sum: '$price' }, count: { $sum: 1 } } }, + { $sort: { revenue: -1 } }, + { $limit: 5 } + ]) + ]) + + const courseIds = bestCoursesAgg.map((row) => row._id).filter(Boolean) + const courses = courseIds.length + ? await CourseModel.find({ _id: { $in: courseIds } }).select('cuorse_name course_image') + : [] + const courseMap = new Map(courses.map((c) => [String(c._id), c])) + + const topItems = bestCoursesAgg + .filter((row) => row._id && courseMap.has(String(row._id))) + .map((row) => { + const course = courseMap.get(String(row._id)) + return { + id: String(row._id), + title: course.cuorse_name, + image: course.course_image || null, + metric: row.revenue + } + }) + + return res.json({ + range, + series, + totals: totalsAgg[0] || { totalRevenue: 0, totalCount: 0 }, + topItems, + rating: { average: academy.rate || 0, count: academy.number_of_rate || 0 } + }) + } catch (error) { + console.error('Error in getAcademyAnalytics:', error) + res.status(500).json({ success: false, message: 'خطا در دریافت آنالیتیکس آموزشگاه' }) + } +} + +const checkCoursePurchase = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) { return res.status(401).json({ error: true, message: 'Access Denied' }) } + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + const { courseId } = req.params + + // بررسی وجود کاربر و دوره + if (!userId || !courseId) { + return res.status(400).json({ + success: false, + message: 'userId و courseId الزامی هستند' + }) + } + + // جستجو در دیتابیس + const purchase = await IsPaymentCourse.findOne({ + user_id: userId, + course_id: courseId + }) + + // نتیجه + if (purchase) { + return res.status(200).json({ + success: true, + isPurchased: true, + message: 'این دوره قبلاً خریداری شده است', + purchaseData: purchase + }) + } else { + return res.status(200).json({ + success: true, + isPurchased: false, + message: 'این دوره خریداری نشده است' + }) + } + } catch (error) { + console.error('Error in checkCoursePurchase:', error) + res.status(500).json({ + success: false, + message: 'خطا در بررسی خرید دوره', + error: error.message + }) + } +} + +const getAcademyCourse = async (req, res, next) => { + try { + const { Id } = req.params + + // گرفتن پارامترهای صفحهبندی + const page = parseInt(req.query.page) || 1 + const limit = parseInt(req.query.limit) || 10 + const skip = (page - 1) * limit + + const academy = await AcademyModel.findById(Id) + if (!academy) { + return res.status(404).json({ + success: false, + message: 'آکادمی پیدا نشد' + }) + } + + // ✅ شرط جستجو - فقط دورههای تایید شده + const query = { + academyId: academy._id, + status: coursePublicStatusFilter() + } + + // دریافت تعداد کل دورههای تایید شده + const totalCourses = await CourseModel.countDocuments(query) + + // دریافت دورههای تایید شده با صفحهبندی + const courses = await CourseModel.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + + // ✅ اگر دورهای وجود نداشت، خطا نده، بلکه آرایه خالی برگردون + if (!courses || courses.length === 0) { + return res.status(200).json({ + success: true, + message: 'هیچ دوره تایید شدهای یافت نشد', + data: { + courses: [], + pagination: { + currentPage: page, + totalPages: 0, + totalItems: 0, + itemsPerPage: limit, + hasNextPage: false, + hasPrevPage: false, + nextPage: null, + prevPage: null + } + } + }) + } + + // محاسبات صفحهبندی + const totalPages = Math.ceil(totalCourses / limit) + const hasNextPage = page < totalPages + const hasPrevPage = page > 1 + + res.status(200).json({ + success: true, + message: 'لیست پکیجها با موفقیت دریافت شد', + data: { + courses, + pagination: { + currentPage: page, + totalPages, + totalItems: totalCourses, + itemsPerPage: limit, + hasNextPage, + hasPrevPage, + nextPage: hasNextPage ? page + 1 : null, + prevPage: hasPrevPage ? page - 1 : null + } + } + }) + } catch (error) { + console.error('Error:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت پکیجها', + error: error.message + }) + } +} + +// ==================== مدیریت پرداختها ==================== +const getAllPayments = async (req, res) => { + try { + const { page = 1, limit = 20, status, startDate, endDate } = req.query + const skip = (parseInt(page) - 1) * parseInt(limit) + + const query = {} + if (status) query.status = status + if (startDate && endDate) { + query.createdAt = { + $gte: new Date(startDate), + $lte: new Date(endDate) + } + } + + const payments = await AcademyPaymentModel.find(query) + .populate('user_id', 'user_name profile_image email') + .populate('course_id', 'cuorse_name price course_image') + .populate('academy_id', 'academy_name academy_image sheba') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)) + + const total = await AcademyPaymentModel.countDocuments(query) + + // آمار کلی پرداختها + const stats = await AcademyPaymentModel.aggregate([ + { $match: query }, + { + $group: { + _id: null, + totalAmount: { $sum: { $toDouble: '$price' } }, + totalCount: { $sum: 1 }, + pendingCount: { + $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } + }, + settledCount: { + $sum: { $cond: [{ $eq: ['$status', 'settled'] }, 1, 0] } + }, + successCount: { + $sum: { $cond: [{ $eq: ['$status', 'success'] }, 1, 0] } + } + } + } + ]) + + res.status(200).json({ + success: true, + data: { + payments, + stats: stats[0] || { + totalAmount: 0, + totalCount: 0, + pendingCount: 0, + settledCount: 0, + successCount: 0 + }, + pagination: { + currentPage: parseInt(page), + totalPages: Math.ceil(total / parseInt(limit)), + totalItems: total, + itemsPerPage: parseInt(limit) + } + } + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در دریافت پرداختها' }) + } +} + +const updatePaymentStatus = async (req, res) => { + try { + const { paymentId, status } = req.body + + const payment = await AcademyPaymentModel.findByIdAndUpdate( + paymentId, + { status, updatedAt: new Date() }, + { new: true } + ) + + if (!payment) { + return res + .status(404) + .json({ success: false, message: 'پرداخت یافت نشد' }) + } + + res.status(200).json({ + success: true, + message: 'وضعیت پرداخت با موفقیت تغییر کرد', + data: payment + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در تغییر وضعیت پرداخت' }) + } +} + +// ==================== مدیریت دورهها ==================== +const getAllCourses = async (req, res) => { + try { + const { page = 1, limit = 20, status, type, category, search } = req.query + const skip = (parseInt(page) - 1) * parseInt(limit) + + const query = {} + if (status) query.status = status + if (type) query.type = type + if (category) query.category = category + if (search) { + query.$or = [ + { cuorse_name: { $regex: search, $options: 'i' } }, + { caption: { $regex: search, $options: 'i' } }, + { teacher_name: { $regex: search, $options: 'i' } } + ] + } + + const courses = await CourseModel.find(query) + .populate('academyId', 'academy_name academy_image') + .populate('user_id', 'user_name profile_image') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)) + + const total = await CourseModel.countDocuments(query) + + // آمار دورهها + const stats = await CourseModel.aggregate([ + { $match: query }, + { + $group: { + _id: null, + totalCourses: { $sum: 1 }, + totalPrice: { $sum: { $toDouble: '$price' } }, + normalCount: { + $sum: { $cond: [{ $eq: ['$type', 'normal'] }, 1, 0] } + }, + proCount: { $sum: { $cond: [{ $eq: ['$type', 'pro'] }, 1, 0] } }, + legendCount: { + $sum: { $cond: [{ $eq: ['$type', 'legend'] }, 1, 0] } + }, + pendingCount: { + $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } + }, + acceptCount: { + $sum: { $cond: [{ $eq: ['$status', 'accept'] }, 1, 0] } + } + } + } + ]) + + res.status(200).json({ + success: true, + data: { + courses, + stats: stats[0] || {}, + pagination: { + currentPage: parseInt(page), + totalPages: Math.ceil(total / parseInt(limit)), + totalItems: total, + itemsPerPage: parseInt(limit) + } + } + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در دریافت دورهها' }) + } +} + +const updateCourseStatus = async (req, res) => { + try { + const { courseId, status, type } = req.body + + console.log(courseId, status, type) + + const course = await CourseModel.findById(courseId) + + if (!course) { + return res.status(404).json({ success: false, message: 'دوره یافت نشد' }) + } + + if (status) course.status = status + if (type) course.type = type + await course.save() + + res.status(200).json({ + success: true, + message: 'دوره با موفقیت بهروزرسانی شد', + data: course, + courseId, + status, + type + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در بهروزرسانی دوره' }) + } +} + +const admindeleteCourse = async (req, res) => { + try { + const { courseId } = req.params + + const course = await CourseModel.findByIdAndDelete(courseId) + + if (!course) { + return res.status(404).json({ success: false, message: 'دوره یافت نشد' }) + } + + // حذف تمام پرداختهای مرتبط با این دوره + await AcademyPaymentModel.deleteMany({ course_id: courseId }) + + res.status(200).json({ + success: true, + message: 'دوره با موفقیت حذف شد' + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در حذف دوره' }) + } +} + +// ==================== مدیریت دورههای خریداری شده ==================== +const getAllPurchasedCourses = async (req, res) => { + try { + const { page = 1, limit = 20, userId, courseId, status } = req.query + const skip = (parseInt(page) - 1) * parseInt(limit) + + const query = {} + if (userId) query.user_id = userId + if (courseId) query.course_id = courseId + if (status) query.status = status + + const purchases = await IsPaymentCourse.find(query) + .populate('user_id', 'user_name profile_image email phone') + .populate('course_id', 'cuorse_name price course_image teacher_name') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)) + + const total = await IsPaymentCourse.countDocuments(query) + + // آمار خریدها + const stats = await IsPaymentCourse.aggregate([ + { $match: query }, + { + $group: { + _id: null, + totalPurchases: { $sum: 1 }, + uniqueUsers: { $addToSet: '$user_id' } + } + } + ]) + + res.status(200).json({ + success: true, + data: { + purchases, + stats: { + totalPurchases: stats[0]?.totalPurchases || 0, + uniqueUsers: stats[0]?.uniqueUsers?.length || 0 + }, + pagination: { + currentPage: parseInt(page), + totalPages: Math.ceil(total / parseInt(limit)), + totalItems: total, + itemsPerPage: parseInt(limit) + } + } + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در دریافت دورههای خریداری شده' }) + } +} + +// ==================== مدیریت آکادمیها ==================== +const getAllAcademies = async (req, res) => { + try { + const { page = 1, limit = 20, search } = req.query + const skip = (parseInt(page) - 1) * parseInt(limit) + + const query = {} + if (search) { + query.academy_name = { $regex: search, $options: 'i' } + } + + const academies = await AcademyModel.find(query) + .populate('userId', 'user_name profile_image email') + .sort({ createdAt: -1 }) + .skip(skip) + .limit(parseInt(limit)) + + const total = await AcademyModel.countDocuments(query) + + // آمار آکادمیها + const stats = await AcademyModel.aggregate([ + { + $group: { + _id: null, + totalAcademies: { $sum: 1 }, + totalRate: { $sum: '$rate' }, + avgRate: { $avg: '$rate' } + } + } + ]) + + res.status(200).json({ + success: true, + data: { + academies, + stats: stats[0] || {}, + pagination: { + currentPage: parseInt(page), + totalPages: Math.ceil(total / parseInt(limit)), + totalItems: total, + itemsPerPage: parseInt(limit) + } + } + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در دریافت آکادمیها' }) + } +} + +const updateAcademyStatus = async (req, res) => { + try { + const { academyId, status, rate, sheba } = req.body + + const updateData = {} + if (status) updateData.status = status + if (rate) updateData.rate = rate + if (sheba) updateData.sheba = sheba + + const academy = await AcademyModel.findByIdAndUpdate( + academyId, + updateData, + { new: true } + ) + + if (!academy) { + return res + .status(404) + .json({ success: false, message: 'آکادمی یافت نشد' }) + } + + res.status(200).json({ + success: true, + message: 'آکادمی با موفقیت بهروزرسانی شد', + data: academy + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در بهروزرسانی آکادمی' }) + } +} + +// ==================== مدیریت تنظیمات (تکس و قیمت پرو) ==================== +const getSettings = async (req, res) => { + try { + let settings = await SettingModel.findOne() + + if (!settings) { + settings = await SettingModel.create({ + taxRate: 9, + proPrice: 99000, + legendPrice: 199000, + minWithdrawAmount: 50000, + maxWithdrawAmount: 10000000 + }) + } + + res.status(200).json({ + success: true, + data: settings + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در دریافت تنظیمات' }) + } +} + +const updateSettings = async (req, res) => { + try { + const { + taxRate, + proPrice, + legendPrice, + minWithdrawAmount, + maxWithdrawAmount + } = req.body + + const settings = await SettingModel.findOneAndUpdate( + {}, + { + taxRate, + proPrice, + legendPrice, + minWithdrawAmount, + maxWithdrawAmount, + updatedAt: new Date() + }, + { new: true, upsert: true } + ) + + res.status(200).json({ + success: true, + message: 'تنظیمات با موفقیت بهروزرسانی شد', + data: settings + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در بهروزرسانی تنظیمات' }) + } +} + +const getCoursePrices = async (req, res) => { + try { + const proPrice = await CoursePaymentModel.findOne({ cuorse_type: 'pro' }) + + const legendPrice = await CoursePaymentModel.findOne({ + cuorse_type: 'legend' + }) + + const normalPrice = await CoursePaymentModel.findOne({ + cuorse_type: 'normal' + }) + + res.status(200).json({ + success: true, + data: { + pro: proPrice + ? { price: proPrice.price, _id: proPrice._id } + : { price: '60000' }, + legend: legendPrice + ? { price: legendPrice.price, _id: legendPrice._id } + : { price: '199000' }, + normal: normalPrice + ? { price: normalPrice.price, _id: normalPrice._id } + : { price: '0' } + } + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در دریافت قیمت دورهها' }) + } +} + +const updateCoursePrice = async (req, res) => { + try { + const { courseType, price } = req.body + + if (!courseType || !price) { + return res + .status(400) + .json({ success: false, message: 'نوع دوره و قیمت الزامی است' }) + } + + let coursePayment = await CoursePaymentModel.findOne({ + cuorse_type: courseType + }) + + if (coursePayment) { + coursePayment.price = price.toString() + await coursePayment.save() + } else { + coursePayment = new CoursePaymentModel({ + price: price.toString(), + cuorse_type: courseType + }) + await coursePayment.save() + } + + res.status(200).json({ + success: true, + message: `قیمت دوره ${ + courseType === 'pro' + ? 'پرو' + : courseType === 'legend' + ? 'لجند' + : 'معمولی' + } با موفقیت بهروزرسانی شد`, + data: coursePayment + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در بهروزرسانی قیمت دوره' }) + } +} + +const getTax = async (req, res) => { + try { + let tax = await Tax.findOne({ type: 'course' }) + + if (!tax) { + tax = new Tax({ tax: 8, type: 'course' }) + await tax.save() + } + + res.status(200).json({ + success: true, + data: { + tax: tax.tax, + _id: tax._id, + type: tax.type + } + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در دریافت مالیات' }) + } +} + +const updateTax = async (req, res) => { + try { + const { tax } = req.body + + if (tax === undefined || tax === null) { + return res + .status(400) + .json({ success: false, message: 'مقدار مالیات الزامی است' }) + } + + let taxRecord = await Tax.findOne({ type: 'course' }) + + if (taxRecord) { + taxRecord.tax = tax + await taxRecord.save() + } else { + taxRecord = new Tax({ tax, type: 'course' }) + await taxRecord.save() + } + + res.status(200).json({ + success: true, + message: 'مالیات با موفقیت بهروزرسانی شد', + data: taxRecord + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در بهروزرسانی مالیات' }) + } +} + +const getAllSettings = async (req, res) => { + try { + const proPrice = await CoursePaymentModel.findOne({ cuorse_type: 'pro' }) + const legendPrice = await CoursePaymentModel.findOne({ + cuorse_type: 'legend' + }) + + let tax = await Tax.findOne({ type: 'course' }) + if (!tax) { + tax = new Tax({ tax: 8, type: 'course' }) + await tax.save() + } + + res.status(200).json({ + success: true, + data: { + tax: tax.tax, + prices: { + pro: proPrice ? proPrice.price : '60000', + legend: legendPrice ? legendPrice.price : '199000' + } + } + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در دریافت تنظیمات' }) + } +} + +const updateAllSettings = async (req, res) => { + try { + const { tax, proPrice, legendPrice } = req.body + + if (tax !== undefined) { + let taxRecord = await Tax.findOne({ type: 'course' }) + if (taxRecord) { + taxRecord.tax = tax + await taxRecord.save() + } else { + taxRecord = new Tax({ tax, type: 'course' }) + await taxRecord.save() + } + } + + if (proPrice !== undefined) { + let proRecord = await CoursePaymentModel.findOne({ cuorse_type: 'pro' }) + if (proRecord) { + proRecord.price = proPrice.toString() + await proRecord.save() + } else { + proRecord = new CoursePaymentModel({ + price: proPrice.toString(), + cuorse_type: 'pro' + }) + await proRecord.save() + } + } + + if (legendPrice !== undefined) { + let legendRecord = await CoursePaymentModel.findOne({ + cuorse_type: 'legend' + }) + if (legendRecord) { + legendRecord.price = legendPrice.toString() + await legendRecord.save() + } else { + legendRecord = new CoursePaymentModel({ + price: legendPrice.toString(), + cuorse_type: 'legend' + }) + await legendRecord.save() + } + } + + res.status(200).json({ + success: true, + message: 'تنظیمات با موفقیت بهروزرسانی شد' + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در بهروزرسانی تنظیمات' }) + } +} + +const getPaymentStats = async (req, res) => { + try { + const stats = await AcademyPaymentModel.aggregate([ + { + $group: { + _id: null, + totalAmount: { $sum: { $toDouble: '$price' } }, + totalCount: { $sum: 1 }, + pendingCount: { + $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } + }, + settledCount: { + $sum: { $cond: [{ $eq: ['$status', 'settled'] }, 1, 0] } + }, + successCount: { + $sum: { $cond: [{ $eq: ['$status', 'success'] }, 1, 0] } + } + } + } + ]) + + const result = stats[0] || { + totalAmount: 0, + totalCount: 0, + pendingCount: 0, + settledCount: 0, + successCount: 0 + } + + result.averageAmount = + result.totalCount > 0 + ? Math.round(result.totalAmount / result.totalCount) + : 0 + + res.status(200).json({ + success: true, + data: result + }) + } catch (error) { + console.error('Error in getPaymentStats:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت آمار پرداختها', + error: process.env.NODE_ENV === 'development' ? error.message : undefined + }) + } +} + +const getCourseById = async (req, res) => { + try { + const { id } = req.params + const course = await CourseModel.findById(id) + .populate('academyId', 'academy_name academy_image') + .populate('user_id', 'user_name profile_image') + + if (!course) { + return res.status(404).json({ success: false, message: 'دوره یافت نشد' }) + } + + res.status(200).json({ + success: true, + data: course + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در دریافت دوره' }) + } +} + +const createCourse = async (req, res) => { + try { + const courseData = req.body + const newCourse = new CourseModel(courseData) + await newCourse.save() + + res.status(201).json({ + success: true, + message: 'دوره با موفقیت ایجاد شد', + data: newCourse + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در ایجاد دوره' }) + } +} + +const getCourseStats = async (req, res) => { + try { + const stats = await CourseModel.aggregate([ + { + $group: { + _id: null, + totalCourses: { $sum: 1 }, + normalCount: { + $sum: { $cond: [{ $eq: ['$type', 'normal'] }, 1, 0] } + }, + proCount: { $sum: { $cond: [{ $eq: ['$type', 'pro'] }, 1, 0] } }, + legendCount: { + $sum: { $cond: [{ $eq: ['$type', 'legend'] }, 1, 0] } + }, + pendingCount: { + $sum: { $cond: [{ $eq: ['$status', 'pending'] }, 1, 0] } + }, + acceptCount: { + $sum: { $cond: [{ $eq: ['$status', 'accept'] }, 1, 0] } + }, + rejectCount: { + $sum: { $cond: [{ $eq: ['$status', 'reject'] }, 1, 0] } + } + } + } + ]) + + res.status(200).json({ + success: true, + data: stats[0] || {} + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در دریافت آمار دورهها' }) + } +} + +const getPurchasedStats = async (req, res) => { + try { + const stats = await IsPaymentCourse.aggregate([ + { + $group: { + _id: null, + totalPurchases: { $sum: 1 }, + uniqueUsers: { $addToSet: '$user_id' } + } + } + ]) + + res.status(200).json({ + success: true, + data: { + totalPurchases: stats[0]?.totalPurchases || 0, + uniqueUsers: stats[0]?.uniqueUsers?.length || 0 + } + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در دریافت آمار خریدها' }) + } +} + +const getAcademyById = async (req, res) => { + try { + const { id } = req.params + const academy = await AcademyModel.findById(id).populate( + 'userId', + 'user_name profile_image email' + ) + + if (!academy) { + return res + .status(404) + .json({ success: false, message: 'آکادمی یافت نشد' }) + } + + res.status(200).json({ + success: true, + data: academy + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در دریافت آکادمی' }) + } +} + +const createAcademy = async (req, res) => { + try { + const academyData = req.body + const newAcademy = new AcademyModel(academyData) + await newAcademy.save() + + res.status(201).json({ + success: true, + message: 'آکادمی با موفقیت ایجاد شد', + data: newAcademy + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در ایجاد آکادمی' }) + } +} + +const updateAcademy = async (req, res) => { + try { + const { id } = req.params + const updateData = req.body + + const academy = await AcademyModel.findByIdAndUpdate(id, updateData, { + new: true + }) + + if (!academy) { + return res + .status(404) + .json({ success: false, message: 'آکادمی یافت نشد' }) + } + + res.status(200).json({ + success: true, + message: 'آکادمی با موفقیت بهروزرسانی شد', + data: academy + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در بهروزرسانی آکادمی' }) + } +} + +const deleteAcademy = async (req, res) => { + try { + const { id } = req.params + const academy = await AcademyModel.findByIdAndDelete(id) + + if (!academy) { + return res + .status(404) + .json({ success: false, message: 'آکادمی یافت نشد' }) + } + + await CourseModel.deleteMany({ academyId: id }) + + res.status(200).json({ + success: true, + message: 'آکادمی با موفقیت حذف شد' + }) + } catch (error) { + console.error(error) + res.status(500).json({ success: false, message: 'خطا در حذف آکادمی' }) + } +} + +const getAcademyStats = async (req, res) => { + try { + const stats = await AcademyModel.aggregate([ + { + $group: { + _id: null, + totalAcademies: { $sum: 1 }, + totalRate: { $sum: '$rate' }, + avgRate: { $avg: '$rate' } + } + } + ]) + + res.status(200).json({ + success: true, + data: stats[0] || { + totalAcademies: 0, + totalRate: 0, + avgRate: 0 + } + }) + } catch (error) { + console.error(error) + res + .status(500) + .json({ success: false, message: 'خطا در دریافت آمار آکادمیها' }) + } +} +const deleteCourseVideo = 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 userId = decodedToken.id + + const { courseContentId, courseId } = req.body + const courseContent = await AcademyContentModel.findById(courseContentId) + + if (!courseContent) { + return res.status(404).json({ + error: true, + message: 'ویدعو یافت نشد' + }) + } + + courseContent.status = 'reject' + + await courseContent.save() + + const course = await CourseModel.findById(courseId) + if (course) { + course.number_of_course_content = `${ + Number(course.number_of_course_content) - 1 + }` + await course.save() + } + + res.status(200).json({ + success: true, + message: 'ویدیو با موفقیت پاک شد' + }) + } catch (error) { + console.error('Error:', error) + res.status(500).json({ + success: false, + message: 'خطا در حذف ویدیو', + error: error.message + }) + } +} + +const getChunkDir = (uploadKey) => { + return path.join(process.cwd(), 'storage', 'temp-chunks', uploadKey) +} + +setInterval(async () => { + const tempDir = path.join(process.cwd(), 'storage', 'temp-chunks') + if (await fs.pathExists(tempDir)) { + const dirs = await fs.readdir(tempDir) + const now = Date.now() + for (const dir of dirs) { + const dirPath = path.join(tempDir, dir) + const stat = await fs.stat(dirPath) + if (now - stat.mtimeMs > 30 * 60 * 1000) { + // 30 دقیقه + await fs.remove(dirPath) + console.log(`Cleaned up old upload: ${dir}`) + } + } + } +}, 60 * 60 * 1000) + +const creatCourseVideoChunk = async (req, res, next) => { + console.log('=== Chunk Video Upload ===') + console.log('Chunk index:', req.body.chunkIndex) + + 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 userId = decodedToken.id + + const { + courseId, + is_free, + file_name, + chunk, + chunkIndex, + totalChunks, + originalFileName, + fileSize, + mime_type + } = req.body + + if (!chunk) { + return res.status(422).json({ + error: true, + message: 'تکه فایل ارسال نشده است' + }) + } + + if (!courseId) { + return res.status(422).json({ + error: true, + message: 'شناسه دوره الزامی است' + }) + } + + const base64Data = chunk.split(',')[1] || chunk + const buffer = Buffer.from(base64Data, 'base64') + + const uploadKey = `${courseId}_${originalFileName}` + const chunkDir = getChunkDir(uploadKey) + + await fs.ensureDir(chunkDir) + + const metaPath = path.join(chunkDir, 'meta.json') + if (chunkIndex === '0' || chunkIndex === 0) { + const metaData = { + totalChunks: parseInt(totalChunks), + file_name, + originalFileName, + fileSize, + mime_type, + courseId, + is_free, + createdAt: Date.now() + } + await fs.writeJson(metaPath, metaData) + } + + if (!(await fs.pathExists(metaPath))) { + return res.status(404).json({ + error: true, + message: 'جلسه آپلود یافت نشد' + }) + } + + const chunkPath = path.join(chunkDir, `chunk_${chunkIndex}`) + await fs.writeFile(chunkPath, buffer) + + console.log( + `✅ Chunk ${parseInt(chunkIndex) + 1}/${totalChunks} saved to disk (${ + buffer.length + } bytes)` + ) + + res.status(200).json({ + success: true, + message: `تکه ${ + parseInt(chunkIndex) + 1 + } از ${totalChunks} با موفقیت دریافت شد`, + chunkIndex, + totalChunks + }) + } catch (error) { + console.error('Error in creatCourseVideoChunk:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت تکه ویدیو', + error: error.message + }) + } +} + +const mergeVideoChunks = async (req, res, next) => { + console.log('=== Merge Video Chunks ===') + + 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 userId = decodedToken.id + + const { courseId, originalFileName, totalChunks } = req.body + + const uploadKey = `${courseId}_${originalFileName}` + const chunkDir = getChunkDir(uploadKey) + const metaPath = path.join(chunkDir, 'meta.json') + + if (!(await fs.pathExists(chunkDir)) || !(await fs.pathExists(metaPath))) { + return res.status(404).json({ + error: true, + message: 'دادههای آپلود یافت نشد' + }) + } + + const metaData = await fs.readJson(metaPath) + + const existingChunks = await fs.readdir(chunkDir) + const chunkFiles = existingChunks.filter((f) => f.startsWith('chunk_')) + + if (chunkFiles.length !== parseInt(totalChunks)) { + return res.status(422).json({ + error: true, + message: `تعداد تکهها کامل نیست. دریافت شده: ${chunkFiles.length}، مورد نیاز: ${totalChunks}` + }) + } + + const chunks = [] + for (let i = 0; i < parseInt(totalChunks); i++) { + const chunkPath = path.join(chunkDir, `chunk_${i}`) + if (!(await fs.pathExists(chunkPath))) { + return res.status(422).json({ + error: true, + message: `تکه ${i + 1} پیدا نشد` + }) + } + const chunkBuffer = await fs.readFile(chunkPath) + chunks.push(chunkBuffer) + } + + const completeBuffer = Buffer.concat(chunks) + console.log('Original file size:', completeBuffer.length, 'bytes') + + const finalVideoDir = path.join( + process.cwd(), + 'storage', + 'courses', + 'videos' + ) + await fs.ensureDir(finalVideoDir) + + let extension = '.mp4' + if (metaData.mime_type === 'video/mp4') extension = '.mp4' + else if (metaData.mime_type === 'video/quicktime') extension = '.mov' + else if (metaData.mime_type === 'video/x-msvideo') extension = '.avi' + else if (metaData.mime_type === 'video/webm') extension = '.webm' + + const safeFileName = `course-${courseId}-${Date.now()}${extension}` + const filePath = path.join(finalVideoDir, safeFileName) + + await fs.writeFile(filePath, completeBuffer) + console.log('Original file saved:', filePath) + + const uploaderUser = await UserModel.findById(userId).select('verify_badge').lean() + + const newAcademyContent = new AcademyContentModel({ + course_video: `/storage/courses/videos/${safeFileName}`, + type: 'video', + is_free: metaData.is_free === 'true' || metaData.is_free === true, + courseId, + file_name: metaData.file_name || metaData.originalFileName, + status: isVerifiedBadge(uploaderUser?.verify_badge) ? 'temp_accept' : 'pending' + }) + + await newAcademyContent.save() + + const course = await CourseModel.findById(courseId) + if (course) { + course.number_of_course_content = `${ + Number(course.number_of_course_content) + 1 + }` + await course.save() + } + + await fs.remove(chunkDir) + + res.status(200).json({ + success: true, + message: 'ویدیو با موفقیت ثبت شد (در حال پردازش...)', + data: newAcademyContent + }) + + if (completeBuffer.length > 20 * 1024 * 1024) { + // اجرای غیرهمزمان بدون await + compressVideoInBackground(filePath, safeFileName, newAcademyContent._id) + } + } catch (error) { + console.error('Error in mergeVideoChunks:', error) + res.status(500).json({ + success: false, + message: 'خطا در ترکیب ویدیو', + error: error.message + }) + } +} + +const compressVideoInBackground = async (filePath, fileName, contentId) => { + console.log(`Starting background compression for ${fileName}`) + + const dir = path.dirname(filePath) + const tempPath = path.join(dir, `temp-${fileName}`) + const { exec } = require('child_process') + const util = require('util') + const execPromise = util.promisify(exec) + + try { + await execPromise( + `ffmpeg -i "${filePath}" -c:v libx264 -crf 28 -preset veryfast -c:a aac -b:a 128k -movflags +faststart "${tempPath}" -y` + ) + + const compressedStat = await fs.stat(tempPath) + const originalStat = await fs.stat(filePath) + + if (compressedStat.size < originalStat.size) { + await fs.rename(tempPath, filePath) + console.log( + `✅ Background compression done: ${( + originalStat.size / + 1024 / + 1024 + ).toFixed(2)}MB → ${(compressedStat.size / 1024 / 1024).toFixed(2)}MB` + ) + } else { + await fs.remove(tempPath) + console.log("Compression didn't reduce size, keeping original") + } + } catch (error) { + console.error('Background compression failed:', error.message) + if (await fs.pathExists(tempPath)) { + await fs.remove(tempPath) + } + } +} + +const getUploadStatus = async (req, res) => { + try { + const { courseId, originalFileName } = req.query + const uploadKey = `${courseId}_${originalFileName}` + const chunkDir = getChunkDir(uploadKey) + + if (!(await fs.pathExists(chunkDir))) { + return res.status(200).json({ + success: true, + uploadedChunks: [], + totalChunks: 0 + }) + } + + const files = await fs.readdir(chunkDir) + const chunkFiles = files + .filter((f) => f.startsWith('chunk_')) + .map((f) => parseInt(f.replace('chunk_', ''))) + .sort((a, b) => a - b) + + const metaPath = path.join(chunkDir, 'meta.json') + let totalChunks = 0 + if (await fs.pathExists(metaPath)) { + const metaData = await fs.readJson(metaPath) + totalChunks = metaData.totalChunks + } + + res.status(200).json({ + success: true, + uploadedChunks: chunkFiles, + totalChunks + }) + } catch (error) { + res.status(500).json({ success: false, error: error.message }) + } +} + +// ایجاد slug یکتا +const generateUniqueSlug = async (title, excludeId = null) => { + const slug = title + .replace(/[^\u0600-\u06FF\uFB8A\u067E\u0686\u06AF\u200C\uFB8E\u0698a-zA-Z0-9\s]/g, '') + .trim() + .replace(/\s+/g, '-') + .toLowerCase() + + let uniqueSlug = slug + let counter = 1 + + while (true) { + const query = { slug: uniqueSlug } + if (excludeId) query._id = { $ne: excludeId } + const exists = await AcademyCategoryModel.findOne(query) + if (!exists) break + uniqueSlug = `${slug}-${counter}` + counter++ + } + + return uniqueSlug +} + +// ==================== GET - دریافت لیست دستهبندیها ==================== +const getAll = async (req, res) => { + try { + const { + page = 1, + limit = 20, + sortBy = 'order', + sortOrder = 'asc', + status, + parent, + search, + is_featured, + withChildren = 'false' + } = req.query + + const query = {} + + if (status) query.status = status + if (parent !== undefined) query.parent = parent === 'null' ? null : parent + if (is_featured === 'true') query.is_featured = true + + if (search) { + query.$or = [ + { title: { $regex: search, $options: 'i' } }, + { description: { $regex: search, $options: 'i' } } + ] + } + + const sortOptions = {} + sortOptions[sortBy] = sortOrder === 'asc' ? 1 : -1 + + const options = { + page: parseInt(page), + limit: parseInt(limit), + sort: sortOptions, + populate: [ + { path: 'parent', select: 'title slug' }, + { path: 'created_by', select: 'user_name name' }, + { path: 'updated_by', select: 'user_name name' } + ] + } + + let categories = await AcademyCategoryModel.paginate(query, options) + + // دریافت درخت دستهبندی (اختیاری) + if (withChildren === 'true' && !parent) { + const tree = await AcademyCategoryModel.getTree() + categories = { ...categories, tree } + } + + res.status(200).json({ + success: true, + message: 'لیست دستهبندیها با موفقیت دریافت شد', + data: categories + }) + } catch (error) { + console.error('Error in getAll:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت دستهبندیها', + error: error.message + }) + } +} + +// ==================== GET - دریافت یک دستهبندی ==================== +const getOne = async (req, res) => { + try { + const { id } = req.params + const { includeCourses = 'false' } = req.query + + let query = AcademyCategoryModel.findById(id) + + query = query.populate('parent', 'title slug icon color') + query = query.populate('children', 'title slug icon color course_count order') + query = query.populate('created_by', 'user_name name') + + if (includeCourses === 'true') { + query = query.populate({ + path: 'courses', + select: 'cuorse_name course_image price offer', + options: { limit: 10 } + }) + } + + const category = await query + + if (!category) { + return res.status(404).json({ + success: false, + message: 'دستهبندی یافت نشد' + }) + } + + res.status(200).json({ + success: true, + message: 'دستهبندی با موفقیت دریافت شد', + data: category + }) + } catch (error) { + console.error('Error in getOne:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت دستهبندی', + error: error.message + }) + } +} + +// ==================== GET - دریافت درخت دستهبندی ==================== +const getTree = async (req, res) => { + try { + const tree = await AcademyCategoryModel.getTree() + + res.status(200).json({ + success: true, + message: 'درخت دستهبندی با موفقیت دریافت شد', + data: tree + }) + } catch (error) { + console.error('Error in getTree:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت درخت دستهبندی', + error: error.message + }) + } +} + +// ==================== GET - دریافت توسط اسلاگ ==================== +const getBySlug = async (req, res) => { + try { + const { slug } = req.params + const { page = 1, limit = 20 } = req.query + + const category = await AcademyCategoryModel.getBySlug(slug) + + if (!category) { + return res.status(404).json({ + success: false, + message: 'دستهبندی یافت نشد' + }) + } + + // دریافت دورههای این دسته + const courses = await CourseModel.find({ + category_id: category._id, + status: 'accept' + }) + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(parseInt(limit)) + + const totalCourses = await CourseModel.countDocuments({ + category_id: category._id, + status: 'accept' + }) + + res.status(200).json({ + success: true, + message: 'دستهبندی با موفقیت دریافت شد', + data: { + category, + courses: { + data: courses, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: totalCourses, + pages: Math.ceil(totalCourses / limit) + } + } + } + }) + } catch (error) { + console.error('Error in getBySlug:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت دستهبندی', + error: error.message + }) + } +} + +// ==================== POST - ایجاد دستهبندی جدید ==================== +const create = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { + title, + description, + icon, + color, + parent, + order, + image, + is_featured, + seo_title, + seo_description + } = req.body + + // اعتبارسنجی + if (!title || title.trim().length < 2) { + return res.status(422).json({ + success: false, + message: 'عنوان دستهبندی باید حداقل ۲ کاراکتر باشد' + }) + } + + // بررسی تکراری نبودن عنوان + const existingTitle = await AcademyCategoryModel.findOne({ title }) + if (existingTitle) { + return res.status(409).json({ + success: false, + message: 'این عنوان قبلاً ثبت شده است' + }) + } + + // تولید slug یکتا + const slug = await generateUniqueSlug(title) + + // بررسی وجود parent + let level = 0 + if (parent) { + const parentCategory = await AcademyCategoryModel.findById(parent) + if (!parentCategory) { + return res.status(404).json({ + success: false, + message: 'دستهبندی والد یافت نشد' + }) + } + level = parentCategory.level + 1 + + if (level > 3) { + return res.status(422).json({ + success: false, + message: 'عمق دستهبندی نمیتواند بیشتر از ۳ سطح باشد' + }) + } + } + + const newCategory = new AcademyCategoryModel({ + title, + slug, + description: description || null, + icon: icon || null, + color: color || '#3B82F6', + parent: parent || null, + level, + order: order || 0, + image: image || null, + is_featured: is_featured || false, + seo_title: seo_title || title, + seo_description: seo_description || description, + created_by: userId, + status: 'active' + }) + + await newCategory.save() + + res.status(201).json({ + success: true, + message: 'دستهبندی با موفقیت ایجاد شد', + data: newCategory + }) + } catch (error) { + console.error('Error in create:', error) + res.status(500).json({ + success: false, + message: 'خطا در ایجاد دستهبندی', + error: error.message + }) + } +} + +// ==================== PUT/PATCH - ویرایش دستهبندی ==================== +const update = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { id } = req.params + const updateData = req.body + + const category = await AcademyCategoryModel.findById(id) + if (!category) { + return res.status(404).json({ + success: false, + message: 'دستهبندی یافت نشد' + }) + } + + // بررسی تکراری نبودن عنوان + if (updateData.title && updateData.title !== category.title) { + const existingTitle = await AcademyCategoryModel.findOne({ + title: updateData.title, + _id: { $ne: id } + }) + if (existingTitle) { + return res.status(409).json({ + success: false, + message: 'این عنوان قبلاً ثبت شده است' + }) + } + + // آپدیت slug + updateData.slug = await generateUniqueSlug(updateData.title, id) + } + + // بررسی parent + if (updateData.parent) { + if (updateData.parent === id) { + return res.status(422).json({ + success: false, + message: 'یک دستهبندی نمیتواند والد خودش باشد' + }) + } + + const parentCategory = await AcademyCategoryModel.findById(updateData.parent) + if (!parentCategory) { + return res.status(404).json({ + success: false, + message: 'دستهبندی والد یافت نشد' + }) + } + + updateData.level = parentCategory.level + 1 + + if (updateData.level > 3) { + return res.status(422).json({ + success: false, + message: 'عمق دستهبندی نمیتواند بیشتر از ۳ سطح باشد' + }) + } + } + + updateData.updated_by = userId + updateData.updatedAt = new Date() + + const updatedCategory = await AcademyCategoryModel.findByIdAndUpdate( + id, + updateData, + { new: true, runValidators: true } + ) + + res.status(200).json({ + success: true, + message: 'دستهبندی با موفقیت بهروزرسانی شد', + data: updatedCategory + }) + } catch (error) { + console.error('Error in update:', error) + res.status(500).json({ + success: false, + message: 'خطا در بهروزرسانی دستهبندی', + error: error.message + }) + } +} + +// ==================== DELETE - حذف (سافت دیلیت) ==================== +const remove = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { id } = req.params + const { permanent = 'false' } = req.query + + const category = await AcademyCategoryModel.findById(id) + if (!category) { + return res.status(404).json({ + success: false, + message: 'دستهبندی یافت نشد' + }) + } + + // بررسی وجود زیردستهها + const hasChildren = await AcademyCategoryModel.exists({ parent: id }) + if (hasChildren) { + return res.status(422).json({ + success: false, + message: 'این دستهبندی دارای زیردسته است. ابتدا زیردستهها را حذف کنید' + }) + } + + // بررسی وجود دوره در این دسته + const hasCourses = await CourseModel.exists({ category_id: id }) + if (hasCourses && permanent === 'true') { + return res.status(422).json({ + success: false, + message: 'این دستهبندی دارای دوره است. ابتدا دورهها را جابهجا کنید' + }) + } + + if (permanent === 'true') { + // حذف فیزیکی + await category.deleteOne() + res.status(200).json({ + success: true, + message: 'دستهبندی با موفقیت حذف شد' + }) + } else { + // سافت دیلیت + category.status = 'deleted' + category.updated_by = userId + await category.save() + + res.status(200).json({ + success: true, + message: 'دستهبندی با موفقیت غیرفعال شد' + }) + } + } catch (error) { + console.error('Error in remove:', error) + res.status(500).json({ + success: false, + message: 'خطا در حذف دستهبندی', + error: error.message + }) + } +} + +// ==================== POST - تغییر وضعیت (اکتیو/غیراکتیو) ==================== +const toggleStatus = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + if (!token) return res.status(401).json({ success: false, message: 'Access Denied' }) + + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userId = decodedToken.id + + const { id } = req.params + const { status } = req.body // 'active' or 'inactive' + + if (!['active', 'inactive'].includes(status)) { + return res.status(422).json({ + success: false, + message: 'وضعیت نامعتبر است' + }) + } + + const category = await AcademyCategoryModel.findByIdAndUpdate( + id, + { + status, + updated_by: userId, + updatedAt: new Date() + }, + { new: true } + ) + + if (!category) { + return res.status(404).json({ + success: false, + message: 'دستهبندی یافت نشد' + }) + } + + res.status(200).json({ + success: true, + message: `وضعیت دستهبندی با موفقیت به ${status === 'active' ? 'فعال' : 'غیرفعال'} تغییر کرد`, + data: category + }) + } catch (error) { + console.error('Error in toggleStatus:', error) + res.status(500).json({ + success: false, + message: 'خطا در تغییر وضعیت دستهبندی', + error: error.message + }) + } +} + +// ==================== POST - افزایش تعداد دورهها ==================== +const incrementCourseCount = async (req, res) => { + try { + const { id } = req.params + const { increment = 1 } = req.body + + const category = await AcademyCategoryModel.incrementCourseCount(id, increment) + + if (!category) { + return res.status(404).json({ + success: false, + message: 'دستهبندی یافت نشد' + }) + } + + res.status(200).json({ + success: true, + message: 'تعداد دورهها بهروزرسانی شد', + data: { course_count: category.course_count } + }) + } catch (error) { + console.error('Error in incrementCourseCount:', error) + res.status(500).json({ + success: false, + message: 'خطا در بهروزرسانی تعداد دورهها', + error: error.message + }) + } +} + +// ==================== GET - دریافت دستهبندیهای ویژه (featured) ==================== +const getFeatured = async (req, res) => { + try { + const { limit = 10 } = req.query + + const categories = await AcademyCategoryModel.find({ + is_featured: true, + status: 'active' + }) + .sort({ order: 1 }) + .limit(parseInt(limit)) + .populate('parent', 'title slug') + + res.status(200).json({ + success: true, + message: 'دستهبندیهای ویژه با موفقیت دریافت شد', + data: categories + }) + } catch (error) { + console.error('Error in getFeatured:', error) + res.status(500).json({ + success: false, + message: 'خطا در دریافت دستهبندیهای ویژه', + error: error.message + }) + } +} + +const getFreeAcademyExploreContent = async (req, res, next) => { + try { + const page = parseInt(req.query.page, 10) || 1 + const limit = parseInt(req.query.limit, 10) || 15 + const skip = (page - 1) * limit + + const freeCourses = await CourseModel.find({ is_free: true }) + .select('_id') + .lean() + const freeCourseIds = freeCourses.map((course) => String(course._id)) + + const query = { + status: academyContentPublicStatusFilter(), + $or: [ + { is_free: true }, + { type: 'video', courseId: { $in: freeCourseIds } } + ] + } + + const totalItems = await AcademyContentModel.countDocuments(query) + const contents = await AcademyContentModel.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(limit) + .lean() + + const courseIds = [ + ...new Set(contents.map((item) => item.courseId).filter(Boolean)) + ] + const courses = courseIds.length + ? await CourseModel.find({ _id: { $in: courseIds } }) + .select( + 'cuorse_name course_image teacher_name academyId user_id caption likes category' + ) + .lean() + : [] + const courseMap = Object.fromEntries( + courses.map((course) => [String(course._id), course]) + ) + + const userIds = [ + ...new Set(courses.map((course) => String(course.user_id)).filter(Boolean)) + ] + const users = userIds.length + ? await UserModel.find({ _id: { $in: userIds } }) + .select('user_name first_name last_name profile_image') + .lean() + : [] + const userMap = Object.fromEntries( + users.map((user) => [String(user._id), user]) + ) + + const mapContentItem = (content) => { + const course = courseMap[String(content.courseId)] || {} + const user = userMap[String(course.user_id)] || {} + return { + ...content, + course_name: course.cuorse_name || content.file_name || '', + course_image: course.course_image || '', + teacher_name: course.teacher_name || '', + academyId: course.academyId || '', + user_name: user.user_name || '', + first_name: user.first_name || '', + last_name: user.last_name || '', + profile_image: user.profile_image || '', + likesCount: Array.isArray(course.likes) ? course.likes.length : 0 + } + } + + let items = contents.map(mapContentItem) + + // بلند کردن محتوای مرتبط با علایق کاربر (فقط صفحه اول، بدون فیلتر دستی) + if (page === 1) { + try { + const token = req.header('Authorization')?.split(' ')[1] + const viewerId = token ? jwt.verify(token, process.env.APP_SECRET)?.id : null + if (viewerId) { + const interestWeights = await buildUnifiedInterestScore(viewerId) + const tags = Object.keys(interestWeights).filter((tag) => interestWeights[tag] > 0) + if (tags.length) { + const interestCategories = await AcademyCategoryModel.find({ + relatedExpertise: { $in: tags } + }) + .select('title') + .lean() + const categoryTitles = interestCategories.map((c) => c.title) + if (categoryTitles.length) { + const interestCourseIds = ( + await CourseModel.find({ category: { $in: categoryTitles } }).select('_id').lean() + ).map((c) => String(c._id)) + if (interestCourseIds.length) { + const existingContentIds = items.map((i) => i._id) + const interestContents = await AcademyContentModel.find({ + ...query, + courseId: { $in: interestCourseIds }, + _id: { $nin: existingContentIds } + }) + .sort({ createdAt: -1 }) + .limit(5) + .lean() + if (interestContents.length) { + const extraCourseIds = interestContents + .map((c) => String(c.courseId)) + .filter((id) => !courseMap[id]) + if (extraCourseIds.length) { + const extraCourses = await CourseModel.find({ _id: { $in: extraCourseIds } }) + .select('cuorse_name course_image teacher_name academyId user_id caption likes category') + .lean() + extraCourses.forEach((course) => { + courseMap[String(course._id)] = course + }) + const extraUserIds = extraCourses + .map((c) => String(c.user_id)) + .filter((id) => id && !userMap[id]) + if (extraUserIds.length) { + const extraUsers = await UserModel.find({ _id: { $in: extraUserIds } }) + .select('user_name first_name last_name profile_image') + .lean() + extraUsers.forEach((user) => { + userMap[String(user._id)] = user + }) + } + } + const mappedInterestContents = interestContents.map(mapContentItem) + items = blendInterestIntoRanked( + items, + [...items, ...mappedInterestContents], + 0.25 + ).slice(0, limit) + } + } + } + } + } + } catch { + /* non-blocking: personalization is a bonus, never break the listing */ + } + } + + const totalPages = Math.ceil(totalItems / limit) || 1 + + return res.status(200).json({ + success: true, + data: { + items, + pagination: { + currentPage: page, + totalPages, + totalItems, + hasNextPage: page < totalPages + } + } + }) + } catch (error) { + console.error('Error in getFreeAcademyExploreContent:', error) + next(error) + } +} + +module.exports = { + // ========================================== + // 🏢 ماژول آکادمی (Academy) + // ========================================== + + // پیدا کردن آکادمی کاربر جاری + findAcademy, + + // پیدا کردن آکادمی با شناسه + findAcademyById, + + // تکمیل و ویرایش پروفایل آکادمی + academyProfile, + + // تغییر وضعیت آکادمی (فعال/غیرفعال - فقط ادمین) + updateAcademyStatus, + + // دریافت اطلاعات یک آکادمی با شناسه + getAcademyById, + + // ایجاد آکادمی جدید (دستی - معمولاً خودکار ایجاد میشود) + createAcademy, + + // ویرایش اطلاعات آکادمی + updateAcademy, + + // حذف آکادمی (به همراه تمام دورههای آن) + deleteAcademy, + + // دریافت آمار کلی آکادمیها (تعداد، میانگین امتیاز و...) + getAcademyStats, + + // ========================================== + // 📚 ماژول دورهها (Course) + // ========================================== + + // ایجاد دوره جدید + creatCourse, + + // ویرایش اطلاعات دوره + updateCourse, + + // حذف دوره (تغییر وضعیت به reject) + deleteCourse, + + // دریافت لیست دورههای آکادمی جاری + getCourse, + + // دریافت لیست همه دورهها با فیلتر و جستجو + getCourses, + + // محتوای رایگان آموزشگاه برای اکسپلور + getFreeAcademyExploreContent, + + // دریافت دورههای یک آکادمی خاص + getAcademyCourse, + + // آپلود ویدیو با روش Multipart + creatCourseVideo, + + // آپلود ویدیو با روش Base64 (برای فایلهای کوچک) + creatCourseVideoBase64, + + // ========================================== + // 💳 ماژول پرداخت (Payment) + // ========================================== + + // پرداخت برای اشتراک پرو + proPayment, + + // کالبک درگاه پرداخت زرینپال (اشتراک پرو) + handleCoursePaymentCallback, + + // پرداخت برای خرید دوره + coursePayment, + + // کالبک درگاه پرداخت زرینپال (خرید دوره) + CoursePaymentCallback, + + // ========================================== + // 🎬 ماژول محتوای دوره (Course Content) + // ========================================== + + // دریافت محتوای دوره (لیست ویدیوها و فایلها) + getCourseContent, + + // حذف ویدیو از دوره + deleteCourseVideo, + + // لایک کردن محتوا + likeCourseContent, + + // حذف لایک (دیسلایک) + dontLikeCourseContent, + + // دریافت تعداد لایکهای یک دوره + getCourseLikesCount, + + // دریافت لیست دورههای لایک شده توسط کاربر + getUserLikedCourses, + + // بررسی وضعیت لایک کاربر (آیا لایک کرده یا نه) + isLikeCourseContent, + + // ========================================== + // 💬 ماژول کامنتها (Comments) + // ========================================== + + // ایجاد کامنت جدید + createComment, + + // دریافت تعداد کل کامنتهای یک دوره + getTotalCommentsCount, + + // دریافت لیست کامنتهای یک دوره (با فیلتر و صفحهبندی) + getCourseComments, + + // تغییر وضعیت کامنت (تایید/رد/در انتظار) + updateCommentStatus, + + // دریافت کامنتهای یک کاربر خاص + getUserComments, + + // حذف کامنت + deleteComment, + + // ========================================== + // 🛒 ماژول دورههای خریداری شده (Purchased) + // ========================================== + + // دریافت دورههای خریداری شده توسط کاربر جاری + getUserPurchasedCourses, + + // دریافت دورههای خریداری شده (با populate - روش بهینه) + getUserPurchasedCoursesWithPopulate, + + // دریافت همه پرداختهای آکادمی + getAllAcademyPayments, + getAcademyAnalytics, + + // بررسی خرید یک دوره توسط کاربر (برای صفحه دوره) + checkCoursePurchase, + + // ========================================== + // 👑 ماژول مدیریتی (Admin Panel) + // ========================================== + + // ------ مدیریت پرداختها ------ + + // دریافت همه پرداختهای سیستم (ادمین) + getAllPayments, + + // تغییر وضعیت پرداخت (pending/success/settled/failed) + updatePaymentStatus, + + // ------ مدیریت دورهها ------ + + // دریافت همه دورههای سیستم (ادمین) + getAllCourses, + + // تغییر وضعیت دوره (تایید/رد/در انتظار) + updateCourseStatus, + + // حذف کامل دوره از سیستم (ادمین) + admindeleteCourse, + + // ------ مدیریت دورههای خریداری شده ------ + + // دریافت همه خریدهای سیستم (ادمین) + getAllPurchasedCourses, + + // ------ مدیریت آکادمیها ------ + + // دریافت همه آکادمیهای سیستم (ادمین) + getAllAcademies, + + // ------ مدیریت تنظیمات ------ + + // دریافت تنظیمات (تکس، قیمتها و...) + getSettings, + + // بهروزرسانی تنظیمات + updateSettings, + + // دریافت قیمت دورهها (پرو، لجند، معمولی) + getCoursePrices, + + // بهروزرسانی قیمت یک نوع دوره + updateCoursePrice, + + // دریافت درصد مالیات + getTax, + + // بهروزرسانی درصد مالیات + updateTax, + + // دریافت همه تنظیمات یکجا + getAllSettings, + + // بهروزرسانی همه تنظیمات یکجا + updateAllSettings, + + // ========================================== + // 📊 ماژول آمار (Stats) + // ========================================== + + // دریافت آمار پرداختها + getPaymentStats, + + // دریافت اطلاعات یک دوره با شناسه + getCourseById, + + // ایجاد دوره جدید (ادمین) + createCourse, + + // ویرایش دوره (ادمین) + updateCourse, + + // دریافت آمار کلی دورهها + getCourseStats, + + // دریافت آمار دورههای خریداری شده + getPurchasedStats, + + // ========================================== + // 📹 ماژول آپلود تکهتکه (Chunk Upload) + // ========================================== + + // دریافت تکه ویدیو (هر بار یک تکه) + creatCourseVideoChunk, + + // ادغام تکهها بعد از اتمام آپلود + mergeVideoChunks, + + // بررسی وضعیت آپلود (برای ادامه از جای قطع شده) + getUploadStatus, + + // ========================================== + // 🏷️ ماژول دستهبندی آکادمی (Category) + // ========================================== + + // دریافت لیست دستهبندیها (با فیلتر و صفحهبندی) + getAll, + + // دریافت یک دستهبندی با شناسه + getOne, + + // دریافت درخت دستهبندی (ساختار سلسلهمراتبی والد-فرزند) + getTree, + + // دریافت دستهبندی با اسلاگ (برای سئو و لینکهای زیبا) + getBySlug, + + // ایجاد دستهبندی جدید + create, + + // ویرایش دستهبندی + update, + + // حذف دستهبندی (سافت دیلیت - فقط غیرفعال میشود) + remove, + + // تغییر وضعیت دستهبندی (فعال/غیرفعال) + toggleStatus, + + // افزایش تعداد دورههای دستهبندی + incrementCourseCount, + + // دریافت دستهبندیهای ویژه (برای نمایش در صفحه اصلی) + getFeatured +} diff --git a/controllers/application/ads/adsController.js b/controllers/application/ads/adsController.js index 4570eeb..2613dc2 100644 --- a/controllers/application/ads/adsController.js +++ b/controllers/application/ads/adsController.js @@ -8,6 +8,7 @@ const { resolveAdPrice } = require('../../../utils/adPricing') const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') const { deliverNotificationAd, trackAdEvent, findActiveAd, buildAdLinkedContentItem } = require('../../../utils/adDelivery') const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity') +const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage') const SPONSORED_TYPES = ['project', 'product', 'shop', 'billboard'] @@ -220,7 +221,7 @@ const createAd = async (req, res, next) => { success: true, adId: ad._id, authority: result.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + paymentUrl: `${buildPaymentPageUrl(result.authority)}` }) } diff --git a/controllers/application/bookings/bookingPaymentController.js b/controllers/application/bookings/bookingPaymentController.js index d5d77ef..18fe1f6 100644 --- a/controllers/application/bookings/bookingPaymentController.js +++ b/controllers/application/bookings/bookingPaymentController.js @@ -3,6 +3,7 @@ const BookingModel = require('../../../models/BookingModel') const PaymentModel = require('../../../models/PaymentModel') const { notifyUser } = require('../../../utils/notifyUser') const { BOOKING_COMMISSION_RATE, creditBookingShare } = require('../../../utils/bookingCommission') +const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage') const apiKey = process.env.ZARINPAL_MERCHANT_ID || @@ -59,7 +60,7 @@ const initiateBookingPayment = async (req, res) => { await booking.save() return res.status(200).json({ authority: result.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + paymentUrl: `${buildPaymentPageUrl(result.authority)}` }) } diff --git a/controllers/application/gifts/giftController.js b/controllers/application/gifts/giftController.js index b48b3e8..72b932f 100644 --- a/controllers/application/gifts/giftController.js +++ b/controllers/application/gifts/giftController.js @@ -6,6 +6,7 @@ const UserModel = require('../../../models/UserModel') const PaymentModel = require('../../../models/PaymentModel') const WalletTransactionModel = require('../../../models/WalletTransactionModel') const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') +const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage') const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE const GIFT_MIN_AMOUNT = 2000 @@ -123,7 +124,7 @@ const initiateGiftPayment = async (req, res, next) => { if (result?.code === 100 && result?.authority) { return res.json({ authority: result.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + paymentUrl: `${buildPaymentPageUrl(result.authority)}` }) } @@ -320,7 +321,7 @@ const initiateChatGift = async (req, res, next) => { return res.json({ paidFromWallet: false, authority: result.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + paymentUrl: `${buildPaymentPageUrl(result.authority)}` }) } diff --git a/controllers/application/offer/offerController.js b/controllers/application/offer/offerController.js index d1ca2c4..d4cb2ff 100644 --- a/controllers/application/offer/offerController.js +++ b/controllers/application/offer/offerController.js @@ -53,8 +53,14 @@ const getOfferTypes = async (req, res, next) => { ] } - const canFree = - (user.monthly_free_offer || 0) > 0 && canUseFreeCreation(user) + // رایگان بودن درخواست به «گیرنده» بستگی دارد: اگر گیرنده «دریافت هدیه» را غیرفعال + // کرده باشه، فرستنده (با سهمیهی رایگانِ خودش) میتونه درخواست رایگان بده + const receiverForFree = req.query.userId + ? await UserModel.findById(req.query.userId).select('gift_enabled offer_payment_enabled').lean() + : null + // رایگان وقتی فعاله که گیرنده «دریافت هدیه» یا «دریافت پرداختی از چت» رو غیرفعال کرده باشه + const freeEligible = receiverForFree?.gift_enabled === false || receiverForFree?.offer_payment_enabled === false + const canFree = freeEligible && canUseFreeCreation(user) // رایگان را حذف نکن — اگر مجاز نیست، با فلگ disabled برگردان تا UI نشان دهد offerTypes = offerTypes.map((t) => { @@ -63,8 +69,8 @@ const getOfferTypes = async (req, res, next) => { plain.disabled = !canFree plain.unavailableReason = canFree ? null - : (user.monthly_free_offer || 0) <= 0 - ? 'سهمیه رایگان ماهانه شما تمام شده است' + : !freeEligible + ? 'برای درخواست رایگان، دریافت هدیه از چت را در تنظیمات حساب غیرفعال کنید' : 'سقف ثبت رایگان شما به پایان رسیده است' } return plain @@ -206,19 +212,15 @@ const handleSuccessfulOfferPayment = async (req, res) => { // اگر گیرنده «دریافت پرداختی از چت» رو غیرفعال کرده باشه، درخواست همکاری براش // همیشه رایگانه — مستقل از سهمیهی رایگان ماهانهی فرستنده - const receiverOffersFree = reciverUser?.offer_payment_enabled === false + const receiverFreeEligible = reciverUser?.gift_enabled === false || reciverUser?.offer_payment_enabled === false let price - if (receiverOffersFree) { - price = 0 - } else if (user.monthly_free_offer > 0 && offerType === 'free' && canUseFreeCreation(user)) { + if (offerType === 'free' && receiverFreeEligible && canUseFreeCreation(user)) { const consumed = await consumeFreeCreation(user) if (!consumed.ok) { return res.status(403).json(freeCreationLimitResponse()) } price = 0 - user.monthly_free_offer -= 1 - await user.save() } else if (offerType === 'free') { return res.status(403).json({ error: true, diff --git a/controllers/application/payment/paymentController.js b/controllers/application/payment/paymentController.js index 7b716b6..2d01c92 100644 --- a/controllers/application/payment/paymentController.js +++ b/controllers/application/payment/paymentController.js @@ -21,9 +21,15 @@ const { creditProjectShare } = require('../../../utils/projectCommission') const { isVerifiedBadge } = require('../../../utils/verifyBadge') +const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage') const apiKey = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE // Replace with your ZarinPal API Key +// زرینپال callback رو فقط وقتی قبول میکنه که دامنهاش همون دامنهی ثبتشدهی مرچنت باشه +// (APP_SITE_CallBack = دامنهی اصلی)؛ APP_URL (زیردامنهی app) باعث خطای «دامنه مجاز نیست» میشه +const getCallbackBase = () => + (process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '') + // درگاه قدیمیِ REST زرینپال (/pg/rest/WebGate) منسوخ شده — مبلغ به API نسخهی v4 // باید به ریال ارسال بشه، نه تومان (برخلاف API قدیمی) function toRial (tomanAmount) { @@ -108,7 +114,7 @@ const initiatePayment = async (req, res) => { merchant_id: apiKey, amount: toRial(price), description: `پرداخت برای سفارش شماره ${projectId}`, - callback_url: `${process.env.APP_URL}/projects/payment?projectId=${projectId}&itemName=${itemName}&userId=${userId}` // Assuming your backend URL for handling payment callback + callback_url: `${getCallbackBase()}/projects/payment?projectId=${projectId}&itemName=${itemName}&userId=${userId}` // Assuming your backend URL for handling payment callback }, { headers: { 'Content-Type': 'application/json' } } ) @@ -116,7 +122,7 @@ const initiatePayment = async (req, res) => { const result = response.data?.data if (result?.code === 100 && result?.authority) { // Payment request was successful - res.status(200).json({ authority: result.authority }) + res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) }) } else { // Payment request failed console.error('Error occurred while initiating payment:', response.data) @@ -246,7 +252,7 @@ const paymentRequestStepOne = async (req, res) => { merchant_id: apiKey, amount: toRial(acceptAmount), description: `پرداخت برای سفارش شماره ${projectId}`, - callback_url: `${process.env.APP_URL}/projects/payment-request-step-one-callback?projectId=${projectId}&requestId=${requestId}&userId=${userId}` // Callback URL including requestId + callback_url: `${getCallbackBase()}/projects/payment-request-step-one-callback?projectId=${projectId}&requestId=${requestId}&userId=${userId}` // Callback URL including requestId }, { headers: { 'Content-Type': 'application/json' } } ) @@ -254,7 +260,7 @@ const paymentRequestStepOne = async (req, res) => { const result = response.data?.data if (result?.code === 100 && result?.authority) { // Payment request was successful - res.status(200).json({ authority: result.authority }) + res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) }) } else { // Payment request failed console.error('Error occurred while initiating payment:', response.data) @@ -290,7 +296,7 @@ const paymentRequestStepTwo = async (req, res) => { merchant_id: apiKey, amount: toRial(secondInstallmentAmount), description: `پرداخت برای سفارش شماره ${projectId}`, - callback_url: `${process.env.APP_URL}/projects/payment-request-step-two-callback?projectId=${projectId}&userId=${userId}&installmentNumber=${installmentNumber}` // Callback URL + callback_url: `${getCallbackBase()}/projects/payment-request-step-two-callback?projectId=${projectId}&userId=${userId}&installmentNumber=${installmentNumber}` // Callback URL }, { headers: { 'Content-Type': 'application/json' } } ) @@ -298,7 +304,7 @@ const paymentRequestStepTwo = async (req, res) => { const result = response.data?.data if (result?.code === 100 && result?.authority) { // Payment request was successful - res.status(200).json({ authority: result.authority }) + res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) }) } else { // Payment request failed console.error('Error occurred while initiating payment:', response.data) @@ -335,7 +341,7 @@ const paymentRequestStepAll = async (req, res) => { merchant_id: apiKey, amount: toRial(secondInstallmentAmount), description: `پرداخت برای سفارش شماره ${projectId}`, - callback_url: `${process.env.APP_URL}/projects/payment-request-step-all-callback?projectId=${projectId}&userId=${userId}` // Callback URL + callback_url: `${getCallbackBase()}/projects/payment-request-step-all-callback?projectId=${projectId}&userId=${userId}` // Callback URL }, { headers: { 'Content-Type': 'application/json' } } ) @@ -343,7 +349,7 @@ const paymentRequestStepAll = async (req, res) => { const result = response.data?.data if (result?.code === 100 && result?.authority) { // Payment request was successful - res.status(200).json({ authority: result.authority }) + res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) }) } else { // Payment request failed console.error('Error occurred while initiating payment:', response.data) @@ -690,14 +696,14 @@ const initiateAdvertisingPayment = async (req, res) => { merchant_id: apiKey, amount: toRial(showDiscount === true || showDiscount === 'true' ? Number(price + 20000) : Number(price)), description: `پرداخت برای سفارش شماره ${advertisingId}`, - callback_url: `${process.env.APP_URL}/advertising/payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback + callback_url: `${getCallbackBase()}/advertising/payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback }, { headers: { 'Content-Type': 'application/json' } } ) const result = response.data?.data if (result?.code === 100 && result?.authority) { // Payment request was successful - res.status(200).json({ authority: result.authority }) + res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) }) } else { // Payment request failed console.error('Error occurred while initiating payment:', response.data) @@ -840,14 +846,14 @@ const republishAdvertisingPayment = async (req, res) => { merchant_id: apiKey, amount: toRial(showDiscount === true || showDiscount === 'true' ? Number(((price + 20000) * 70) / 100) : Number(((price) * 70) / 100)), description: `پرداخت برای سفارش شماره ${advertisingId}`, - callback_url: `${process.env.APP_URL}/advertising/republish-verify-payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback + callback_url: `${getCallbackBase()}/advertising/republish-verify-payment?advertisingId=${advertisingId}&itemName=${itemName}&userId=${userId}&showDiscount=${showDiscount}` // Assuming your backend URL for handling payment callback }, { headers: { 'Content-Type': 'application/json' } } ) const result = response.data?.data if (result?.code === 100 && result?.authority) { // Payment request was successful - res.status(200).json({ authority: result.authority }) + res.status(200).json({ authority: result.authority, paymentUrl: buildPaymentPageUrl(result.authority) }) } else { // Payment request failed console.error('Error occurred while initiating payment:', response.data) diff --git a/controllers/application/payment/paymentControllerWeb.js b/controllers/application/payment/paymentControllerWeb.js index 2fc88c5..b5bd15b 100644 --- a/controllers/application/payment/paymentControllerWeb.js +++ b/controllers/application/payment/paymentControllerWeb.js @@ -18,6 +18,8 @@ const { PROJECT_ACCEPT_RATE, PROJECT_COMPLETION_COMMISSION_RATE, creditProjectSh const { isVerifiedBadge } = require('../../../utils/verifyBadge') const { resolveWalletSplit, debitWalletForPurchase } = require('../../../utils/walletSplit') const { needsUsernameAndPassword } = require('../../../utils/authSteps') +const { canUseFreeCreation, consumeFreeCreation } = require('../../../utils/freeCreationLimit') +const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage') const OFFER_COMMISSION_RATE = 0.5 @@ -331,7 +333,7 @@ const paymentRequestAcceptWeb = async (req, res) => { if (result?.code === 100 && result?.authority) { return res.status(200).json({ authority: result.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + paymentUrl: `${buildPaymentPageUrl(result.authority)}` }) } @@ -487,7 +489,7 @@ const initiateProjectCompletionPaymentWeb = async (req, res) => { if (result?.code === 100 && result?.authority) { return res.status(200).json({ authority: result.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + paymentUrl: `${buildPaymentPageUrl(result.authority)}` }) } @@ -914,38 +916,30 @@ const initiateOfferPaymentWeb = async (req, res) => { }) } - // 🎁 free offer - if (offerType === 'free' && user.monthly_free_offer > 0) { + // 🎁 رایگان: فقط وقتی مجازه که گیرنده «دریافت هدیه» یا «دریافت پرداختی از چت» رو + // غیرفعال کرده باشه، و سهمیهی رایگان ماهانهی فرستنده هنوز باقی باشه. + // درخواستِ پولی (offerType غیر از free) همیشه با قیمت واقعی ثبت میشه + if (offerType === 'free') { + const receiverFreeEligible = receiver.gift_enabled === false || receiver.offer_payment_enabled === false + if (!receiverFreeEligible) { + return res.status(403).json({ + error: true, + message: 'برای درخواست رایگان، گیرنده باید دریافت هدیه یا دریافت پرداختی از چت را غیرفعال کرده باشد' + }) + } + if (!canUseFreeCreation(user)) { + return res.status(403).json({ error: true, message: 'سهمیه درخواست رایگان ماهانه شما تمام شده است.' }) + } + const consumed = await consumeFreeCreation(user) + if (!consumed.ok) { + return res.status(403).json({ error: true, message: 'سهمیه درخواست رایگان ماهانه شما تمام شده است.' }) + } const offer = await OfferModel.create({ sender: userId, receiver: receiverId, transaction_id: null }) - - // updateOne + $inc بهجای user.save() — save() کل سند رو ریولیدیت میکنه و برای - // کاربرهایی که مقدار قدیمی/نامعتبر توی فیلدهای دیگه (مثلاً income_range) دارن - // با ValidationError شکست میخوره، حتی وقتی فقط monthly_free_offer تغییر کرده - await UserModel.updateOne({ _id: userId }, { $inc: { monthly_free_offer: -1 } }) - - return res.json({ - type: 'free', - offerId: offer._id - }) - } - - // 🎁 گیرنده «دریافت پرداختی از چت» رو غیرفعال کرده — درخواست همکاری براش همیشه - // رایگانه، مستقل از سهمیهی رایگان ماهانهی فرستنده - if (receiver.offer_payment_enabled === false) { - const offer = await OfferModel.create({ - sender: userId, - receiver: receiverId, - transaction_id: null - }) - - return res.json({ - type: 'free', - offerId: offer._id - }) + return res.json({ type: 'free', offerId: offer._id }) } // نوعِ «رایگان» قیمتی توی OfferTypeModel نداره (اصلاً پرایسدار نیست، سهمیهمحوره) — @@ -975,7 +969,7 @@ const initiateOfferPaymentWeb = async (req, res) => { }) await debitWalletForPurchase(userId, walletAmount, { - referenceType: 'offer', + referenceType: 'payment', referenceId: offer._id, description: 'پرداخت آفر از کیف پول' }) @@ -985,7 +979,7 @@ const initiateOfferPaymentWeb = async (req, res) => { const amount = toRial(gatewayAmount) const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : '' - const callbackUrl = `https://api.modstagram.com/api/v1/offers/payment-web?receiverId=${receiverId}&offerType=${offerType}&userId=${userId}&walletAmount=${walletAmount}${platformSuffix}` + const callbackUrl = `${(process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '')}/offers/payment-web?receiverId=${receiverId}&offerType=${offerType}&userId=${userId}&walletAmount=${walletAmount}${platformSuffix}` const zarinpalRes = await axios.post( 'https://api.zarinpal.com/pg/v4/payment/request.json', @@ -1010,7 +1004,7 @@ const initiateOfferPaymentWeb = async (req, res) => { if (result.code === 100) { return res.json({ authority: result.authority, - paymentUrl: `https://www.zarinpal.com/pg/StartPay/${result.authority}` + paymentUrl: `${buildPaymentPageUrl(result.authority)}` }) } @@ -1086,7 +1080,7 @@ const handleOfferPaymentCallback = async (req, res) => { // درگاه فقط بخشِ باقیمونده رو تایید کرد؛ حالا که پرداخت قطعاً موفق شد، سهمِ کیف // پول هم واقعاً کسر میشه (نگاه کن به utils/walletSplit.js) await debitWalletForPurchase(userId, usedWalletAmount, { - referenceType: 'offer', + referenceType: 'payment', referenceId: offer._id, description: 'پرداخت آفر از کیف پول' }) diff --git a/controllers/application/posts/postController.js b/controllers/application/posts/postController.js index e0fd2f5..9d4ad28 100644 --- a/controllers/application/posts/postController.js +++ b/controllers/application/posts/postController.js @@ -1,723 +1,726 @@ -/* eslint-disable camelcase */ -const PostModel = require('../../../models/PostModel') -const UserModel = require('../../../models/UserModel') -const CourseModel = require('../../../models/CourseModel') -const ProjectModel = require('../../../models/ProjectModel') -const AdvertisingModel = require('../../../models/AdvertisingModel') -const ShopModel = require('../../../models/ShopModel') -const ShopProductListingModel = require('../../../models/ShopProductListingModel') -const LikeModel = require('../../../models/LikeModel') -const CommentModel = require('../../../models/CommentModel') -const PostReportModel = require('../../../models/PostReportModel') -const PostBookmarkModel = require('../../../models/PostBookmarkModel') -const PostShareModel = require('../../../models/PostShareModel') -const PostSendModel = require('../../../models/PostSendModel') -const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity') -const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity') -const { check, validationResult } = require('express-validator') -const jwt = require('jsonwebtoken') -const path = require('path') -const fs = require('fs-extra') -const { default: mongoose } = require('mongoose') -const { viewerIsBlockedBy } = require('../../../utils/blockVisibility') -const { attachSinglePostEngagement } = require('../../../utils/postEngagement') -const { isHiddenFromPublic } = require('../../../utils/accountLifecycle') -const { viewerCanSeeUserContent } = require('../../../utils/privateProfile') -const { isPostPubliclyVisible } = require('../../../utils/postModeration') -const { postStatusFilter, PUBLIC_POST_STATUSES, commentStatusFilter } = require('../../../utils/moderationStatus') -const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') -const { resolvePostModerationStatus } = require('../../../utils/contentModeration') -const { savePostWithModerationStatus } = require('../../../utils/postStatusSave') -const { createTagNotifications } = require('../../../utils/tagNotification') -const { notifyFollowers } = require('../../../utils/notifyFollowers') - -let processPostVideoInBackground = async () => {} -try { - ({ processPostVideoInBackground } = require('../../../utils/postVideoProcessing')) -} catch (error) { - console.warn('[postController] postVideoProcessing unavailable:', error.message) -} -const { optimizeImageFile } = require('../../../utils/imageOptimize') - -const createPostValidationRules = () => { - return [ - check('caption') - .optional({ nullable: true }) - .isLength({ max: 2000 }) - .withMessage('کپشن حداکثر ۲۰۰۰ کاراکتر') - ] -} - -const createPost = async (req, res) => { - try { - console.log('DEBUG: createPost called', { - filesCount: Array.isArray(req.files) ? req.files.length : 0, - hasCaption: Boolean(req.body?.caption), - userId: String(req.user?._id || req.user?.id || '') - }) - - const { caption } = req.body - const files = req.files || [] - const userId = req.user?._id || req.user?.id - if (!userId) { - return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' }) - } - - let taggedUserIds = [] - if (req.body.tagged_user_ids) { - const raw = req.body.tagged_user_ids - let parsed = [] - try { - parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []) - } catch { - parsed = [] - } - taggedUserIds = parsed - .filter((id) => mongoose.Types.ObjectId.isValid(id)) - .slice(0, 50) - } - - // اعتبارسنجی همهی آیتمهای قابللینک (پکیج/پروژه/بیلبورد/فروشگاه/کالا/رزرو) از یک تابع - // مشترک — همون شرط مالکیت+وضعیت که قبلاً اینجا پراکنده بود، حالا با استوری هم به اشتراک - // گذاشته میشه (utils/resolveLinkedEntity.js) - const linkedCourseId = await resolveLinkedEntity('pkg', req.body.linked_course_id, userId) - const linkedProjectId = await resolveLinkedEntity('project', req.body.linked_project_id, userId) - const linkedBillboardId = await resolveLinkedEntity('billboard', req.body.linked_billboard_id, userId) - const linkedShopId = await resolveLinkedEntity('shop', req.body.linked_shop_id, userId) - const linkedShopProductId = await resolveLinkedEntity('shopproduct', req.body.linked_shop_product_id, userId) - const linkedBookingId = await resolveLinkedEntity('booking', req.body.linked_booking_id, userId) - - if (!files || files.length === 0) { - console.log('DEBUG: No files provided') - return res.status(400).json({ error: true, message: 'هیچ فایلی آپلود نشده است' }) - } - - const captionText = - caption != null && String(caption).trim() - ? maskPhoneNumbers(String(caption).trim().slice(0, 2000)) - : null - - // کوچک/فشردهکردنِ عکسها قبل از ذخیره — قبل از اینکه مسیرشون توی دیتابیس بره، چون - // مسیر (file.path) دستنخورده میماند، فقط بایتهای خودِ فایل جایگزین میشوند - await Promise.all( - files - .filter((file) => (file.mimetype || file.type || '').startsWith('image/')) - .map((file) => optimizeImageFile(file.path)) - ) - - const filePaths = files.map((file) => ({ - path: file.path, - type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image' - })) - - const postMediaType = filePaths.some((file) => file.type === 'video') - ? 'video' - : 'image' - - const author = await UserModel.findById(userId) - .select('post_location_enabled lat lng verify_badge') - .lean() - - const moderation = resolvePostModerationStatus({ caption: captionText, filePaths, verifyBadge: author?.verify_badge }) - if (moderation.status === 'reject') { - return res.status(422).json({ - error: true, - message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.' - }) - } - - console.log('DEBUG: Saving post to MongoDB, files:', filePaths.length) - - const postPayload = { - user_id: userId, - caption: captionText, - files: filePaths, - type: postMediaType, - tagged_users: taggedUserIds, - status: moderation.status - } - - if (linkedCourseId) { - postPayload.linked_course_id = linkedCourseId - } - if (linkedProjectId) { - postPayload.linked_project_id = linkedProjectId - } - if (linkedBillboardId) { - postPayload.linked_billboard_id = linkedBillboardId - } - if (linkedShopId) { - postPayload.linked_shop_id = linkedShopId - } - if (linkedShopProductId) { - postPayload.linked_shop_product_id = linkedShopProductId - } - if (linkedBookingId) { - postPayload.linked_booking_id = linkedBookingId - } - - if ( - author?.post_location_enabled && - author.lat && - author.lng && - String(author.lat).trim() && - String(author.lng).trim() - ) { - postPayload.lat = String(author.lat) - postPayload.lng = String(author.lng) - } - - // کشور/شهر/استان پست — اختیاری، جدا از موقعیت پروفایل کاربر؛ کاربر خودش موقع ثبت پست انتخاب میکنه - if (req.body.country_id) { - const countryFind = await CountryModel.findOne({ id: req.body.country_id }, { _id: 0 }) - if (countryFind) { - postPayload.country = { id: countryFind.id, name: countryFind.name_fa, iso2: countryFind.iso2, slug: countryFind.slug } - } - } - if (req.body.province_id) { - const provinceFind = await ProvinceModel.findOne({ id: req.body.province_id }, { _id: 0 }) - if (provinceFind) { - postPayload.province = provinceFind - } - } - if (req.body.city_id) { - const cityFind = await CityModel.findOne({ id: req.body.city_id }) - if (cityFind) { - postPayload.city = cityFind - } - } - - const post = await savePostWithModerationStatus(PostModel, postPayload) - console.log('DEBUG: Post saved:', post._id) - - if (taggedUserIds.length > 0) { - await createTagNotifications({ - taggerId: userId, - taggedUserIds, - postId: post._id - }) - } - - notifyFollowers(userId, post).catch((err) => { - console.error('[notifyFollowers] background error:', err.message) - }) - - const firstVideo = filePaths.find((file) => file.type === 'video') - if (firstVideo?.path) { - processPostVideoInBackground(firstVideo.path, post._id).catch((err) => { - console.error('[postVideoProcessing] background error:', err.message) - }) - } - - res.status(201).json({ - message: 'پست ثبت شد و در حالت تایید موقت نمایش داده میشود', - postId: post._id, - status: post.status - }) - } catch (err) { - console.error('DEBUG: createPost error:', err.message) - if (err?.name === 'ValidationError') { - return res.status(500).json({ - error: true, - message: 'خطا در ثبت پست. مدل پست روی سرور بهروز نیست.' - }) - } - res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` }) - } -} - -function toPublicFilePath (filePath) { - if (!filePath || typeof filePath !== 'string') return filePath - const s = filePath.replace(/\\/g, '/') - const storageIdx = s.indexOf('/storage/') - if (storageIdx >= 0) { - return s.slice(storageIdx + '/storage'.length) - } - return s -} - -const updatePost = async (req, res) => { - try { - const token = req.header('Authorization')?.split(' ')[1] - let userId = null - if (token) { - try { - userId = jwt.verify(token, process.env.APP_SECRET).id - } catch { - userId = null - } - } - if (!userId) { - return res.status(401).json({ error: true, message: 'دسترسی غیرمجاز' }) - } - const { postId, caption } = req.body - const files = req.files || [] - - if (!postId || !mongoose.Types.ObjectId.isValid(postId)) { - return res.status(400).json({ error: true, message: 'شناسه پست نامعتبر است' }) - } - - const post = await PostModel.findById(postId) - if (!post) { - return res.status(404).json({ error: true, message: 'پست یافت نشد' }) - } - - if (String(post.user_id) !== String(userId)) { - return res.status(403).json({ error: true, message: 'اجازه ویرایش این پست را ندارید' }) - } - - let keepFiles = [] - if (req.body.keep_files) { - try { - keepFiles = Array.isArray(req.body.keep_files) - ? req.body.keep_files - : JSON.parse(req.body.keep_files) - } catch { - keepFiles = [] - } - } - - const existingByPath = new Map( - (post.files || []).map((f) => [toPublicFilePath(f.path), f]) - ) - - const orderedExisting = [] - for (const item of keepFiles) { - const rawPath = typeof item === 'string' ? item : item?.path - if (!rawPath) continue - const key = toPublicFilePath(rawPath) - const found = existingByPath.get(key) || - (post.files || []).find((f) => toPublicFilePath(f.path) === key || f.path === rawPath) - if (found) { - orderedExisting.push({ - path: found.path, - type: found.type === 'video' ? 'video' : 'image' - }) - } - } - - const newFilePaths = (files || []).map((file) => ({ - path: file.path, - type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image' - })) - - // اگر media_order ارسال شده: existing:index یا new:index - let finalFiles = [...orderedExisting, ...newFilePaths] - if (req.body.media_order) { - let order = [] - try { - order = Array.isArray(req.body.media_order) - ? req.body.media_order - : JSON.parse(req.body.media_order) - } catch { - order = [] - } - if (Array.isArray(order) && order.length) { - const rebuilt = [] - for (const token of order) { - const value = String(token || '') - if (value.startsWith('existing:')) { - const idx = Number(value.slice('existing:'.length)) - if (orderedExisting[idx]) rebuilt.push(orderedExisting[idx]) - } else if (value.startsWith('new:')) { - const idx = Number(value.slice('new:'.length)) - if (newFilePaths[idx]) rebuilt.push(newFilePaths[idx]) - } - } - if (rebuilt.length) finalFiles = rebuilt - } - } - - if (!finalFiles.length) { - return res.status(400).json({ error: true, message: 'حداقل یک فایل برای پست لازم است' }) - } - - if (caption != null) { - const captionText = String(caption).trim() - ? maskPhoneNumbers(String(caption).trim().slice(0, 2000)) - : null - const moderation = resolvePostModerationStatus({ - caption: captionText, - filePaths: finalFiles - }) - if (moderation.status === 'reject') { - return res.status(422).json({ - error: true, - message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.' - }) - } - post.caption = captionText - post.status = moderation.status - } - - let taggedUserIds = post.tagged_users || [] - if (req.body.tagged_user_ids !== undefined) { - const raw = req.body.tagged_user_ids - let parsed = [] - try { - parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []) - } catch { - parsed = [] - } - taggedUserIds = parsed - .filter((id) => mongoose.Types.ObjectId.isValid(id)) - .slice(0, 50) - post.tagged_users = taggedUserIds - } - - post.files = finalFiles - post.type = finalFiles.some((f) => f.type === 'video') ? 'video' : 'image' - post.updatedAt = new Date() - await post.save() - - const firstVideo = finalFiles.find((file) => file.type === 'video') - if (firstVideo?.path) { - processPostVideoInBackground(firstVideo.path, post._id).catch((err) => { - console.error('[postVideoProcessing] background error:', err.message) - }) - } - - return res.status(200).json({ - message: 'پست با موفقیت ویرایش شد', - postId: post._id, - status: post.status - }) - } catch (err) { - console.error('DEBUG: updatePost error:', err.message) - return res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` }) - } -} - -const getUserPosts = async (req, res, next) => { - try { - const token = req.header('Authorization').split(' ')[1] - if (!token) return res.status(401).send('Access Denied') - const user_id = req.query.user_id - // eslint-disable-next-line no-unused-vars - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - const userReqId = decodedToken.id - - if (!mongoose.Types.ObjectId.isValid(user_id)) { - return res.status(400).json({ message: 'شناسه کاربر معتبر نیست' }) - } - const user = await UserModel.findById(user_id) - - if (!user) { - return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' }) - } - if (user_id.toString() !== userReqId.toString() && isHiddenFromPublic(user)) { - return res.status(200).json({ - data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0 } - }) - } - if (user_id.toString() !== userReqId.toString()) { - const canView = await viewerCanSeeUserContent(userReqId, user) - if (!canView) { - return res.status(200).json({ - data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0, privateLocked: true } - }) - } - } - const response = {} - const options = { - page: req.query.page || 1, // صفحه پیشفرض ۱ - limit: req.query.limit || 10, // حداکثر تعداد موارد برای هر صفحه - sort: { createdAt: -1 } - } - if (user_id.toString() === userReqId.toString()) { - // کاربر خودش است، همه پستهایش را بررسی میکند - const posts = await PostModel.paginate( - { user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } }, // فیلتر - options // گزینههای پیجینیشن - ) - response.posts = posts.docs - response.postsCount = posts.totalDocs - response.totalPages = posts.totalPages // ارسال تعداد کل صفحات - response.totalItems = posts.totalDocs - } else { - // دیگران، فقط پستهایی که وضعیتشان "accept" است را ببینند - const posts = await PostModel.paginate( - { user_id: user._id, status: postStatusFilter(), is_archived: { $ne: true } }, // فیلتر - options // گزینههای پیجینیشن - ) - response.posts = posts.docs - response.postsCount = posts.totalDocs - response.totalPages = posts.totalPages // ارسال تعداد کل صفحات - response.totalItems = posts.totalDocs - } - return res.status(200).json({ data: response }) - } catch (error) { - next(error) - } -} -const getUserPostsWeb = async (req, res, next) => { - try { - let userReqId = null - - // دریافت توکن و بررسی معتبر بودنش (اگر وجود داشت) - const authHeader = req.header('Authorization') - if (authHeader) { - const token = authHeader.split(' ')[1] - try { - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - userReqId = decodedToken.id - } catch (err) { - console.warn('توکن نامعتبر است، ادامه بدون احراز هویت') // فقط هشدار، نه ارور - } - } - - const user_id = req.query.user_id - - if (!mongoose.Types.ObjectId.isValid(user_id)) { - return res.status(400).json({ message: 'شناسه کاربر معتبر نیست' }) - } - - const user = await UserModel.findById(user_id) - if (!user) { - return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' }) - } - - if (userReqId && viewerIsBlockedBy(user, userReqId)) { - return res.status(200).json({ - posts: [], - postsCount: 0, - totalPages: 0, - totalItems: 0 - }) - } - - if ((!userReqId || user_id.toString() !== userReqId.toString()) && isHiddenFromPublic(user)) { - return res.status(200).json({ - posts: [], - postsCount: 0, - totalPages: 0, - totalItems: 0 - }) - } - - if ( - !userReqId || - user_id.toString() !== userReqId.toString() - ) { - const canView = await viewerCanSeeUserContent(userReqId, user) - if (!canView) { - return res.status(200).json({ - posts: [], - postsCount: 0, - totalPages: 0, - totalItems: 0, - privateLocked: true - }) - } - } - - const data = {} - const options = { - page: req.query.page || 1, - limit: req.query.limit || 10, - sort: { createdAt: -1 } - } - - if (userReqId && user_id.toString() === userReqId.toString()) { - const posts = await PostModel.paginate( - { user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } }, - options - ) - data.posts = posts.docs.filter( - (post) => post.status === 'pending' || isPostPubliclyVisible(post) - ) - data.postsCount = data.posts.length - data.totalPages = posts.totalPages - data.totalItems = data.posts.length - } else { - const posts = await PostModel.paginate( - { - user_id: user._id, - status: postStatusFilter(), - is_archived: { $ne: true }, - $or: [ - { report_hidden: false }, - { report_hidden: { $exists: false } }, - { report_hidden: null } - ] - }, - options - ) - data.posts = posts.docs - data.postsCount = posts.totalDocs - data.totalPages = posts.totalPages - data.totalItems = posts.totalDocs - } - - const pinnedIds = (user.pinned_post_ids || []).map(String) - if (pinnedIds.length && data.posts?.length) { - const pinOrder = new Map(pinnedIds.map((id, idx) => [id, idx])) - data.posts.sort((a, b) => { - const aPin = pinOrder.get(String(a._id)) - const bPin = pinOrder.get(String(b._id)) - if (aPin !== undefined && bPin !== undefined) return aPin - bPin - if (aPin !== undefined) return -1 - if (bPin !== undefined) return 1 - return new Date(b.createdAt) - new Date(a.createdAt) - }) - data.posts.forEach((p) => { - p.is_pinned = pinOrder.has(String(p._id)) - }) - } - - return res.status(200).json(data) - } catch (error) { - next(error) - } -} - -// حذفِ کاملِ پست — غیرقابلبازگشت. علاوه بر خودِ پست، هرچی که بهش ارجاع میده هم پاک -// میشه (لایک/کامنت/گزارش/بوکمارک/اشتراک/ارسالشدهدرچت + پینشده از پروفایل مالک) تا -// دیتای یتیم تو دیتابیس نمونه؛ فایلهای فیزیکی (عکس/ویدیو) هم از دیسک حذف میشن -const deletePost = async (req, res, next) => { - try { - const userId = req.user?._id || req.user?.id - if (!userId) { - return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' }) - } - - const { postId } = req.params - if (!mongoose.Types.ObjectId.isValid(postId)) { - return res.status(400).json({ error: true, message: 'شناسه پست معتبر نیست' }) - } - - const post = await PostModel.findById(postId) - if (!post) { - return res.status(404).json({ error: true, message: 'پست یافت نشد' }) - } - if (String(post.user_id) !== String(userId)) { - return res.status(403).json({ error: true, message: 'اجازهی حذف این پست را ندارید' }) - } - - await Promise.all([ - LikeModel.deleteMany({ postId }), - CommentModel.deleteMany({ post: postId }), - PostReportModel.deleteMany({ post_id: postId }), - PostBookmarkModel.deleteMany({ postId }), - PostShareModel.deleteMany({ postId }), - PostSendModel.deleteMany({ postId }), - UserModel.updateMany( - { pinned_post_ids: postId }, - { $pull: { pinned_post_ids: postId } } - ) - ]) - - for (const file of post.files || []) { - if (file?.path && fs.existsSync(file.path)) { - await fs.remove(file.path).catch(() => {}) - } - } - - await PostModel.deleteOne({ _id: postId }) - - return res.status(200).json({ message: 'پست با موفقیت حذف شد' }) - } catch (error) { - next(error) - } -} - -const getPostByIdWeb = async (req, res, next) => { - try { - const { postId } = req.params - - if (!mongoose.Types.ObjectId.isValid(postId)) { - return res.status(400).json({ message: 'شناسه پست معتبر نیست' }) - } - - let userReqId = null - const authHeader = req.header('Authorization') - if (authHeader) { - const token = authHeader.split(' ')[1] - try { - const decodedToken = jwt.verify(token, process.env.APP_SECRET) - userReqId = decodedToken.id - } catch (err) { - console.warn('توکن نامعتبر است، ادامه بدون احراز هویت') - } - } - - const post = await PostModel.findById(postId).lean() - if (!post || !isPostPubliclyVisible(post)) { - return res.status(404).json({ message: 'پست یافت نشد' }) - } - if (post.is_archived && String(post.user_id) !== String(userReqId)) { - return res.status(404).json({ message: 'پست یافت نشد' }) - } - - const user = await UserModel.findById(post.user_id) - .select( - '_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image blocked_users is_private account_status' - ) - .lean() - - if (!user) { - return res.status(404).json({ message: 'پست یافت نشد' }) - } - - if (userReqId && viewerIsBlockedBy(user, userReqId)) { - return res.status(404).json({ message: 'پست یافت نشد' }) - } - - const canView = await viewerCanSeeUserContent(userReqId, user) - if (!canView) { - return res.status(404).json({ message: 'پست یافت نشد' }) - } - - const LikeModel = require('../../../models/LikeModel') - const CommentModel = require('../../../models/CommentModel') - - let is_liked = false - if (userReqId) { - const like = await LikeModel.findOne({ - userId: userReqId, - postId: post._id - }).lean() - is_liked = !!like - } - - const commentsCount = await CommentModel.countDocuments({ - post: post._id, - status: commentStatusFilter() - }) - - let enrichedPost = { - ...post, - user_name: user.user_name || '', - first_name: user.first_name || '', - last_name: user.last_name || '', - expertise: user.expertise || '', - sub_expertise: user.sub_expertise || [], - province: user.show_location ? user.province || {} : {}, - city: user.show_location ? user.city || {} : {}, - show_location: user.show_location, - user_level: user.user_level || '', - profile_image: user.profile_image || '', - likesCount: post.likes ? post.likes.length : 0, - is_liked, - commentsCount - } - - enrichedPost = await attachSinglePostEngagement(enrichedPost, userReqId) - - return res.status(200).json({ - post: enrichedPost - }) - } catch (error) { - next(error) - } -} - -module.exports = { - createPostValidationRules, - createPost, - updatePost, - deletePost, - getUserPosts, - getUserPostsWeb, - getPostByIdWeb -} +/* eslint-disable camelcase */ +const PostModel = require('../../../models/PostModel') +const UserModel = require('../../../models/UserModel') +const CourseModel = require('../../../models/CourseModel') +const ProjectModel = require('../../../models/ProjectModel') +const AdvertisingModel = require('../../../models/AdvertisingModel') +const ShopModel = require('../../../models/ShopModel') +const ShopProductListingModel = require('../../../models/ShopProductListingModel') +const LikeModel = require('../../../models/LikeModel') +const CommentModel = require('../../../models/CommentModel') +const PostReportModel = require('../../../models/PostReportModel') +const PostBookmarkModel = require('../../../models/PostBookmarkModel') +const PostShareModel = require('../../../models/PostShareModel') +const PostSendModel = require('../../../models/PostSendModel') +const { CountryModel, ProvinceModel, CityModel } = require('../../../models/StateCity') +const { resolveLinkedEntity } = require('../../../utils/resolveLinkedEntity') +const { check, validationResult } = require('express-validator') +const jwt = require('jsonwebtoken') +const path = require('path') +const fs = require('fs-extra') +const { default: mongoose } = require('mongoose') +const { viewerIsBlockedBy } = require('../../../utils/blockVisibility') +const { attachSinglePostEngagement } = require('../../../utils/postEngagement') +const { isHiddenFromPublic } = require('../../../utils/accountLifecycle') +const { viewerCanSeeUserContent } = require('../../../utils/privateProfile') +const { isPostPubliclyVisible } = require('../../../utils/postModeration') +const { postStatusFilter, PUBLIC_POST_STATUSES, commentStatusFilter } = require('../../../utils/moderationStatus') +const { maskPhoneNumbers } = require('../../../utils/phoneNumberMasking') +const { resolvePostModerationStatus } = require('../../../utils/contentModeration') +const { savePostWithModerationStatus } = require('../../../utils/postStatusSave') +const { createTagNotifications } = require('../../../utils/tagNotification') +const { notifyFollowers } = require('../../../utils/notifyFollowers') + +let processPostVideoInBackground = async () => {} +try { + ({ processPostVideoInBackground } = require('../../../utils/postVideoProcessing')) +} catch (error) { + console.warn('[postController] postVideoProcessing unavailable:', error.message) +} +const { optimizeImageFile, generateImageVariants } = require("../../../utils/imageOptimize") + +const createPostValidationRules = () => { + return [ + check('caption') + .optional({ nullable: true }) + .isLength({ max: 2000 }) + .withMessage('کپشن حداکثر ۲۰۰۰ کاراکتر') + ] +} + +const createPost = async (req, res) => { + try { + console.log('DEBUG: createPost called', { + filesCount: Array.isArray(req.files) ? req.files.length : 0, + hasCaption: Boolean(req.body?.caption), + userId: String(req.user?._id || req.user?.id || '') + }) + + const { caption } = req.body + const files = req.files || [] + const userId = req.user?._id || req.user?.id + if (!userId) { + return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' }) + } + + let taggedUserIds = [] + if (req.body.tagged_user_ids) { + const raw = req.body.tagged_user_ids + let parsed = [] + try { + parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []) + } catch { + parsed = [] + } + taggedUserIds = parsed + .filter((id) => mongoose.Types.ObjectId.isValid(id)) + .slice(0, 50) + } + + // اعتبارسنجی همهی آیتمهای قابللینک (پکیج/پروژه/بیلبورد/فروشگاه/کالا/رزرو) از یک تابع + // مشترک — همون شرط مالکیت+وضعیت که قبلاً اینجا پراکنده بود، حالا با استوری هم به اشتراک + // گذاشته میشه (utils/resolveLinkedEntity.js) + const linkedCourseId = await resolveLinkedEntity('pkg', req.body.linked_course_id, userId) + const linkedProjectId = await resolveLinkedEntity('project', req.body.linked_project_id, userId) + const linkedBillboardId = await resolveLinkedEntity('billboard', req.body.linked_billboard_id, userId) + const linkedShopId = await resolveLinkedEntity('shop', req.body.linked_shop_id, userId) + const linkedShopProductId = await resolveLinkedEntity('shopproduct', req.body.linked_shop_product_id, userId) + const linkedBookingId = await resolveLinkedEntity('booking', req.body.linked_booking_id, userId) + + if (!files || files.length === 0) { + console.log('DEBUG: No files provided') + return res.status(400).json({ error: true, message: 'هیچ فایلی آپلود نشده است' }) + } + + const captionText = + caption != null && String(caption).trim() + ? maskPhoneNumbers(String(caption).trim().slice(0, 2000)) + : null + + // کوچک/فشردهکردنِ عکسها قبل از ذخیره — قبل از اینکه مسیرشون توی دیتابیس بره، چون + // مسیر (file.path) دستنخورده میماند، فقط بایتهای خودِ فایل جایگزین میشوند + await Promise.all( + files + .filter((file) => (file.mimetype || file.type || '').startsWith('image/')) + .map(async (file) => { + await optimizeImageFile(file.path) + await generateImageVariants(file.path) + }) + ) + + const filePaths = files.map((file) => ({ + path: file.path, + type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image' + })) + + const postMediaType = filePaths.some((file) => file.type === 'video') + ? 'video' + : 'image' + + const author = await UserModel.findById(userId) + .select('post_location_enabled lat lng verify_badge') + .lean() + + const moderation = resolvePostModerationStatus({ caption: captionText, filePaths, verifyBadge: author?.verify_badge }) + if (moderation.status === 'reject') { + return res.status(422).json({ + error: true, + message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.' + }) + } + + console.log('DEBUG: Saving post to MongoDB, files:', filePaths.length) + + const postPayload = { + user_id: userId, + caption: captionText, + files: filePaths, + type: postMediaType, + tagged_users: taggedUserIds, + status: moderation.status + } + + if (linkedCourseId) { + postPayload.linked_course_id = linkedCourseId + } + if (linkedProjectId) { + postPayload.linked_project_id = linkedProjectId + } + if (linkedBillboardId) { + postPayload.linked_billboard_id = linkedBillboardId + } + if (linkedShopId) { + postPayload.linked_shop_id = linkedShopId + } + if (linkedShopProductId) { + postPayload.linked_shop_product_id = linkedShopProductId + } + if (linkedBookingId) { + postPayload.linked_booking_id = linkedBookingId + } + + if ( + author?.post_location_enabled && + author.lat && + author.lng && + String(author.lat).trim() && + String(author.lng).trim() + ) { + postPayload.lat = String(author.lat) + postPayload.lng = String(author.lng) + } + + // کشور/شهر/استان پست — اختیاری، جدا از موقعیت پروفایل کاربر؛ کاربر خودش موقع ثبت پست انتخاب میکنه + if (req.body.country_id) { + const countryFind = await CountryModel.findOne({ id: req.body.country_id }, { _id: 0 }) + if (countryFind) { + postPayload.country = { id: countryFind.id, name: countryFind.name_fa, iso2: countryFind.iso2, slug: countryFind.slug } + } + } + if (req.body.province_id) { + const provinceFind = await ProvinceModel.findOne({ id: req.body.province_id }, { _id: 0 }) + if (provinceFind) { + postPayload.province = provinceFind + } + } + if (req.body.city_id) { + const cityFind = await CityModel.findOne({ id: req.body.city_id }) + if (cityFind) { + postPayload.city = cityFind + } + } + + const post = await savePostWithModerationStatus(PostModel, postPayload) + console.log('DEBUG: Post saved:', post._id) + + if (taggedUserIds.length > 0) { + await createTagNotifications({ + taggerId: userId, + taggedUserIds, + postId: post._id + }) + } + + notifyFollowers(userId, post).catch((err) => { + console.error('[notifyFollowers] background error:', err.message) + }) + + const firstVideo = filePaths.find((file) => file.type === 'video') + if (firstVideo?.path) { + processPostVideoInBackground(firstVideo.path, post._id).catch((err) => { + console.error('[postVideoProcessing] background error:', err.message) + }) + } + + res.status(201).json({ + message: 'پست ثبت شد و در حالت تایید موقت نمایش داده میشود', + postId: post._id, + status: post.status + }) + } catch (err) { + console.error('DEBUG: createPost error:', err.message) + if (err?.name === 'ValidationError') { + return res.status(500).json({ + error: true, + message: 'خطا در ثبت پست. مدل پست روی سرور بهروز نیست.' + }) + } + res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` }) + } +} + +function toPublicFilePath (filePath) { + if (!filePath || typeof filePath !== 'string') return filePath + const s = filePath.replace(/\\/g, '/') + const storageIdx = s.indexOf('/storage/') + if (storageIdx >= 0) { + return s.slice(storageIdx + '/storage'.length) + } + return s +} + +const updatePost = async (req, res) => { + try { + const token = req.header('Authorization')?.split(' ')[1] + let userId = null + if (token) { + try { + userId = jwt.verify(token, process.env.APP_SECRET).id + } catch { + userId = null + } + } + if (!userId) { + return res.status(401).json({ error: true, message: 'دسترسی غیرمجاز' }) + } + const { postId, caption } = req.body + const files = req.files || [] + + if (!postId || !mongoose.Types.ObjectId.isValid(postId)) { + return res.status(400).json({ error: true, message: 'شناسه پست نامعتبر است' }) + } + + const post = await PostModel.findById(postId) + if (!post) { + return res.status(404).json({ error: true, message: 'پست یافت نشد' }) + } + + if (String(post.user_id) !== String(userId)) { + return res.status(403).json({ error: true, message: 'اجازه ویرایش این پست را ندارید' }) + } + + let keepFiles = [] + if (req.body.keep_files) { + try { + keepFiles = Array.isArray(req.body.keep_files) + ? req.body.keep_files + : JSON.parse(req.body.keep_files) + } catch { + keepFiles = [] + } + } + + const existingByPath = new Map( + (post.files || []).map((f) => [toPublicFilePath(f.path), f]) + ) + + const orderedExisting = [] + for (const item of keepFiles) { + const rawPath = typeof item === 'string' ? item : item?.path + if (!rawPath) continue + const key = toPublicFilePath(rawPath) + const found = existingByPath.get(key) || + (post.files || []).find((f) => toPublicFilePath(f.path) === key || f.path === rawPath) + if (found) { + orderedExisting.push({ + path: found.path, + type: found.type === 'video' ? 'video' : 'image' + }) + } + } + + const newFilePaths = (files || []).map((file) => ({ + path: file.path, + type: (file.mimetype || file.type || '').startsWith('video/') ? 'video' : 'image' + })) + + // اگر media_order ارسال شده: existing:index یا new:index + let finalFiles = [...orderedExisting, ...newFilePaths] + if (req.body.media_order) { + let order = [] + try { + order = Array.isArray(req.body.media_order) + ? req.body.media_order + : JSON.parse(req.body.media_order) + } catch { + order = [] + } + if (Array.isArray(order) && order.length) { + const rebuilt = [] + for (const token of order) { + const value = String(token || '') + if (value.startsWith('existing:')) { + const idx = Number(value.slice('existing:'.length)) + if (orderedExisting[idx]) rebuilt.push(orderedExisting[idx]) + } else if (value.startsWith('new:')) { + const idx = Number(value.slice('new:'.length)) + if (newFilePaths[idx]) rebuilt.push(newFilePaths[idx]) + } + } + if (rebuilt.length) finalFiles = rebuilt + } + } + + if (!finalFiles.length) { + return res.status(400).json({ error: true, message: 'حداقل یک فایل برای پست لازم است' }) + } + + if (caption != null) { + const captionText = String(caption).trim() + ? maskPhoneNumbers(String(caption).trim().slice(0, 2000)) + : null + const moderation = resolvePostModerationStatus({ + caption: captionText, + filePaths: finalFiles + }) + if (moderation.status === 'reject') { + return res.status(422).json({ + error: true, + message: 'متن پست با قوانین همخوانی ندارد و ثبت نشد.' + }) + } + post.caption = captionText + post.status = moderation.status + } + + let taggedUserIds = post.tagged_users || [] + if (req.body.tagged_user_ids !== undefined) { + const raw = req.body.tagged_user_ids + let parsed = [] + try { + parsed = Array.isArray(raw) ? raw : (typeof raw === 'string' ? JSON.parse(raw) : []) + } catch { + parsed = [] + } + taggedUserIds = parsed + .filter((id) => mongoose.Types.ObjectId.isValid(id)) + .slice(0, 50) + post.tagged_users = taggedUserIds + } + + post.files = finalFiles + post.type = finalFiles.some((f) => f.type === 'video') ? 'video' : 'image' + post.updatedAt = new Date() + await post.save() + + const firstVideo = finalFiles.find((file) => file.type === 'video') + if (firstVideo?.path) { + processPostVideoInBackground(firstVideo.path, post._id).catch((err) => { + console.error('[postVideoProcessing] background error:', err.message) + }) + } + + return res.status(200).json({ + message: 'پست با موفقیت ویرایش شد', + postId: post._id, + status: post.status + }) + } catch (err) { + console.error('DEBUG: updatePost error:', err.message) + return res.status(500).json({ error: true, message: `خطا در سرور: ${err.message}` }) + } +} + +const getUserPosts = async (req, res, next) => { + try { + const token = req.header('Authorization').split(' ')[1] + if (!token) return res.status(401).send('Access Denied') + const user_id = req.query.user_id + // eslint-disable-next-line no-unused-vars + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + const userReqId = decodedToken.id + + if (!mongoose.Types.ObjectId.isValid(user_id)) { + return res.status(400).json({ message: 'شناسه کاربر معتبر نیست' }) + } + const user = await UserModel.findById(user_id) + + if (!user) { + return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' }) + } + if (user_id.toString() !== userReqId.toString() && isHiddenFromPublic(user)) { + return res.status(200).json({ + data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0 } + }) + } + if (user_id.toString() !== userReqId.toString()) { + const canView = await viewerCanSeeUserContent(userReqId, user) + if (!canView) { + return res.status(200).json({ + data: { posts: [], postsCount: 0, totalPages: 0, totalItems: 0, privateLocked: true } + }) + } + } + const response = {} + const options = { + page: req.query.page || 1, // صفحه پیشفرض ۱ + limit: req.query.limit || 10, // حداکثر تعداد موارد برای هر صفحه + sort: { createdAt: -1 } + } + if (user_id.toString() === userReqId.toString()) { + // کاربر خودش است، همه پستهایش را بررسی میکند + const posts = await PostModel.paginate( + { user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } }, // فیلتر + options // گزینههای پیجینیشن + ) + response.posts = posts.docs + response.postsCount = posts.totalDocs + response.totalPages = posts.totalPages // ارسال تعداد کل صفحات + response.totalItems = posts.totalDocs + } else { + // دیگران، فقط پستهایی که وضعیتشان "accept" است را ببینند + const posts = await PostModel.paginate( + { user_id: user._id, status: postStatusFilter(), is_archived: { $ne: true } }, // فیلتر + options // گزینههای پیجینیشن + ) + response.posts = posts.docs + response.postsCount = posts.totalDocs + response.totalPages = posts.totalPages // ارسال تعداد کل صفحات + response.totalItems = posts.totalDocs + } + return res.status(200).json({ data: response }) + } catch (error) { + next(error) + } +} +const getUserPostsWeb = async (req, res, next) => { + try { + let userReqId = null + + // دریافت توکن و بررسی معتبر بودنش (اگر وجود داشت) + const authHeader = req.header('Authorization') + if (authHeader) { + const token = authHeader.split(' ')[1] + try { + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + userReqId = decodedToken.id + } catch (err) { + console.warn('توکن نامعتبر است، ادامه بدون احراز هویت') // فقط هشدار، نه ارور + } + } + + const user_id = req.query.user_id + + if (!mongoose.Types.ObjectId.isValid(user_id)) { + return res.status(400).json({ message: 'شناسه کاربر معتبر نیست' }) + } + + const user = await UserModel.findById(user_id) + if (!user) { + return res.status(404).json({ message: 'کاربر مورد نظر یافت نشد' }) + } + + if (userReqId && viewerIsBlockedBy(user, userReqId)) { + return res.status(200).json({ + posts: [], + postsCount: 0, + totalPages: 0, + totalItems: 0 + }) + } + + if ((!userReqId || user_id.toString() !== userReqId.toString()) && isHiddenFromPublic(user)) { + return res.status(200).json({ + posts: [], + postsCount: 0, + totalPages: 0, + totalItems: 0 + }) + } + + if ( + !userReqId || + user_id.toString() !== userReqId.toString() + ) { + const canView = await viewerCanSeeUserContent(userReqId, user) + if (!canView) { + return res.status(200).json({ + posts: [], + postsCount: 0, + totalPages: 0, + totalItems: 0, + privateLocked: true + }) + } + } + + const data = {} + const options = { + page: req.query.page || 1, + limit: req.query.limit || 10, + sort: { createdAt: -1 } + } + + if (userReqId && user_id.toString() === userReqId.toString()) { + const posts = await PostModel.paginate( + { user_id: user._id, status: { $in: ['pending', ...PUBLIC_POST_STATUSES] }, is_archived: { $ne: true } }, + options + ) + data.posts = posts.docs.filter( + (post) => post.status === 'pending' || isPostPubliclyVisible(post) + ) + data.postsCount = data.posts.length + data.totalPages = posts.totalPages + data.totalItems = data.posts.length + } else { + const posts = await PostModel.paginate( + { + user_id: user._id, + status: postStatusFilter(), + is_archived: { $ne: true }, + $or: [ + { report_hidden: false }, + { report_hidden: { $exists: false } }, + { report_hidden: null } + ] + }, + options + ) + data.posts = posts.docs + data.postsCount = posts.totalDocs + data.totalPages = posts.totalPages + data.totalItems = posts.totalDocs + } + + const pinnedIds = (user.pinned_post_ids || []).map(String) + if (pinnedIds.length && data.posts?.length) { + const pinOrder = new Map(pinnedIds.map((id, idx) => [id, idx])) + data.posts.sort((a, b) => { + const aPin = pinOrder.get(String(a._id)) + const bPin = pinOrder.get(String(b._id)) + if (aPin !== undefined && bPin !== undefined) return aPin - bPin + if (aPin !== undefined) return -1 + if (bPin !== undefined) return 1 + return new Date(b.createdAt) - new Date(a.createdAt) + }) + data.posts.forEach((p) => { + p.is_pinned = pinOrder.has(String(p._id)) + }) + } + + return res.status(200).json(data) + } catch (error) { + next(error) + } +} + +// حذفِ کاملِ پست — غیرقابلبازگشت. علاوه بر خودِ پست، هرچی که بهش ارجاع میده هم پاک +// میشه (لایک/کامنت/گزارش/بوکمارک/اشتراک/ارسالشدهدرچت + پینشده از پروفایل مالک) تا +// دیتای یتیم تو دیتابیس نمونه؛ فایلهای فیزیکی (عکس/ویدیو) هم از دیسک حذف میشن +const deletePost = async (req, res, next) => { + try { + const userId = req.user?._id || req.user?.id + if (!userId) { + return res.status(401).json({ error: true, message: 'توکن ارائه نشده است' }) + } + + const { postId } = req.params + if (!mongoose.Types.ObjectId.isValid(postId)) { + return res.status(400).json({ error: true, message: 'شناسه پست معتبر نیست' }) + } + + const post = await PostModel.findById(postId) + if (!post) { + return res.status(404).json({ error: true, message: 'پست یافت نشد' }) + } + if (String(post.user_id) !== String(userId)) { + return res.status(403).json({ error: true, message: 'اجازهی حذف این پست را ندارید' }) + } + + await Promise.all([ + LikeModel.deleteMany({ postId }), + CommentModel.deleteMany({ post: postId }), + PostReportModel.deleteMany({ post_id: postId }), + PostBookmarkModel.deleteMany({ postId }), + PostShareModel.deleteMany({ postId }), + PostSendModel.deleteMany({ postId }), + UserModel.updateMany( + { pinned_post_ids: postId }, + { $pull: { pinned_post_ids: postId } } + ) + ]) + + for (const file of post.files || []) { + if (file?.path && fs.existsSync(file.path)) { + await fs.remove(file.path).catch(() => {}) + } + } + + await PostModel.deleteOne({ _id: postId }) + + return res.status(200).json({ message: 'پست با موفقیت حذف شد' }) + } catch (error) { + next(error) + } +} + +const getPostByIdWeb = async (req, res, next) => { + try { + const { postId } = req.params + + if (!mongoose.Types.ObjectId.isValid(postId)) { + return res.status(400).json({ message: 'شناسه پست معتبر نیست' }) + } + + let userReqId = null + const authHeader = req.header('Authorization') + if (authHeader) { + const token = authHeader.split(' ')[1] + try { + const decodedToken = jwt.verify(token, process.env.APP_SECRET) + userReqId = decodedToken.id + } catch (err) { + console.warn('توکن نامعتبر است، ادامه بدون احراز هویت') + } + } + + const post = await PostModel.findById(postId).lean() + if (!post || !isPostPubliclyVisible(post)) { + return res.status(404).json({ message: 'پست یافت نشد' }) + } + if (post.is_archived && String(post.user_id) !== String(userReqId)) { + return res.status(404).json({ message: 'پست یافت نشد' }) + } + + const user = await UserModel.findById(post.user_id) + .select( + '_id user_name first_name last_name expertise sub_expertise province city user_level show_location profile_image blocked_users is_private account_status' + ) + .lean() + + if (!user) { + return res.status(404).json({ message: 'پست یافت نشد' }) + } + + if (userReqId && viewerIsBlockedBy(user, userReqId)) { + return res.status(404).json({ message: 'پست یافت نشد' }) + } + + const canView = await viewerCanSeeUserContent(userReqId, user) + if (!canView) { + return res.status(404).json({ message: 'پست یافت نشد' }) + } + + const LikeModel = require('../../../models/LikeModel') + const CommentModel = require('../../../models/CommentModel') + + let is_liked = false + if (userReqId) { + const like = await LikeModel.findOne({ + userId: userReqId, + postId: post._id + }).lean() + is_liked = !!like + } + + const commentsCount = await CommentModel.countDocuments({ + post: post._id, + status: commentStatusFilter() + }) + + let enrichedPost = { + ...post, + user_name: user.user_name || '', + first_name: user.first_name || '', + last_name: user.last_name || '', + expertise: user.expertise || '', + sub_expertise: user.sub_expertise || [], + province: user.show_location ? user.province || {} : {}, + city: user.show_location ? user.city || {} : {}, + show_location: user.show_location, + user_level: user.user_level || '', + profile_image: user.profile_image || '', + likesCount: post.likes ? post.likes.length : 0, + is_liked, + commentsCount + } + + enrichedPost = await attachSinglePostEngagement(enrichedPost, userReqId) + + return res.status(200).json({ + post: enrichedPost + }) + } catch (error) { + next(error) + } +} + +module.exports = { + createPostValidationRules, + createPost, + updatePost, + deletePost, + getUserPosts, + getUserPostsWeb, + getPostByIdWeb +} diff --git a/controllers/application/wallet/walletController.js b/controllers/application/wallet/walletController.js index 0726332..e5660c5 100644 --- a/controllers/application/wallet/walletController.js +++ b/controllers/application/wallet/walletController.js @@ -6,6 +6,8 @@ const OrderModel = require('../../../models/OrderModel') const UserModel = require('../../../models/UserModel') const WalletTransactionModel = require('../../../models/WalletTransactionModel') const WalletWithdrawalRequestModel = require('../../../models/WalletWithdrawalRequestModel') +const PaymentModel = require('../../../models/PaymentModel') +const { buildPaymentPageUrl } = require('../../../utils/zarinpalPaymentPage') const MERCHANT_ID = process.env.ZARINPAL_MERCHANT_ID || process.env.MERCHENT_CODE const GIFT_WITHDRAWAL_MIN = 1000000 @@ -199,49 +201,89 @@ const initiateWalletCharge = async (req, res, next) => { return walletError(res, 422, 'مبلغ نامعتبر است') } - const callbackBase = process.env.APP_SITE_CallBack || process.env.APP_URL + const callbackBase = (process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '') if (!callbackBase) { return walletError(res, 500, 'آدرس بازگشت پرداخت تنظیم نشده است') } - const platformSuffix = platform ? `&platform=${encodeURIComponent(platform)}` : '' + // کالبک باید یک آدرس ثابت و بدون query string باشد تا با دامنه/Callback + // ثبتشده در زرینپال دقیقاً یکی باشد. اطلاعات پرداخت را در دیتابیس نگه میداریم. + const callbackUrl = `${callbackBase}/wallet/charge/callback` + const paymentType = platform === 'android' ? 'wallet_charge_android' : 'wallet_charge_web' + const response = await axios.post( 'https://api.zarinpal.com/pg/v4/payment/request.json', { merchant_id: MERCHANT_ID, amount: toRial(chargeAmount), description: 'شارژ کیف پول مدستاگرام', - callback_url: `${callbackBase}/wallet/charge/callback?userId=${req.user._id}&amount=${chargeAmount}${platformSuffix}` + callback_url: callbackUrl }, - { headers: { 'Content-Type': 'application/json' } } + { headers: { 'Content-Type': 'application/json' }, timeout: 20000 } ) const result = response.data?.data if (result?.code === 100 && result?.authority) { - return res.json({ authority: result.authority }) + await PaymentModel.create({ + amount: chargeAmount, + status: 'pending', + authority: result.authority, + user_id: req.user._id, + type: paymentType + }) + + return res.json({ + authority: result.authority, + paymentUrl: `${buildPaymentPageUrl(result.authority)}` + }) } console.error('ZarinPal wallet charge initiate error:', response.data) return walletError(res, 502, 'درگاه پرداخت پاسخ نامعتبر داد. لطفاً دوباره تلاش کنید.') } catch (error) { + console.error('initiateWalletCharge error:', error?.response?.data || error?.message || error) next(error) } } const handleWalletChargeCallback = async (req, res) => { - const { Authority, Status, userId, amount, platform } = req.query - const isAndroid = platform === 'android' - const failRedirect = isAndroid - ? 'modstagram://FailedPay?type=wallet' - : `${process.env.APP_SITE}/settings/wallet?charge=failed` - const successRedirect = isAndroid - ? 'modstagram://SuccessPay?type=wallet' - : `${process.env.APP_SITE}/settings/wallet?charge=success` + const { Authority, Status } = req.query + + let payment = null + let failRedirect = `${process.env.APP_SITE}/settings/wallet/payment/failed` + let successRedirect = `${process.env.APP_SITE}/settings/wallet/payment/success` try { - if (Status !== 'OK') return res.redirect(failRedirect) + if (!Authority) return res.redirect(failRedirect) + + payment = await PaymentModel.findOne({ + authority: Authority, + type: { $in: ['wallet_charge_android', 'wallet_charge_web'] } + }) + + if (!payment) return res.redirect(failRedirect) + + const chargeAmount = Number(payment.amount) + const isAndroid = payment.type === 'wallet_charge_android' + + failRedirect = isAndroid + ? `modstagram://FailedPay?type=wallet&amount=${encodeURIComponent(chargeAmount)}` + : `${process.env.APP_SITE}/settings/wallet/payment/failed?amount=${encodeURIComponent(chargeAmount)}` + successRedirect = isAndroid + ? `modstagram://SuccessPay?type=wallet&amount=${encodeURIComponent(chargeAmount)}` + : `${process.env.APP_SITE}/settings/wallet/payment/success?amount=${encodeURIComponent(chargeAmount)}` + + if (Status !== 'OK') { + payment.status = 'failed' + await payment.save() + return res.redirect(failRedirect) + } + + // اگر callback دوباره ارسال شد، شارژ دوباره به کیف پول اضافه نشود. + if (payment.status === 'successful') { + return res.redirect(successRedirect) + } - const chargeAmount = Number(amount) const verify = await axios.post( 'https://api.zarinpal.com/pg/v4/payment/verify.json', { @@ -249,41 +291,60 @@ const handleWalletChargeCallback = async (req, res) => { authority: Authority, amount: toRial(chargeAmount) }, - { headers: { 'Content-Type': 'application/json' } } + { headers: { 'Content-Type': 'application/json' }, timeout: 20000 } ) const data = verify.data?.data if (data?.code === 100 || data?.code === 101) { - const user = await UserModel.findById(userId) - if (!user) return res.redirect(failRedirect) + const user = await UserModel.findById(payment.user_id) + if (!user) { + payment.status = 'failed' + await payment.save() + return res.redirect(failRedirect) + } await WalletTransactionModel.create({ - user: userId, + user: payment.user_id, bucket: 'charged', type: 'credit', amount: chargeAmount, referenceType: 'payment', + referenceId: payment._id, description: 'شارژ کیف پول' }) const bonus = Math.round(chargeAmount * CHARGE_BONUS_RATE) if (bonus > 0) { await WalletTransactionModel.create({ - user: userId, + user: payment.user_id, bucket: 'gift', type: 'credit', amount: bonus, referenceType: 'payment', + referenceId: payment._id, description: 'پاداش شارژ کیف پول (۵٪)' }) } + payment.status = 'successful' + await payment.save() + return res.redirect(successRedirect) } + payment.status = 'failed' + await payment.save() return res.redirect(failRedirect) } catch (error) { - console.error('handleWalletChargeCallback error:', error?.response?.data || error.message) + console.error('handleWalletChargeCallback error:', error?.response?.data || error?.message || error) + if (payment) { + try { + payment.status = 'failed' + await payment.save() + } catch (saveError) { + console.error('Failed to update wallet payment status:', saveError?.message || saveError) + } + } return res.redirect(failRedirect) } } diff --git a/index.js b/index.js index 5fe0574..e305c44 100644 --- a/index.js +++ b/index.js @@ -13,6 +13,7 @@ try { } const http = require('http').createServer(app); const multer = require('multer'); +const { buildZarinpalStartPayUrl } = require('./utils/zarinpalPaymentPage'); const cors = require('cors'); const jwt = require('jsonwebtoken'); const moment = require('moment-jalaali'); @@ -425,6 +426,17 @@ app.post('/api/v1/notification/send-sms', [blockCheck], async (req, res) => { // فایل/ویس توی چت (وب و اندروید، چون هر دو همین یه اندپوینتِ مشترک رو صدا میزنن) با // «Server error» شکست میخورد. همون الگوی multer.diskStorage که posts استفاده میکنه، // اینجا هم (مستقیماً روی پوشهی نهایی، بدونِ نیاز به fs.move جداگانه) وصل شد +app.get('/api/v1/pay/:authority', (req, res) => { + const authority = String(req.params.authority || '') + if (!/^[A-Za-z0-9]{1,64}$/.test(authority)) { + return res.status(400).send('invalid authority') + } + const target = buildZarinpalStartPayUrl(authority) + res.set('Content-Type', 'text/html; charset=utf-8') + res.set('Referrer-Policy', 'origin') + res.send(`
در حال انتقال به درگاه پرداخت…
ادامه`) +}) + const chatFileUploadDir = path.join(__dirname, '../storage/messages') if (!fs.existsSync(chatFileUploadDir)) { fs.mkdirSync(chatFileUploadDir, { recursive: true }) diff --git a/models/UserModel.js b/models/UserModel.js index d5da722..301967c 100644 --- a/models/UserModel.js +++ b/models/UserModel.js @@ -758,7 +758,7 @@ const userSchema = new mongoose.Schema({ }], monthly_free_offer: { type: Number, - default: 1 + default: 0 }, last_free_offer_date: { type: Date, diff --git a/utils/freeCreationLimit.js b/utils/freeCreationLimit.js index dbff7c4..51717da 100644 --- a/utils/freeCreationLimit.js +++ b/utils/freeCreationLimit.js @@ -1,3 +1,4 @@ +const UserModel = require('../models/UserModel') const FREE_LIFETIME_CREATION_LIMIT = 100 const canUseFreeCreation = (user) => { @@ -18,8 +19,20 @@ const consumeFreeCreation = async (user) => { } } + // updateOne + $inc (اتمیک، فقط همین فیلد) بهجای user.save() — save() کل سند رو + // ریولیدیت میکنه و برای کاربرهایی با مقدار نامعتبر در فیلدهای دیگه (مثلاً + // income_range) با ValidationError شکست میخوره + const res = await UserModel.updateOne( + { _id: user._id, lifetime_free_creations: { $lt: FREE_LIFETIME_CREATION_LIMIT } }, + { $inc: { lifetime_free_creations: 1 } } + ) + if (!res.modifiedCount) { + return { + ok: false, + message: `سقف ${FREE_LIFETIME_CREATION_LIMIT} ثبت رایگان (پروژه، بیلبورد و درخواست) به پایان رسیده است.`, + } + } user.lifetime_free_creations = (user.lifetime_free_creations || 0) + 1 - await user.save() return { ok: true } } diff --git a/utils/imageOptimize.js b/utils/imageOptimize.js index 524e9ba..c5d9998 100644 --- a/utils/imageOptimize.js +++ b/utils/imageOptimize.js @@ -57,3 +57,42 @@ async function optimizeImageFile(filePath, { maxDimension = DEFAULT_MAX_DIMENSIO } module.exports = { optimizeImageFile, DEFAULT_MAX_DIMENSION, DEFAULT_JPEG_QUALITY } + +const VARIANT_WIDTHS = [480, 800] + +/** + * نسخههای کوچکتر (WebP، با حفظ نسبت تصویر) کنار فایل اصلی میسازد و یک placeholder + * خیلی کمحجم هم میسازد. فایل اصلی دستنخورده میماند و نسخهای که از اصل بزرگتر + * نباشد ساخته نمیشود. + */ +async function generateImageVariants(filePath, { widths = VARIANT_WIDTHS } = {}) { + if (!filePath) return + try { + if (!(await fs.pathExists(filePath))) return + const metadata = await sharp(filePath, { failOn: 'none' }).metadata() + if (!metadata.width || !metadata.height) return + + const dir = path.dirname(filePath) + const base = path.basename(filePath, path.extname(filePath)) + const source = sharp(filePath, { failOn: 'none' }).rotate() + + for (const width of widths) { + if (width >= metadata.width) continue + await source + .clone() + .resize({ width, fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 80 }) + .toFile(path.join(dir, `${base}.w${width}.webp`)) + } + + await source + .clone() + .resize({ width: 24, fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 40 }) + .toFile(path.join(dir, `${base}.lqip.webp`)) + } catch (error) { + console.error('[imageOptimize] variants failed:', error.message) + } +} + +module.exports.generateImageVariants = generateImageVariants diff --git a/utils/zarinpalPaymentPage.js b/utils/zarinpalPaymentPage.js new file mode 100644 index 0000000..9d757b6 --- /dev/null +++ b/utils/zarinpalPaymentPage.js @@ -0,0 +1,18 @@ +const ZARINPAL_START_PAY = 'https://www.zarinpal.com/pg/StartPay' + +function getPaymentPageBase () { + return (process.env.APP_SITE_CallBack || process.env.APP_URL || '').replace(/\/$/, '') +} + +// زرینپال صفحهی پرداخت رو فقط وقتی باز میکنه که Referer درخواست یه دامنهی مجاز باشه؛ +// لینکِ مستقیمِ StartPay از مرورگرِ خارجی (مثلاً اندروید) Referer نداره و رد میشه. این +// آدرس روی دامنهی خودمون یه صفحهی redirect میزنه تا Referer درست ارسال بشه +function buildPaymentPageUrl (authority) { + return `${getPaymentPageBase()}/pay/${encodeURIComponent(String(authority))}` +} + +function buildZarinpalStartPayUrl (authority) { + return `${ZARINPAL_START_PAY}/${encodeURIComponent(String(authority))}` +} + +module.exports = { buildPaymentPageUrl, buildZarinpalStartPayUrl }