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

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