Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. const Schmervice = require('@hapipal/schmervice')
  2. const cosineSimilarity = require('compute-cosine-similarity')
  3. const haversine = require('haversine')
  4. const zipcodeKey = 7
  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. const filterByDistance = (profileList, max) => {
  23. return profileList.filter(profile => {
  24. const profileDistance = Math.floor(parseFloat(profile.distance) * 100)
  25. const adjustedMaxDistance = Math.floor(parseFloat(max) * 100)
  26. return profileDistance <= adjustedMaxDistance
  27. })
  28. }
  29. const scoreAll = (profileList, userProfile) => {
  30. return profileList.map(profile => {
  31. return {
  32. // Uncomment to return the whole profile
  33. // ...profile,
  34. profile_id: profile.profile_id,
  35. score: scoreResponses(userProfile, profile),
  36. distance: profile.distance,
  37. }
  38. })
  39. }
  40. /**
  41. * Grab the zip code string
  42. */
  43. const getZipCodeFromProfile = profile => {
  44. // There should only be one zip code entry per profile
  45. let zipRes = profile.responses.filter(
  46. // Whatever the zipcode questions is
  47. response => response.response_key_id == zipcodeKey,
  48. )[0]
  49. const responseIndexForZip = profile.responses.indexOf(zipRes)
  50. if (responseIndexForZip >= 0) {
  51. profile.responses.splice(responseIndexForZip, 1)
  52. }
  53. return zipRes.val
  54. }
  55. /**
  56. * Class to hold our retrieved profile information
  57. * in a convenient wrapper
  58. * !: This needs to match the responseSchema in profiles.js
  59. */
  60. class CompleteProfile {
  61. constructor(profile, type) {
  62. this.user_id = profile.user_id // int user_id
  63. this.profile_id = profile.profile_id // int profile_id
  64. this.user_name = profile.user.user_name // string user_name
  65. this.user_media = profile.user_media // string user_media
  66. this.responses = profile.responses // [] of all responses
  67. this.user_type = type
  68. }
  69. }
  70. module.exports = class ProfileService extends Schmervice.Service {
  71. constructor(...args) {
  72. super(...args)
  73. }
  74. /**
  75. * Internal method to get list of profile_ids for this user
  76. * @param {number} userId
  77. * @returns {Array} List of all profile_ids for user
  78. */
  79. async _getProfileIdsForUserId(userId) {
  80. const { Profile } = this.server.models()
  81. /** Grab every Profile associated with this id */
  82. const allProfiles = await Profile.query().where('user_id', userId)
  83. /** Copy a list of the just the Profiles */
  84. const profileIdsToGrab = allProfiles.map(profile => profile.profile_id)
  85. /** Uncomment to dedupe the list just in case */
  86. return [...new Set(profileIdsToGrab)]
  87. }
  88. async getCompleteProfilesFor(userId, type) {
  89. const { Profile } = this.server.models()
  90. const dedupedProfileIds = await this._getProfileIdsForUserId(userId)
  91. const profilesEntries = await Profile.query()
  92. .whereIn('profile_id', dedupedProfileIds)
  93. .withGraphFetched('responses')
  94. // CHECKTHIS: Added this because we added user.user_name to CompleteProfile
  95. // so without this, we get undefined user_name
  96. .withGraphFetched('user')
  97. //** Get responses asociated with each profile_id */
  98. return profilesEntries.map(profile => {
  99. return new CompleteProfile(profile, type)
  100. })
  101. }
  102. async getProfilesFor(profileIdArray, type, includeResponses = true) {
  103. const { Profile } = this.server.models()
  104. // profilesEntries is profiles in database row order
  105. const profilesEntries = includeResponses ? await Profile.query()
  106. .whereIn('profile_id', profileIdArray)
  107. .withGraphFetched('responses')
  108. .withGraphFetched('user') : await Profile.query()
  109. .whereIn('profile_id', profileIdArray)
  110. .withGraphFetched('user')
  111. // taking the info from profilesEntries
  112. // to repack into completeProfiles
  113. // in same order as profileIdArray
  114. const completeProfiles = []
  115. profileIdArray.forEach(pid => {
  116. profilesEntries.forEach(entry => {
  117. if (entry.profile_id == pid) {
  118. const complete = new CompleteProfile(entry, type)
  119. if(!includeResponses) {
  120. delete complete['responses']
  121. }
  122. completeProfiles.push(complete)
  123. }
  124. })
  125. })
  126. return completeProfiles
  127. }
  128. /**
  129. * Save responses in a profile
  130. * @param {number} userId
  131. * @param {Array} responses
  132. * @returns {object}
  133. */
  134. async saveResponsesCreateProfileFor(userId, responses, txn) {
  135. const { Profile, Response } = this.server.models()
  136. const profile = await Profile.query(txn).insert({
  137. user_id: userId,
  138. })
  139. for (const responseToSave of responses) {
  140. const responseInfo = {
  141. profile_id: profile.id,
  142. response_key_id: responseToSave.response_key_id,
  143. val: responseToSave.val,
  144. }
  145. await Response.query(txn).insert(responseInfo)
  146. }
  147. //** Work around for HAPI returning profile_id as id */
  148. return { user_id: profile.user_id, profile_id: profile.id }
  149. }
  150. /** Update responses in place
  151. * @param {number} profileId
  152. * @param {Array} responses
  153. * @returns {Array} updated responses
  154. */
  155. async updateResponsesInProfile(profileId, responses, txn) {
  156. const { Response } = this.server.models()
  157. for (const responseToSave of responses) {
  158. await Response.query(txn)
  159. .update({
  160. response_id: responseToSave.response_id,
  161. profile_id: responseToSave.profile_id,
  162. response_key_id: responseToSave.response_key_id,
  163. val: responseToSave.val,
  164. })
  165. .where({
  166. profile_id: profileId,
  167. })
  168. .where({
  169. response_id: responseToSave.response_id,
  170. })
  171. }
  172. return await Response.query(txn).where({
  173. profile_id: profileId,
  174. })
  175. }
  176. /** Add response
  177. * @param {Object} response to save
  178. * @returns {null} updated responses
  179. * @returns {Array} updated responses
  180. */
  181. async saveResponseForProfile(profileId, responseToSave) {
  182. const { Response } = this.server.models()
  183. let allResponses = await Response.query().where({
  184. profile_id: profileId,
  185. })
  186. const matchingResponses = allResponses.filter(
  187. response =>
  188. response.response_key_id == responseToSave.response_key_id,
  189. )
  190. // Delete matches
  191. // ?:Maybe bad idea
  192. if (matchingResponses.length > 0) {
  193. const alreadyAnswered = matchingResponses.map(matchingRes => matchingRes.response_key_id)
  194. await Response.query()
  195. .where({ profile_id: profileId })
  196. .delete()
  197. .whereIn('response_key_id', alreadyAnswered)
  198. }
  199. await Response.query().insert(responseToSave)
  200. return allResponses
  201. }
  202. /**
  203. * Delete a profile
  204. * @param {number} userId
  205. * @param {number} profileId
  206. * @returns
  207. */
  208. async deleteProfile(userId, profileId) {
  209. const { Profile } = this.server.models()
  210. const dedupedGroupings = await this._getProfileIdsForUserId(userId)
  211. /** Do NOTHING if NOT in Grouping */
  212. if (!dedupedGroupings.includes(profileId)) return
  213. return await Profile.query().delete().where('profile_id', profileId)
  214. }
  215. /**
  216. * Score a profile
  217. * @param {number} profileId
  218. * @returns {Array} Ordered and scored Profiles
  219. */
  220. async scoreProfilesFor(profileId, maxDistance, distanceUnit) {
  221. const { Profile } = this.server.models()
  222. // Our User Profile to score for
  223. const userProfile = await Profile.query()
  224. .findOne('profile_id', profileId)
  225. .withGraphFetched('responses')
  226. .withGraphFetched('user')
  227. // Move unneeded responses
  228. const userZip = getZipCodeFromProfile(userProfile)
  229. // Find all Profiles that are NOT of our userProfile.type
  230. // ie. If userProfile.type == seeker, then find: poster
  231. let profileIdsOfOppositeType = await Profile.query()
  232. .withGraphFetched('responses')
  233. .withGraphFetched('user')
  234. // TODO: Let Objection optimize this
  235. const isPosterOpposite = userProfile.user.is_poster == 1 ? 0 : 1
  236. profileIdsOfOppositeType = profileIdsOfOppositeType.filter(
  237. profile => profile.user.is_poster == isPosterOpposite,
  238. )
  239. // Only include profiles that included zipcode response
  240. profileIdsOfOppositeType = profileIdsOfOppositeType.filter(profile => {
  241. const zipcodeResponses = profile.responses.filter(response => response.response_key_id == zipcodeKey)
  242. return zipcodeResponses.length > 0
  243. })
  244. const profilePlusDistance = await Promise.all(
  245. profileIdsOfOppositeType.map(async profile => {
  246. const targetZip = getZipCodeFromProfile(profile)
  247. if(!userZip || !targetZip) return { ...profile, distance: [9999, distanceUnit] }
  248. const distance = await this._compareDistance(
  249. userZip,
  250. targetZip,
  251. distanceUnit,
  252. )
  253. return {
  254. ...profile,
  255. distance: [distance.toFixed(2), distanceUnit],
  256. }
  257. }),
  258. )
  259. const distanceFilteredProfiles = filterByDistance(
  260. profilePlusDistance,
  261. maxDistance,
  262. )
  263. const scoredProfilesWithDistance = scoreAll(
  264. distanceFilteredProfiles,
  265. userProfile,
  266. )
  267. // Order by score
  268. return scoredProfilesWithDistance.sort((a, b) => a.score - b.score)
  269. }
  270. /**
  271. * Use the db for zipcode info
  272. * @param {string} zipCode
  273. * @param {object}
  274. */
  275. async _latLonForZip(zipCode) {
  276. const { ZipCode } = this.server.models()
  277. const zipInfo = await ZipCode.query().findOne(
  278. 'zip_code_id',
  279. parseInt(zipCode),
  280. )
  281. if (!zipInfo) {
  282. console.error('zip:', zipCode)
  283. }
  284. return {
  285. latitude: parseFloat(zipInfo.latitude),
  286. longitude: parseFloat(zipInfo.longitude),
  287. }
  288. }
  289. /**
  290. * Get the distance between two zipcodes
  291. * using the haversine formula
  292. * @param {string} start_zip
  293. * @param {string} end_zip
  294. * @param {number} distance in miles
  295. */
  296. async _compareDistance(start_zip, end_zip, distanceUnit) {
  297. if (!start_zip || !end_zip || isNaN(start_zip) || isNaN(end_zip)) return
  298. const start = await this._latLonForZip(start_zip)
  299. const end = await this._latLonForZip(end_zip)
  300. return haversine(start, end, { unit: distanceUnit })
  301. }
  302. }