const Schmervice = require('@hapipal/schmervice') const haversine = require('haversine') const config = require('../../db/data-generator/config.json') const _isScorableResponse = res_key_id => { let isScorable = false if(config.resKeys.includes(res_key_id)) { isScorable = true } return isScorable } const scoreResponses = (seeker, potentialMatch, prescoreLookup) => { 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 aRes = seeker.responses.filter( res => _isScorableResponse(res.response_key_id) ) const bRes = potentialMatch.responses.filter( res => _isScorableResponse(res.response_key_id) ) const composite = [] while (aRes.length + bRes.length > 0) { const mKey = resList => { let el = resList.shift() let pair = el.val el = resList.shift() return `${pair}:${el.val}` } composite.push(prescoreLookup[mKey(aRes)][mKey(bRes)]) } return { total: Math.round(composite.reduce((a, b) => a + b) / composite.length), aspects: composite, } } 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, prescoreLookup) => { return profileList.map(profile => { return { // Uncomment to return the whole profile // ...profile, profile_id: profile.profile_id, score: scoreResponses(userProfile, profile, prescoreLookup), distance: profile.distance, } }) } /** * Grab the zip code string */ const getZipCodeFromProfile = profile => { // There should only be one zip code entry per profile let zipRes = profile.responses.find( res => res.response_key_id == 7 ) return zipRes.val } const makeScoreLookup = (aspects, labels) => { const labelLookup = {} labels.forEach(label => (labelLookup[label.aspect_id] = label)) const scoreLookup = {} aspects.forEach(aspect => { const key = labelLookup[aspect.aspect_id] scoreLookup[`${key.a}:${key.b}`] = {} Object.keys(aspect).forEach(aspect_id => { if (!labelLookup[aspect_id]) return const comp = labelLookup[aspect_id] const score = aspect[aspect_id] scoreLookup[`${key.a}:${key.b}`][`${comp.a}:${comp.b}`] = score }) }) return scoreLookup } /** * 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.user_name = profile.user.user_name // string user_name this.responses = [] this.tags = profile.tags // [] of all tags this.user_type = type // TODO: generalize this for multiple images, and languages this.profile_description = '' this.profile_media = [] this.profile_languages = [] this.profile_prefs = {} if (profile?.responses?.length) { // [] of all "profile" responses this.responses = profile.responses // image, language, duration, presence, blurb, urgency, role, pronouns, distance const prefs = ['presence', 'duration', 'zipcode'] prefs.forEach(pref => { this.profile_prefs[pref] = this.responses.filter( r => r.response_key_prompt === pref )[0] }) this.profile_description = this.responses.filter(r=> r.response_key_prompt === 'blurb')[0] this.profile_media = this.responses.filter(r => r.response_key_prompt === 'image') this.profile_languages = this.responses.filter(r => r.response_key_prompt === 'language') } } } module.exports = class ProfileService extends Schmervice.Service { constructor(...args) { super(...args) this.scoreLookup = {} this.tagLookup = {} // this.responseKeyLookup = ResponseKey.query() } async _setScoreLookup() { if (!Object.keys(this.scoreLookup).length) { const { Aspect, AspectLabel } = this.server.models() const aspects = await Aspect.query() const labels = await AspectLabel.query() this.scoreLookup = makeScoreLookup(aspects, labels) } } async _setTagLookup() { if (!Object.keys(this.tagLookup).length) { const { Tag } = this.server.models() const allTagDescriptions = await Tag.query() allTagDescriptions.forEach( desc => (this.tagLookup[desc.tag_id] = { description: desc.tag_description, category: desc.tag_category, }), ) } } /** * 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 getProfile(profileId) { const { Profile } = this.server.models() await this._setTagLookup() const matchingProfile = await Profile.query() .where('profile_id', profileId) .first() .withGraphFetched('tags') .withGraphFetched('responses') .withGraphFetched('user') if(matchingProfile?.tags.length){ matchingProfile.tags = matchingProfile.tags.map( tag => this.tagLookup[tag.tag_id], ) } return new CompleteProfile(matchingProfile) } async getCompleteProfilesFor(userId, type) { const { Profile } = this.server.models() await this._setTagLookup() const dedupedProfileIds = await this._getProfileIdsForUserId(userId) const profilesEntries = await Profile.query() .whereIn('profile_id', dedupedProfileIds) .withGraphFetched('tags') .withGraphFetched('responses') // CHECKTHIS: Added this because we added user.user_name to CompleteProfile // so without this, we get undefined user_name .withGraphFetched('user') profilesEntries.forEach(profile => { profile.tags = profile.tags.map(tag => this.tagLookup[tag.tag_id]) }) //** Get responses asociated with each profile_id */ return profilesEntries.map(profile => { return new CompleteProfile(profile, type) }) } async getProfilesFor(profileIdArray, type, includeResponses = true) { const { Profile } = this.server.models() await this._setScoreLookup() await this._setTagLookup() // profilesEntries is profiles in dataaspect_labelsbase row order const profilesEntries = await Profile.query() .whereIn('profile_id', profileIdArray) .withGraphFetched('tags') .withGraphFetched('responses') .withGraphFetched('user') // taking the info from profilesEntries // to repack into completeProfiles // in same order as profileIdArray const completeProfiles = [] profileIdArray.forEach(pid => { profilesEntries.forEach(entry => { if (entry.profile_id == pid) { const complete = new CompleteProfile(entry, type) if (!includeResponses) { delete complete['responses'] } if (entry?.tags?.length) { complete.tags = entry.tags.map( tag => this.tagLookup[tag.tag_id], ) } completeProfiles.push(complete) } }) }) return completeProfiles } /** * 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) { /** * Convert indexes to actual score values * Using using the input and converting to index * of the generated possible prescore array in config * DUPLICATE:See saveResponseForProfile() line 343 */ let convertedResponse = responseToSave if(_isScorableResponse(responseToSave.response_key_id)) { // Convert -3 to 0, 0 to 3, 3 to 6 const offset = (config.scoreVals.length - 1) / 2 const indexFromInput = parseInt(responseToSave.val) + offset convertedResponse.val = config.scoreVals[indexFromInput].toString() } const responseInfo = { profile_id: profile.id, response_key_id: convertedResponse.response_key_id, val: convertedResponse.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, }) // Delete matches // ?:Maybe bad idea const matchingResponses = allResponses.filter( response => response.response_key_id == responseToSave.response_key_id, ) if (matchingResponses.length > 0) { const alreadyAnswered = matchingResponses.map( matchingRes => matchingRes.response_key_id ) await Response.query() .where({ profile_id: profileId }) .delete() .whereIn('response_key_id', alreadyAnswered) } /** * Convert indexes to actual score values * Using using the input and converting to index * of the generated possible prescore array in config */ let convertedResponse = responseToSave if(_isScorableResponse(responseToSave.response_key_id)) { // Convert -3 to 0, 0 to 3, 3 to 6 const offset = (config.scoreVals.length - 1) / 2 const indexFromInput = parseInt(responseToSave.val) + offset convertedResponse.val = config.scoreVals[indexFromInput].toString() } await Response.query().insert(convertedResponse) 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() await this._setScoreLookup() // 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 => { return profile.user.is_poster == isPosterOpposite }).filter(profile => { // Only include profiles that included zipcode response return getZipCodeFromProfile(profile) ? true : false }) const profilePlusDistance = await Promise.all( profileIdsOfOppositeType.map(async profile => { const targetZip = getZipCodeFromProfile(profile) if (!userZip || !targetZip) return { ...profile, distance: [9999, distanceUnit] } 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, this.scoreLookup, ) // Order by score return scoredProfilesWithDistance.sort( (a, b) => b.score.total - a.score.total, ) } /** * 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.error('zip:', 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 || isNaN(start_zip) || isNaN(end_zip)) return const start = await this._latLonForZip(start_zip) const end = await this._latLonForZip(end_zip) return haversine(start, end, { unit: distanceUnit }) } }