Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

profile.js 8.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. const Schmervice = require('@hapipal/schmervice')
  2. const cosineSimilarity = require('compute-cosine-similarity')
  3. const haversine = require('haversine')
  4. const profile = require('../plugins/profile')
  5. const magic = 1000
  6. const scoreResponses = (seeker, potentialMatch) => {
  7. if (seeker.responses.length != potentialMatch.responses.length)
  8. return {
  9. error: `complete responses for profile: ${seeker.profile_id} unqeual to profile: ${potentialMatch.profile_id} | ${seeker.responses.length}:${potentialMatch.responses.length}`,
  10. }
  11. const checkValCb = res => {
  12. const val = parseInt(res.val)
  13. return isNaN(val) ? 0 : val
  14. }
  15. return Math.floor(
  16. cosineSimilarity(
  17. seeker.responses.map(checkValCb),
  18. potentialMatch.responses.map(checkValCb),
  19. ) * magic,
  20. )
  21. }
  22. /**
  23. * Class to hold our retrieved profile information
  24. * in a convenient wrapper
  25. * !: This needs to match the responseSchema in profiles.js
  26. */
  27. class CompleteProfile {
  28. constructor(profile, type) {
  29. this.user_id = profile.user_id // int user_id
  30. this.profile_id = profile.profile_id // int profile_id
  31. this.responses = profile.responses // [] of all responses
  32. this.user_type = type
  33. }
  34. }
  35. module.exports = class ProfileService extends Schmervice.Service {
  36. constructor(...args) {
  37. super(...args)
  38. }
  39. /**
  40. * Internal method to get list of profile_ids for this user
  41. * @param {number} userId
  42. * @returns {Array} List of all profile_ids for user
  43. */
  44. async _getProfileIdsForUserId(userId) {
  45. const { Profile } = this.server.models()
  46. /** Grab every Profile associated with this id */
  47. const allProfiles = await Profile.query().where('user_id', userId)
  48. /** Copy a list of the just the Profiles */
  49. const profileIdsToGrab = allProfiles.map(profile => profile.profile_id)
  50. /** Uncomment to dedupe the list just in case */
  51. return [...new Set(profileIdsToGrab)]
  52. }
  53. async getCompleteProfilesFor(userId, type) {
  54. const { Profile } = this.server.models()
  55. const dedupedProfileIds = await this._getProfileIdsForUserId(userId)
  56. const profilesEntries = await Profile.query()
  57. .whereIn('profile_id', dedupedProfileIds)
  58. .withGraphFetched('responses')
  59. //** Get responses asociated with each profile_id */
  60. return profilesEntries.map(profile => {
  61. return new CompleteProfile(profile, type)
  62. })
  63. }
  64. /**
  65. * Save responses in a profile
  66. * @param {number} userId
  67. * @param {Array} responses
  68. * @returns {object}
  69. */
  70. async saveResponsesCreateProfileFor(userId, responses, txn) {
  71. const { Profile, Response } = this.server.models()
  72. const profile = await Profile.query(txn).insert({
  73. user_id: userId,
  74. })
  75. for (const responseToSave of responses) {
  76. const responseInfo = {
  77. profile_id: profile.id,
  78. response_key_id: responseToSave.response_key_id,
  79. val: responseToSave.val,
  80. }
  81. await Response.query(txn).insert(responseInfo)
  82. }
  83. //** Work around for HAPI returning profile_id as id */
  84. return { user_id: profile.user_id, profile_id: profile.id }
  85. }
  86. /** Update responses in place
  87. * @param {number} profileId
  88. * @param {Array} responses
  89. * @returns {Array} updated responses
  90. */
  91. async updateResponsesInProfile(profileId, responses, txn) {
  92. const { Response } = this.server.models()
  93. for (const responseToSave of responses) {
  94. await Response.query(txn)
  95. .update({
  96. response_id: responseToSave.response_id,
  97. profile_id: responseToSave.profile_id,
  98. response_key_id: responseToSave.response_key_id,
  99. val: responseToSave.val,
  100. })
  101. .where({
  102. profile_id: profileId,
  103. })
  104. .where({
  105. response_id: responseToSave.response_id,
  106. })
  107. }
  108. return await Response.query(txn).where({
  109. profile_id: profileId,
  110. })
  111. }
  112. /** Add response
  113. * @param {Object} response to save
  114. * @returns {null} updated responses
  115. * @returns {Array} updated responses
  116. */
  117. async saveResponseForProfile(profileId, responseToSave) {
  118. const { Response } = this.server.models()
  119. let allResponses = await Response.query().where({
  120. profile_id: profileId,
  121. })
  122. const matchingResponses = allResponses.filter(response => response.response_key_id == responseToSave.response_key_id)
  123. // ?:Maybe bad idea
  124. if(matchingResponses.length > 0) { return null }
  125. await await Response.query().insert(responseToSave)
  126. return allResponses
  127. }
  128. /**
  129. * Delete a profile
  130. * @param {number} userId
  131. * @param {number} profileId
  132. * @returns
  133. */
  134. async deleteProfile(userId, profileId) {
  135. const { Profile } = this.server.models()
  136. const dedupedGroupings = await this._getProfileIdsForUserId(userId)
  137. /** Do NOTHING if NOT in Grouping */
  138. if (!dedupedGroupings.includes(profileId)) return
  139. return await Profile.query().delete().where('profile_id', profileId)
  140. }
  141. /**
  142. * Grab the zip code string
  143. */
  144. _getZipCodeFromProfile(profile) {
  145. // There should only be one zip code entry per profile
  146. let zip = profile.responses.filter(response => response.response_key_id == 16)[0]
  147. const responseIndexForZip = profile.responses.indexOf(zip)
  148. if(responseIndexForZip >= 0) {
  149. profile.responses.splice(responseIndexForZip, 1)
  150. }
  151. return zip.val
  152. }
  153. /**
  154. * Score a profile
  155. * @param {number} profileId
  156. * @returns {Array} Ordered and scored Profiles
  157. */
  158. async scoreProfilesFor(profileId, maxDistanceMiles, distanceUnit) {
  159. const { Profile } = this.server.models()
  160. // Our User Profile to score for
  161. const userProfile = await Profile.query()
  162. .findOne('profile_id', profileId)
  163. .withGraphFetched('responses')
  164. .withGraphFetched('user')
  165. // Move unneeded responses
  166. const userZip = this._getZipCodeFromProfile(userProfile)
  167. // Find all Profiles that are NOT of our userProfile.type
  168. // ie. If userProfile.type == seeker, then find: poster
  169. let profileIdsOfOppositeType = await Profile.query()
  170. .withGraphFetched('responses')
  171. .withGraphFetched('user')
  172. // TODO: Let Objection optimize this
  173. const isPosterOpposite = userProfile.user.is_poster == 1 ? 0 : 1
  174. profileIdsOfOppositeType = profileIdsOfOppositeType.filter(profile => profile.user.is_poster == isPosterOpposite)
  175. const profilePlusDistance = await Promise.all(profileIdsOfOppositeType.map(async profile => {
  176. const targetZip = this._getZipCodeFromProfile(profile)
  177. const distance = await this._compareDistance(userZip, targetZip, distanceUnit)
  178. return {
  179. ...profile,
  180. distance: [distance.toFixed(2), distanceUnit]
  181. }
  182. }))
  183. // Filter by distance
  184. // TODO: probably do this with a query
  185. const distanceFiltered = profilePlusDistance.filter(profile => {
  186. const profileDistance = Math.floor(parseFloat(profile.distance) * 100)
  187. const adjustedMaxDistance = Math.floor(parseFloat(maxDistanceMiles) * 100)
  188. return profileDistance <= adjustedMaxDistance
  189. })
  190. const scored = distanceFiltered.map(profile => {
  191. return {
  192. // Uncomment to return the whole profile
  193. // ...profile,
  194. profile_id: profile.profile_id,
  195. score: scoreResponses(userProfile, profile),
  196. distance: profile.distance
  197. }
  198. })
  199. // Order by score
  200. return scored.sort((a, b) => a.score - b.score)
  201. }
  202. /**
  203. * Use the db for zipcode info
  204. * @param {string} zipCode
  205. * @param {object}
  206. */
  207. async _latLonForZip(zipCode) {
  208. const { ZipCode } = this.server.models()
  209. const zipInfo = await ZipCode.query().findOne('zip_code_id', parseInt(zipCode))
  210. const latitude = parseFloat(zipInfo.latitude)
  211. const longitude = parseFloat(zipInfo.longitude)
  212. return { latitude, longitude }
  213. }
  214. /**
  215. * Get the distance between two zipcodes
  216. * using the haversine formula
  217. * @param {string} start_zip
  218. * @param {string} end_zip
  219. * @param {number} distance in miles
  220. */
  221. async _compareDistance(start_zip, end_zip, distanceUnit) {
  222. if(!start_zip || !end_zip) return
  223. const start = await this._latLonForZip(start_zip)
  224. const end = await this._latLonForZip(end_zip)
  225. return haversine(start, end, { unit: distanceUnit })
  226. }
  227. }