You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

profile.js 15KB

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