4.1.3
This commit is contained in:
@@ -16,21 +16,24 @@ async function ensureGeoData(force = false) {
|
||||
const cities = JSON.parse(fs.readFileSync(path.join(dataDir, 'cities.json'), 'utf8'))
|
||||
|
||||
const provincesWithFaCount = provinces.filter((p) => p.name_fa).length
|
||||
const citiesWithFaCount = cities.filter((c) => c.name_fa).length
|
||||
|
||||
const [countryCount, provinceCount, cityCount, provinceFaCount] = await Promise.all([
|
||||
const [countryCount, provinceCount, cityCount, provinceFaCount, cityFaCount] = await Promise.all([
|
||||
CountryModel.countDocuments(),
|
||||
ProvinceModel.countDocuments(),
|
||||
CityModel.countDocuments(),
|
||||
ProvinceModel.countDocuments({ name_fa: { $exists: true, $ne: null } })
|
||||
ProvinceModel.countDocuments({ name_fa: { $exists: true, $ne: null } }),
|
||||
CityModel.countDocuments({ name_fa: { $exists: true, $ne: null } })
|
||||
])
|
||||
const inSync =
|
||||
countryCount === countries.length &&
|
||||
provinceCount === provinces.length &&
|
||||
cityCount === cities.length &&
|
||||
provinceFaCount === provincesWithFaCount
|
||||
provinceFaCount === provincesWithFaCount &&
|
||||
cityFaCount === citiesWithFaCount
|
||||
if (!force && inSync) return
|
||||
|
||||
console.log(`[boot] Geo reference data out of sync (countries: ${countryCount}/${countries.length}, provinces: ${provinceCount}/${provinces.length}, cities: ${cityCount}/${cities.length}) — reseeding...`)
|
||||
console.log(`[boot] Geo reference data out of sync (countries: ${countryCount}/${countries.length}, provinces: ${provinceCount}/${provinces.length}, cities: ${cityCount}/${cities.length}, city name_fa: ${cityFaCount}/${citiesWithFaCount}) — reseeding...`)
|
||||
|
||||
await CountryModel.deleteMany({})
|
||||
await ProvinceModel.deleteMany({})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
const OfferModel = require('../models/OfferModel')
|
||||
const WalletTransactionModel = require('../models/WalletTransactionModel')
|
||||
const { notifyUser } = require('./notifyUser')
|
||||
|
||||
const OFFER_REPLY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
const OFFER_REPLY_WINDOW_MS = 12 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Called whenever a chat message is sent. If there's a pending paid Offer
|
||||
* between these two users, tracks the requester's first direct message and
|
||||
* the receiver's reply — the receiver's 30% commission is only credited if
|
||||
* they reply within 24 hours of that first message.
|
||||
* they reply within 12 hours of that first message.
|
||||
*/
|
||||
async function trackOfferMessageForCommission (io, senderId, receiverId) {
|
||||
try {
|
||||
@@ -33,7 +34,7 @@ async function trackOfferMessageForCommission (io, senderId, receiverId) {
|
||||
return
|
||||
}
|
||||
|
||||
// پیام از طرف گیرنده — کسی که باید ظرف ۲۴ ساعت پاسخ بدهد
|
||||
// پیام از طرف گیرنده — کسی که باید ظرف ۱۲ ساعت پاسخ بدهد
|
||||
if (!offer.firstMessageAt || offer.repliedAt) return
|
||||
if (new Date() > offer.replyDeadline) return
|
||||
|
||||
@@ -65,4 +66,54 @@ async function trackOfferMessageForCommission (io, senderId, receiverId) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { trackOfferMessageForCommission, OFFER_REPLY_WINDOW_MS }
|
||||
/**
|
||||
* Sweep for paid Offers whose 12-hour reply deadline has passed without the
|
||||
* receiver replying — refunds the pending 30% commission to the sender's
|
||||
* wallet instead (both web and Android go through the same payment callback,
|
||||
* so a single server-side sweep covers both). Meant to be called
|
||||
* periodically by a cron job (see services/OfferReplyCron.js).
|
||||
*/
|
||||
async function refundExpiredOfferCommissions (io) {
|
||||
const now = new Date()
|
||||
const expiredOffers = await OfferModel.find({
|
||||
commissionCredited: false,
|
||||
commissionRefunded: false,
|
||||
pendingCommissionAmount: { $gt: 0 },
|
||||
firstMessageAt: { $ne: null },
|
||||
repliedAt: null,
|
||||
replyDeadline: { $lte: now }
|
||||
})
|
||||
|
||||
for (const offer of expiredOffers) {
|
||||
try {
|
||||
offer.commissionRefunded = true
|
||||
await offer.save()
|
||||
|
||||
await WalletTransactionModel.create({
|
||||
user: offer.sender,
|
||||
bucket: 'gift',
|
||||
type: 'credit',
|
||||
amount: offer.pendingCommissionAmount,
|
||||
referenceType: 'payment',
|
||||
referenceId: offer._id,
|
||||
description: 'بازگشت وجه درخواست همکاری (طرف مقابل ظرف ۱۲ ساعت مقرر پاسخ نداد)'
|
||||
})
|
||||
|
||||
await notifyUser(io, offer.sender, {
|
||||
type: 'offer_commission_refunded',
|
||||
title: 'بازگشت وجه به کیف پول',
|
||||
description: 'طرف مقابل ظرف مهلت ۱۲ ساعته به درخواست همکاری شما پاسخ نداد؛ مبلغ به کیف پول شما بازگشت.'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('refundExpiredOfferCommissions error for offer', offer._id, error)
|
||||
}
|
||||
}
|
||||
|
||||
return expiredOffers.length
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
trackOfferMessageForCommission,
|
||||
refundExpiredOfferCommissions,
|
||||
OFFER_REPLY_WINDOW_MS
|
||||
}
|
||||
|
||||
33
utils/panelPermissions.js
Normal file
33
utils/panelPermissions.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// فهرست همهی بخشهای پنل که میشه دسترسیِ جداگانه بهشون داد — هر کلید دقیقاً معادلِ
|
||||
// یه مسیر پنل (routes/index.js: app.use('/api/v1/panel/<key>', ...)). وقتی بخش جدیدی
|
||||
// به پنل اضافه شد، اینجا هم باید اضافه بشه وگرنه staff هیچوقت بهش دسترسی نمیگیره
|
||||
const PANEL_PERMISSIONS = [
|
||||
{ key: 'users', label: 'کاربران' },
|
||||
{ key: 'projects', label: 'پروژهها' },
|
||||
{ key: 'posts', label: 'پستها' },
|
||||
{ key: 'expertise', label: 'تخصصها' },
|
||||
{ key: 'shop-categories', label: 'دستهبندی فروشگاه' },
|
||||
{ key: 'marketplace-shops', label: 'فروشگاههای مارکتپلیس' },
|
||||
{ key: 'wallet-requests', label: 'درخواستهای کیف پول' },
|
||||
{ key: 'tickets', label: 'تیکتها' },
|
||||
{ key: 'financial', label: 'مالی' },
|
||||
{ key: 'comments', label: 'نظرات' },
|
||||
{ key: 'advertising', label: 'بیلبوردها' },
|
||||
{ key: 'shops', label: 'فروشگاههای بیلبورد' },
|
||||
{ key: 'settings', label: 'تنظیمات (مالیات/قیمت/CDN/SSL/ترافیک)' },
|
||||
{ key: 'version', label: 'نسخه اپلیکیشن' },
|
||||
{ key: 'stories', label: 'استوریها' },
|
||||
{ key: 'reports', label: 'گزارشهای محتوا' },
|
||||
{ key: 'suggestions', label: 'پیشنهادها' },
|
||||
{ key: 'moderation', label: 'داشبورد بازبینی' },
|
||||
{ key: 'analytics', label: 'آنالیتیکس' },
|
||||
{ key: 'academy-content', label: 'محتوای آموزشگاه' },
|
||||
{ key: 'licenses', label: 'مجوزها' },
|
||||
{ key: 'notifications', label: 'اعلانها' },
|
||||
{ key: 'user-management', label: 'مدیریت کاربران پنل' },
|
||||
{ key: 'system-history', label: 'تاریخچه سیستم' }
|
||||
]
|
||||
|
||||
const PANEL_PERMISSION_KEYS = new Set(PANEL_PERMISSIONS.map((p) => p.key))
|
||||
|
||||
module.exports = { PANEL_PERMISSIONS, PANEL_PERMISSION_KEYS }
|
||||
@@ -55,6 +55,43 @@ function matchesCriteria (user, criteria = {}) {
|
||||
if (!user.job || !user.job.toLowerCase().includes(criteria.job.toLowerCase())) return false
|
||||
}
|
||||
|
||||
// Multi-select dating-profile fields: match if the candidate's stored
|
||||
// array overlaps ANY of the alert's selected values — mirrors the $in
|
||||
// semantics of criteriaToMongoFilter in partnerSearchController.js.
|
||||
if (criteria.relationship_goal?.length) {
|
||||
const userValues = user.relationship_goal || []
|
||||
if (!criteria.relationship_goal.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.personality_traits?.length) {
|
||||
const userValues = user.personality_traits || []
|
||||
if (!criteria.personality_traits.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.lifestyle?.length) {
|
||||
const userValues = user.lifestyle || []
|
||||
if (!criteria.lifestyle.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.interests?.length) {
|
||||
const userValues = user.interests || []
|
||||
if (!criteria.interests.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
if (criteria.values?.length) {
|
||||
const userValues = user.values || []
|
||||
if (!criteria.values.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
|
||||
// Single-select dating-profile fields: exact match.
|
||||
if (criteria.seeking_gender && user.seeking_gender !== criteria.seeking_gender) return false
|
||||
if (criteria.relationship_view && user.relationship_view !== criteria.relationship_view) return false
|
||||
if (criteria.geo_preference && user.geo_preference !== criteria.geo_preference) return false
|
||||
if (criteria.smoking_status && user.smoking_status !== criteria.smoking_status) return false
|
||||
|
||||
// children_status is multi-select (a user may pick more than one status) —
|
||||
// same overlap semantics as the multi-select fields above.
|
||||
if (criteria.children_status?.length) {
|
||||
const userValues = user.children_status || []
|
||||
if (!criteria.children_status.some((v) => userValues.includes(v))) return false
|
||||
}
|
||||
|
||||
if (criteria.country_id && user.country?.id !== criteria.country_id) return false
|
||||
if (criteria.province_id && user.province?.id !== criteria.province_id) return false
|
||||
if (criteria.city_id && user.city?.id !== criteria.city_id) return false
|
||||
@@ -67,8 +104,11 @@ function matchesCriteria (user, criteria = {}) {
|
||||
const MATCH_RELEVANT_FIELDS = [
|
||||
'gender', 'height', 'weight', 'size', 'birthday', 'hair_color', 'eye_color',
|
||||
'marital_status', 'residence_type', 'residence_area', 'vehicle_type',
|
||||
'vehicle_details', 'personal_style', 'job', 'income_range', 'moral_traits',
|
||||
'country', 'province', 'city', 'is_Register'
|
||||
'vehicle_details', 'personal_style', 'job', 'income_range',
|
||||
'country', 'province', 'city', 'is_Register',
|
||||
'relationship_goal', 'seeking_gender', 'desired_age_range', 'personality_traits',
|
||||
'lifestyle', 'interests', 'values', 'relationship_view', 'geo_preference',
|
||||
'children_status', 'smoking_status'
|
||||
]
|
||||
|
||||
function shouldCheck (user) {
|
||||
|
||||
@@ -126,6 +126,11 @@ async function getSuggestedFollowUsers(userId, limit = 15) {
|
||||
previewPost: {
|
||||
_id: previewPost._id,
|
||||
type: previewPost.type,
|
||||
// مدل واقعی مدیا رو با post_images/post_video برمیگردونه، نه همیشه files —
|
||||
// بدون این دو تا، فرانتاند (RN/وب) نمیتونست فایلِ پیشنمایش رو پیدا کنه و
|
||||
// کارت کاملاً خالی میموند
|
||||
post_images: previewPost.post_images,
|
||||
post_video: previewPost.post_video,
|
||||
files: previewPost.files,
|
||||
caption: previewPost.caption,
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ const AdvertisingModel = require('../models/AdvertisingModel');
|
||||
const ShopModel = require('../models/ShopModel');
|
||||
const ShopProductListingModel = require('../models/ShopProductListingModel');
|
||||
const BookingConfigModel = require('../models/BookingConfigModel');
|
||||
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES } = require('./moderationStatus');
|
||||
const { PUBLIC_COURSE_STATUSES, PUBLIC_BILLBOARD_STATUSES, PUBLIC_PROJECT_STATUSES } = require('./moderationStatus');
|
||||
|
||||
// اعتبارسنجی مشترک «آیتم قابللینکشدن» — هم پست (linked_*_id) هم استوری (لینکاورلی) از
|
||||
// همین یه تابع استفاده میکنن تا شرط مالکیت+وضعیت هرکدوم دوبار نوشته نشه. خروجی: شناسهی
|
||||
@@ -30,10 +30,13 @@ async function resolveLinkedEntity(kind, id, userId) {
|
||||
}
|
||||
case 'project': {
|
||||
const project = await ProjectModel.findById(id).select('_id creator_id status').lean();
|
||||
// عین pkg/billboard — هر وضعیتِ واقعی/متعهدشده و غیرِ رد/لغوشده رو میپذیریم، نه فقط
|
||||
// done/accepted؛ وگرنه تقریباً هیچ پروژهی واقعیای (که بیشترِ عمرش pending/ongoing/
|
||||
// temp_accept هست، نه دقیقاً done/accepted) قابللینکشدن نبود
|
||||
if (
|
||||
project &&
|
||||
String(project.creator_id) === String(userId) &&
|
||||
['done', 'accepted'].includes(project.status)
|
||||
PUBLIC_PROJECT_STATUSES.includes(project.status)
|
||||
) {
|
||||
return String(project._id);
|
||||
}
|
||||
@@ -53,14 +56,20 @@ async function resolveLinkedEntity(kind, id, userId) {
|
||||
}
|
||||
case 'shop': {
|
||||
const shop = await ShopModel.findById(id).select('_id owner status').lean();
|
||||
if (shop && String(shop.owner) === String(userId) && shop.status === 'active') {
|
||||
// draft/rejected هنوز واقعاً وجود عمومی ندارن، ولی pending_review (ثبتشده، منتظرِ
|
||||
// تایید) هم مثل بقیهی موارد بالا باید قابللینک باشه — فقط active بیشازحد سختگیرانه بود
|
||||
if (
|
||||
shop &&
|
||||
String(shop.owner) === String(userId) &&
|
||||
['active', 'pending_review'].includes(shop.status)
|
||||
) {
|
||||
return String(shop._id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case 'shopproduct': {
|
||||
const listing = await ShopProductListingModel.findById(id).select('_id shop status').lean();
|
||||
if (!listing || listing.status !== 'active') return null;
|
||||
if (!listing || !['active', 'pending_shop_approval'].includes(listing.status)) return null;
|
||||
const shop = await ShopModel.findById(listing.shop).select('_id owner').lean();
|
||||
if (shop && String(shop.owner) === String(userId)) {
|
||||
return String(listing._id);
|
||||
|
||||
24
utils/storyPhotoFilters.js
Normal file
24
utils/storyPhotoFilters.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const sharp = require('sharp')
|
||||
|
||||
// فیلترهای رنگ/نورِ سبک اینستاگرام — با sharp (رایگان، بدون لایسنس/کلید API، فقط
|
||||
// پردازش تصویر روی سرور). هر پیشتنظیم ترکیبی از modulate (اشباع/روشنایی/چرخشرنگ)،
|
||||
// tint (رنگآمیزیِ کلی برای حس گرم/سرد/قدیمی) و linear (کنتراست) است.
|
||||
const STORY_PHOTO_FILTERS = {
|
||||
normal: (img) => img,
|
||||
warm: (img) => img.modulate({ brightness: 1.05, saturation: 1.15 }).tint({ r: 255, g: 230, b: 200 }),
|
||||
cool: (img) => img.modulate({ brightness: 1.0, saturation: 1.1 }).tint({ r: 210, g: 230, b: 255 }),
|
||||
bw: (img) => img.grayscale().modulate({ brightness: 1.05 }),
|
||||
vivid: (img) => img.modulate({ saturation: 1.6, brightness: 1.08 }).sharpen(),
|
||||
fade: (img) => img.modulate({ saturation: 0.65, brightness: 1.12 }).linear(0.85, 25),
|
||||
vintage: (img) => img.modulate({ saturation: 0.75, brightness: 1.02 }).tint({ r: 255, g: 235, b: 205 })
|
||||
}
|
||||
|
||||
const STORY_PHOTO_FILTER_NAMES = Object.keys(STORY_PHOTO_FILTERS)
|
||||
|
||||
/** buffer ورودی -> بافرِ jpeg خروجی با فیلترِ اعمالشده. filterName نامعتبر => همون تصویر اصلی. */
|
||||
async function applyStoryPhotoFilter (buffer, filterName) {
|
||||
const apply = STORY_PHOTO_FILTERS[filterName] || STORY_PHOTO_FILTERS.normal
|
||||
return apply(sharp(buffer)).jpeg({ quality: 90 }).toBuffer()
|
||||
}
|
||||
|
||||
module.exports = { STORY_PHOTO_FILTERS, STORY_PHOTO_FILTER_NAMES, applyStoryPhotoFilter }
|
||||
@@ -1,5 +1,5 @@
|
||||
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
|
||||
const USERNAME_MIN_LENGTH = 8
|
||||
const USERNAME_MIN_LENGTH = 6
|
||||
const USERNAME_MAX_LENGTH = 30
|
||||
|
||||
/** first char letter/digit, then up to (max-1) allowed chars */
|
||||
|
||||
Reference in New Issue
Block a user