Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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