Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

profile.js 15KB

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