const Schmervice = require('@hapipal/schmervice') const cosineSimilarity = require('compute-cosine-similarity') const haversine = require('haversine') const runMatch = (allYins, allYangs) => { console.log(allYins.length, ':', allYangs.length) // You only need to engage from one side engageEveryone(allYins) console.log('---') } const engageEveryone = allYins => { let done do { console.log('rerunning...') done = true allYins.forEach(yin => { // Keep matching if no true pairing is found console.log(yin.realId, yin.otp?.realId) if (!yin.otp) { done = false const yang = yin.getNextCandidate() if (!yang.otp || yang.prefers(yin)) { yin.engageTo(yang) } } else { console.log(yin.otp.realId) } }) } while (!done) } class ProfileFacade { constructor(id, matchQueue) { this.realId = id ? id : undefined this.matchCandidateIndex = 0 this.otp = null this.matchQueue = matchQueue?.length ? matchQueue : [] this.fOrder = [] } clearPlaceholderMatches() { this.matchQueue = this.matchQueue.filter(match => match.realId == false) this.fOrder = this.fOrder.filter( potentialFiance => potentialFiance.realId == false, ) if (this.otp && this.otp.realId) { this.otp = null } } rank(id) { const idQueue = this.matchQueue.map( profileFacade => profileFacade.realId, ) return idQueue.includes(id) ? idQueue.indexOf(id) : this.matchQueue.length + 1 } prefers(p) { return this.rank(p.realId) < this.rank(this.otp.realId) } getNextCandidate() { if (this.matchCandidateIndex >= this.matchQueue.length) return null this.matchCandidateIndex = this.matchCandidateIndex + 1 return this.matchQueue[this.matchCandidateIndex - 1] } engageTo(p) { if (p.otp) { p.otp.otp = null } if (this.otp) { this.otp.otp = null } p.otp = this p.fOrder.unshift(this) this.otp = p this.fOrder.unshift(p) console.log( 'partners pref: ', this.otp.matchQueue.map(f => f.realId).indexOf(this.realId) + 1, 'choice', ) } } const magic = 1000 const scoreResponses = (seeker, potentialMatch) => { if (seeker.responses.length != potentialMatch.responses.length) return { error: `complete responses for profile: ${seeker.profile_id} unqeual to profile: ${potentialMatch.profile_id} | ${seeker.responses.length}:${potentialMatch.responses.length}`, } const checkValCb = res => { const val = parseInt(res.val) return isNaN(val) ? 0 : val } return Math.floor( cosineSimilarity( seeker.responses.map(checkValCb), potentialMatch.responses.map(checkValCb), ) * magic, ) } const filterByDistance = (profileList, max) => { return profileList.filter(profile => { const profileDistance = Math.floor(parseFloat(profile.distance) * 100) const adjustedMaxDistance = Math.floor(parseFloat(max) * 100) return profileDistance <= adjustedMaxDistance }) } const scoreAll = (profileList, userProfile) => { return profileList.map(profile => { return { // Uncomment to return the whole profile // ...profile, profile_id: profile.profile_id, score: scoreResponses(userProfile, profile), distance: profile.distance, } }) } /** * Grab the zip code string */ const getZipCodeFromProfile = profile => { // There should only be one zip code entry per profile let zip = profile.responses.filter( response => response.response_key_id == 16, )[0] const responseIndexForZip = profile.responses.indexOf(zip) if (responseIndexForZip >= 0) { profile.responses.splice(responseIndexForZip, 1) } return zip.val } /** * Class to hold our retrieved profile information * in a convenient wrapper * !: This needs to match the responseSchema in profiles.js */ class CompleteProfile { constructor(profile, type) { this.user_id = profile.user_id // int user_id this.profile_id = profile.profile_id // int profile_id this.responses = profile.responses // [] of all responses this.user_type = type } } module.exports = class ProfileService extends Schmervice.Service { constructor(...args) { super(...args) } /** * Internal method to get list of profile_ids for this user * @param {number} userId * @returns {Array} List of all profile_ids for user */ async _getProfileIdsForUserId(userId) { const { Profile } = this.server.models() /** Grab every Profile associated with this id */ const allProfiles = await Profile.query().where('user_id', userId) /** Copy a list of the just the Profiles */ const profileIdsToGrab = allProfiles.map(profile => profile.profile_id) /** Uncomment to dedupe the list just in case */ return [...new Set(profileIdsToGrab)] } async getCompleteProfilesFor(userId, type) { const { Profile } = this.server.models() const dedupedProfileIds = await this._getProfileIdsForUserId(userId) const profilesEntries = await Profile.query() .whereIn('profile_id', dedupedProfileIds) .withGraphFetched('responses') //** Get responses asociated with each profile_id */ return profilesEntries.map(profile => { return new CompleteProfile(profile, type) }) } /** * Save responses in a profile * @param {number} userId * @param {Array} responses * @returns {object} */ async saveResponsesCreateProfileFor(userId, responses, txn) { const { Profile, Response } = this.server.models() const profile = await Profile.query(txn).insert({ user_id: userId, }) for (const responseToSave of responses) { const responseInfo = { profile_id: profile.id, response_key_id: responseToSave.response_key_id, val: responseToSave.val, } await Response.query(txn).insert(responseInfo) } //** Work around for HAPI returning profile_id as id */ return { user_id: profile.user_id, profile_id: profile.id } } /** Update responses in place * @param {number} profileId * @param {Array} responses * @returns {Array} updated responses */ async updateResponsesInProfile(profileId, responses, txn) { const { Response } = this.server.models() for (const responseToSave of responses) { await Response.query(txn) .update({ response_id: responseToSave.response_id, profile_id: responseToSave.profile_id, response_key_id: responseToSave.response_key_id, val: responseToSave.val, }) .where({ profile_id: profileId, }) .where({ response_id: responseToSave.response_id, }) } return await Response.query(txn).where({ profile_id: profileId, }) } /** Add response * @param {Object} response to save * @returns {null} updated responses * @returns {Array} updated responses */ async saveResponseForProfile(profileId, responseToSave) { const { Response } = this.server.models() let allResponses = await Response.query().where({ profile_id: profileId, }) const matchingResponses = allResponses.filter( response => response.response_key_id == responseToSave.response_key_id, ) // ?:Maybe bad idea if (matchingResponses.length > 0) { return null } await await Response.query().insert(responseToSave) return allResponses } /** * Delete a profile * @param {number} userId * @param {number} profileId * @returns */ async deleteProfile(userId, profileId) { const { Profile } = this.server.models() const dedupedGroupings = await this._getProfileIdsForUserId(userId) /** Do NOTHING if NOT in Grouping */ if (!dedupedGroupings.includes(profileId)) return return await Profile.query().delete().where('profile_id', profileId) } /** * Score a profile * @param {number} profileId * @returns {Array} Ordered and scored Profiles */ async scoreProfilesFor(profileId, maxDistance, distanceUnit) { const { Profile } = this.server.models() // Our User Profile to score for const userProfile = await Profile.query() .findOne('profile_id', profileId) .withGraphFetched('responses') .withGraphFetched('user') // Move unneeded responses const userZip = getZipCodeFromProfile(userProfile) // Find all Profiles that are NOT of our userProfile.type // ie. If userProfile.type == seeker, then find: poster let profileIdsOfOppositeType = await Profile.query() .withGraphFetched('responses') .withGraphFetched('user') // TODO: Let Objection optimize this const isPosterOpposite = userProfile.user.is_poster == 1 ? 0 : 1 profileIdsOfOppositeType = profileIdsOfOppositeType.filter( profile => profile.user.is_poster == isPosterOpposite, ) const profilePlusDistance = await Promise.all( profileIdsOfOppositeType.map(async profile => { const targetZip = getZipCodeFromProfile(profile) const distance = await this._compareDistance( userZip, targetZip, distanceUnit, ) return { ...profile, distance: [distance.toFixed(2), distanceUnit], } }), ) const distanceFilteredProfiles = filterByDistance( profilePlusDistance, maxDistance, ) const scoredProfilesWithDistance = scoreAll( distanceFilteredProfiles, userProfile, ) // Order by score return scoredProfilesWithDistance.sort((a, b) => a.score - b.score) } async calcMatches() { const { Profile } = this.server.models() // Grab all profiles with matchQueues let allProfiles = await Profile.query().withGraphFetched('user') const seekerIds = allProfiles .filter(profile => profile.user.is_poster == 0) .map(profile => profile.profile_id) const posterIds = allProfiles .filter(profile => profile.user.is_poster == 1) .map(profile => profile.profile_id) let diff = Math.abs(posterIds.length - seekerIds.length) let smallerList = posterIds.length < seekerIds.length ? posterIds : seekerIds // ADD DUMMY IDS TO THE SMALLER LIST for (let d = 0; d < diff; d++) { smallerList.push(allProfiles.length + d) } // !:FAKE Score everyone const scoredProfileQueuesById = {} for (let profile of allProfiles) { const profileQueue = await this.scoreProfilesFor( profile.profile_id, 10000, 'mile', ) scoredProfileQueuesById[profile.profile_id] = profileQueue.map( profile => profile.profile_id, ) } const allProfileFacadesWithQueue = allProfiles.map(profile => { const profileFacadeQueue = scoredProfileQueuesById[ profile.profile_id ].map(id => { const subQueue = scoredProfileQueuesById[id].map( id => new ProfileFacade(id), ) return new ProfileFacade(id, subQueue) }) return new ProfileFacade(profile.profile_id, profileFacadeQueue) }) // // ! FAKE -- END const yins = seekerIds.map(id => allProfileFacadesWithQueue[id]) const yangs = posterIds.map(id => allProfileFacadesWithQueue[id]) runMatch(yins, yangs) return yins } /** * Use the db for zipcode info * @param {string} zipCode * @param {object} */ async _latLonForZip(zipCode) { const { ZipCode } = this.server.models() const zipInfo = await ZipCode.query().findOne( 'zip_code_id', parseInt(zipCode), ) if (!zipInfo) { console.log(zipCode) } return { latitude: parseFloat(zipInfo.latitude), longitude: parseFloat(zipInfo.longitude), } } /** * Get the distance between two zipcodes * using the haversine formula * @param {string} start_zip * @param {string} end_zip * @param {number} distance in miles */ async _compareDistance(start_zip, end_zip, distanceUnit) { if (!start_zip || !end_zip) return const start = await this._latLonForZip(start_zip) const end = await this._latLonForZip(end_zip) return haversine(start, end, { unit: distanceUnit }) } }