Files
modstagram-back/utils/partnerMatch.js
payacom c302aea177 4.1.3
2026-09-13 21:32:36 +03:30

187 lines
7.9 KiB
JavaScript

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
}
// 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
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',
'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) {
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 }