This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

146
utils/partnerMatch.js Normal file
View File

@@ -0,0 +1,146 @@
const PartnerSearchAlertModel = require('../models/PartnerSearchAlertModel')
const NotificationModel = require('../models/NotificationModel')
const { sendPushToTokens } = require('./pushNotification')
let ioInstance = null
/** یک‌بار از index.js صدا زده می‌شه — مثل الگوی notifyFollowers.js */
function setIo (io) {
ioInstance = io
}
function ageFromBirthday (birthday) {
if (!birthday) return null
const diffMs = Date.now() - new Date(birthday).getTime()
return Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000))
}
function inRange (value, min, max) {
if (value === null || value === undefined || Number.isNaN(value)) return false
if (min !== undefined && min !== null && value < min) return false
if (max !== undefined && max !== null && value > max) return false
return true
}
/**
* Compares one user document against one saved alert's criteria. Runs as a
* plain JS predicate (not a Mongo query) since it always checks a single
* already-loaded document, not a collection.
*/
function matchesCriteria (user, criteria = {}) {
if (criteria.gender && user.gender !== criteria.gender) return false
if (criteria.heightMin !== undefined || criteria.heightMax !== undefined) {
if (!inRange(Number(user.height), criteria.heightMin, criteria.heightMax)) return false
}
if (criteria.weightMin !== undefined || criteria.weightMax !== undefined) {
if (!inRange(Number(user.weight), criteria.weightMin, criteria.weightMax)) return false
}
if (criteria.sizeMin !== undefined || criteria.sizeMax !== undefined) {
if (!inRange(Number(user.size), criteria.sizeMin, criteria.sizeMax)) return false
}
if (criteria.ageMin !== undefined || criteria.ageMax !== undefined) {
if (!inRange(ageFromBirthday(user.birthday), criteria.ageMin, criteria.ageMax)) return false
}
if (criteria.hair_color && user.hair_color !== criteria.hair_color) return false
if (criteria.eye_color && user.eye_color !== criteria.eye_color) return false
if (criteria.marital_status && user.marital_status !== criteria.marital_status) return false
if (criteria.residence_type && user.residence_type !== criteria.residence_type) return false
if (criteria.vehicle_type && user.vehicle_type !== criteria.vehicle_type) return false
if (criteria.personal_style && user.personal_style !== criteria.personal_style) return false
if (criteria.income_range && user.income_range !== criteria.income_range) return false
if (criteria.job) {
if (!user.job || !user.job.toLowerCase().includes(criteria.job.toLowerCase())) 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
return true
}
/** Only re-check when a field a saved search could plausibly filter on has
* actually changed — avoids running on unrelated saves like `last_online`. */
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'
]
function shouldCheck (user) {
return MATCH_RELEVANT_FIELDS.some((field) => user.isModified(field))
}
/** Finds every active alert this user now matches (that hasn't already been
* notified about this exact user) and notifies each alert owner once.
* Caller is responsible for the shouldCheck() gate — isModified() is only
* meaningful in pre('save'), so it can't be re-checked here in a post('save')
* context (Mongoose clears the modified-paths tracking before post hooks run). */
async function checkPartnerAlerts (user) {
try {
const alerts = await PartnerSearchAlertModel.find({
active: true,
user_id: { $ne: user._id },
notified_user_ids: { $ne: user._id }
})
if (alerts.length === 0) return
const matchedAlerts = alerts.filter((alert) => matchesCriteria(user, alert.criteria))
if (matchedAlerts.length === 0) return
const title = 'همکار پیشنهادی جدید'
const description = 'کاربری با مشخصات مورد نظر شما به مجموعه مدستاگرام پیوست'
const notifications = matchedAlerts.map((alert) => ({
user_id: alert.user_id,
type: 'partner_match',
title,
description,
project_post_id: user._id,
actor_id: user._id
}))
// create() (not insertMany) — insertMany skips document middleware, so the
// mongoose-timestamp plugin never runs and createdAt/updatedAt are left
// unset, which sorts these notifications to the very last page of the list
await NotificationModel.create(notifications)
await Promise.all(
matchedAlerts.map((alert) =>
PartnerSearchAlertModel.updateOne(
{ _id: alert._id },
{ $addToSet: { notified_user_ids: user._id } }
)
)
)
if (ioInstance) {
matchedAlerts.forEach((alert) => {
try {
ioInstance.to(`user:${String(alert.user_id)}`).emit('notification', { type: 'partner_match', title, description })
} catch (_) {}
})
}
const UserModel = require('../models/UserModel')
const alertOwners = await UserModel.find({ _id: { $in: matchedAlerts.map((a) => a.user_id) } })
.select('fcm_tokens fcm_web_tokens')
.lean()
const tokens = alertOwners.flatMap((u) => [...(u.fcm_tokens || []), ...(u.fcm_web_tokens || [])])
if (tokens.length > 0) {
await sendPushToTokens(tokens, {
title,
description,
type: 'partner_match',
data: { project_post_id: String(user._id) }
}).catch(() => {})
}
} catch (error) {
console.error('[checkPartnerAlerts] خطا:', error.message)
}
}
module.exports = { matchesCriteria, checkPartnerAlerts, setIo, ageFromBirthday, shouldCheck }