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 }