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

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