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 14KB

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