Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

profile.js 16KB

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