const Schmervice = require('@hapipal/schmervice') const haversine = require('haversine') const config = require('../../../db/data-generator/config.json') const profiler = require('./profiler') const scoring = require('./scorer') const zipcoder = require('./zipcoder') module.exports = class ProfileService extends Schmervice.Service { constructor(...args) { super(...args) /** Scores available in the db to map against score indices*/ this.scoreLookup = {} /** Tags available in the db to map against tag_associations*/ 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 = scoring.makeScoreLookup(aspects, labels) } } async _setTagLookup() { /** Grab tag descriptions if they do NOT exist: Needed once per app load */ if (Object.keys(this.tagLookup).length) return const { Tag } = this.server.models() const allTagDescriptions = await Tag.query() allTagDescriptions.forEach(desc => { if (!desc.is_active) return this.tagLookup[desc.tag_id] = desc }) } /** * 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') matchingProfile.tags = matchingProfile.tags.map( tag => this.tagLookup[tag.tag_id], ) const complete = new profiler.CompleteProfile(matchingProfile, true) // TODO: Refactor to use this.setDefaults(), currently does not play // nice with reveal functionality return complete // return this.setDefaults(complete) } 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') return profiler.makeCompleteFromProfileEntries( profilesEntries, type, this.tagLookup, ) } 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 return profiler.makeOrderedCompleteProfiles( profileIdArray, profilesEntries, type, includeResponses, this.tagLookup, ) } /** * 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 (scoring._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 (scoring._isScorableResponse(responseToSave.response_key_id)) { // Convert -3 to 0, 0 to 3, 3 to 6 const offset = (config.scoreVals.length - 1) / 2 const scoreFromInput = parseInt(responseToSave.val) + offset const scoreFromConfig = config.scoreVals.indexOf(scoreFromInput) if (scoreFromConfig < 0) { console.error('score not found in possible config responses') } convertedResponse.val = scoreFromConfig.toString() } await Response.query().insert(convertedResponse) return allResponses } /** * Sets default profile attributes * @param {object} complete * @returns {object} updated profile */ setDefaults(complete) { let defaultValues = { user_email: 'hidden@email.com', user_name: 'Hidden Name', } let defaultProfile = { ...complete, user_email: defaultValues.user_email, user_name: defaultValues.user_name, } console.log('---') console.log('defaultProfile: ', defaultProfile.user_email) if (!complete.reveal.length) return defaultProfile // nothing to reveal // swap out values of keys that are not found as complete.reveal.tag.tag_description values for (let [attribute, defaultVal] of Object.entries(defaultValues)) { if ( typeof complete.reveal.find( tag => tag.tag_description == attribute, ) == 'undefined' ) complete[attribute] = defaultVal } console.log('complete: ', complete.user_email) return complete } /** * 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 = zipcoder.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 zipcoder.getZipCodeFromProfile(profile) ? true : false }) const profilePlusDistance = await Promise.all( profileIdsOfOppositeType.map(async profile => { const targetZip = zipcoder.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 = zipcoder.filterByDistance( profilePlusDistance, maxDistance, ) const scoredProfilesWithDistance = scoring.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 }) } /** * Use the db to grab tag associations * by profile and match them to tag types * @param {number} profileId * @param {object} */ async getTagsFor(profileId, groupingId, category) { const { TagAssociation } = this.server.models() await this._setTagLookup() let associations = groupingId ? await TagAssociation.query() .where('grouping_id', groupingId) .andWhere('profile_id', profileId) : await TagAssociation.query().andWhere('profile_id', profileId) return associations .map(assoc => ({ ...assoc, tag: this.tagLookup[assoc.tag_id], })) .filter(tagWithAssoc => { return category ? tagWithAssoc.tag.tag_category == category : true }) } /** * Use the db to grab tag associations * by profile, grouping, tag, and insert * it if it already exists * @param {object} association */ async revealProfileInfo(association) { const { TagAssociation } = this.server.models() const existingAssociations = await TagAssociation.query() .where('profile_id', `${association.profile_id}`) .where('grouping_id', `${association.grouping_id}`) .where('tag_id', `${association.tag_id}`) .where('is_deleted', 0) if (!existingAssociations.length) { await TagAssociation.query().insert(association) return await this.getTagsFor(association.profile_id) } else { return console.error('tag association already exists') } } }