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.

index.js 14KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. const Schmervice = require('@hapipal/schmervice')
  2. const haversine = require('haversine')
  3. const config = require('../../../db/data-generator/config.json')
  4. const profiler = require('./profiler')
  5. const scoring = require('./scorer')
  6. const zipcoder = require('./zipcoder')
  7. const Filter = require('../filter')
  8. const filter = new Filter()
  9. module.exports = class ProfileService extends Schmervice.Service {
  10. constructor(...args) {
  11. super(...args)
  12. /** Scores available in the db to map against score indices*/
  13. this.scoreLookup = {}
  14. /** Tags available in the db to map against tag_associations*/
  15. this.tagLookup = {}
  16. // this.responseKeyLookup = ResponseKey.query()
  17. }
  18. async _setScoreLookup() {
  19. if (!Object.keys(this.scoreLookup).length) {
  20. const { Aspect, AspectLabel } = this.server.models()
  21. const aspects = await Aspect.query()
  22. const labels = await AspectLabel.query()
  23. this.scoreLookup = scoring.makeScoreLookup(aspects, labels)
  24. }
  25. }
  26. async _setTagLookup() {
  27. /** Grab tag descriptions if they do NOT exist: Needed once per app load */
  28. if (Object.keys(this.tagLookup).length) return
  29. const { Tag } = this.server.models()
  30. const allTagDescriptions = await Tag.query()
  31. if (allTagDescriptions.length) {
  32. allTagDescriptions.forEach(desc => {
  33. if (!desc.is_active) return
  34. this.tagLookup[desc.tag_id] = desc
  35. })
  36. } else return
  37. }
  38. /**
  39. * Internal method to get list of profile_ids for this user
  40. * @param {number} userId
  41. * @returns {Array} List of all profile_ids for user
  42. */
  43. async _getProfileIdsForUserId(userId) {
  44. const { Profile } = this.server.models()
  45. /** Grab every Profile associated with this id */
  46. const allProfiles = await Profile.query().where('user_id', userId)
  47. /** Copy a list of the just the Profiles */
  48. return typeof allProfiles === 'object' && allProfiles !== null
  49. ? [...new Set(allProfiles.map(profile => profile.profile_id))]
  50. : [allProfiles]
  51. }
  52. /**
  53. * Convert indexes to actual score values
  54. * Using using the input and converting to index
  55. * of the generated possible prescore array in config
  56. */
  57. _convertResponse(responseToSave) {
  58. if (scoring._isScorableResponse(responseToSave.response_key_id)) {
  59. // Convert -3 to 0, 0 to 3, 3 to 6
  60. const offset = (config.scoreVals.length - 1) / 2
  61. const scoreFromInput = parseInt(responseToSave.val) + offset
  62. const scoreFromConfig = config.scoreVals.indexOf(scoreFromInput)
  63. if (scoreFromConfig < 0) {
  64. console.error('score not found in possible config responses')
  65. }
  66. responseToSave.val = scoreFromConfig.toString()
  67. }
  68. return responseToSave
  69. }
  70. async getProfile(profileId) {
  71. const { Profile } = this.server.models()
  72. await this._setTagLookup()
  73. const matchingProfile = await Profile.query()
  74. .where('profile_id', profileId)
  75. .first()
  76. .withGraphFetched('tags')
  77. .withGraphFetched('responses')
  78. .withGraphFetched('user')
  79. matchingProfile.tags = matchingProfile.tags.map(
  80. tag => this.tagLookup[tag.tag_id],
  81. )
  82. return new profiler.CompleteProfile(matchingProfile)
  83. }
  84. async getCompleteProfilesFor(userId, type) {
  85. const { Profile } = this.server.models()
  86. await this._setTagLookup()
  87. const dedupedProfileIds = await this._getProfileIdsForUserId(userId)
  88. const profilesEntries = await Profile.query()
  89. .whereIn('profile_id', dedupedProfileIds)
  90. .withGraphFetched('tags')
  91. .withGraphFetched('responses')
  92. .withGraphFetched('user')
  93. return profiler.makeCompleteFromProfileEntries(
  94. profilesEntries,
  95. type,
  96. this.tagLookup,
  97. )
  98. }
  99. async getProfilesFor(profileIdArray, type) {
  100. const { Profile } = this.server.models()
  101. await this._setScoreLookup()
  102. await this._setTagLookup()
  103. // profilesEntries is profiles in database row order
  104. const profilesEntries = await Profile.query()
  105. .whereIn('profile_id', profileIdArray)
  106. .withGraphFetched('tags')
  107. .withGraphFetched('responses')
  108. .withGraphFetched('user')
  109. // taking the info from profilesEntries
  110. // to repack into completeProfiles
  111. // in same order as profileIdArray
  112. return profiler.makeOrderedCompleteProfiles(
  113. profileIdArray,
  114. profilesEntries,
  115. type,
  116. this.tagLookup,
  117. )
  118. }
  119. /**
  120. * Save responses in a profile
  121. * @param {number} userId
  122. * @param {Array} responses
  123. * @returns {object}
  124. */
  125. async saveResponsesCreateProfileFor(userId, responses, txn) {
  126. const { Profile, Response } = this.server.models()
  127. try {
  128. const profile = await Profile.query(txn).insert({
  129. user_id: userId,
  130. })
  131. for (const responseToSave of responses) {
  132. const convertedResponse = this._convertResponse(responseToSave)
  133. const responseInfo = {
  134. profile_id: profile.id,
  135. response_key_id: convertedResponse.response_key_id,
  136. val: convertedResponse.val,
  137. }
  138. await Response.query(txn).insert(responseInfo)
  139. }
  140. //** Work around for HAPI returning profile_id as id */
  141. return { user_id: profile.user_id, profile_id: profile.id }
  142. } catch (err) {
  143. throw new Error(err)
  144. }
  145. }
  146. /** Update responses in place
  147. * @param {number} profileId
  148. * @param {Array} responses
  149. * @returns {Array} updated responses
  150. */
  151. async updateResponsesInProfile(profileId, responses, txn) {
  152. const { Response } = this.server.models()
  153. for (const responseToSave of responses) {
  154. await Response.query(txn)
  155. .update({
  156. response_id: responseToSave.response_id,
  157. profile_id: responseToSave.profile_id,
  158. response_key_id: responseToSave.response_key_id,
  159. val: responseToSave.val,
  160. })
  161. .where({
  162. profile_id: profileId,
  163. })
  164. .where({
  165. response_id: responseToSave.response_id,
  166. })
  167. }
  168. return await Response.query(txn).where({
  169. profile_id: profileId,
  170. })
  171. }
  172. async insertSingleResponseForProfile(responseToSave) {
  173. const { Response } = this.server.models()
  174. const convertedResponse = this._convertResponse(responseToSave)
  175. const savedResponse = await Response.query().insert(convertedResponse)
  176. delete savedResponse.id
  177. return savedResponse
  178. }
  179. /** Add response
  180. * @param {Object} response to save
  181. * @returns {null} updated responses
  182. * @returns {Array} updated responses
  183. */
  184. async saveResponseForProfile(profileId, responseToSave) {
  185. const { Response } = this.server.models()
  186. let allResponses = await Response.query().where({
  187. profile_id: profileId,
  188. })
  189. // Delete matches
  190. // ?:Maybe bad idea
  191. const matchingResponses = allResponses.filter(
  192. response =>
  193. response.response_key_id == responseToSave.response_key_id,
  194. )
  195. if (matchingResponses.length > 0) {
  196. const alreadyAnswered = matchingResponses.map(
  197. matchingRes => matchingRes.response_key_id,
  198. )
  199. await Response.query()
  200. .where({ profile_id: profileId })
  201. .delete()
  202. .whereIn('response_key_id', alreadyAnswered)
  203. }
  204. const convertedResponse = this._convertResponse(responseToSave)
  205. await Response.query().insert(convertedResponse)
  206. return allResponses
  207. }
  208. /**
  209. * Delete a profile
  210. * @param {number} userId
  211. * @param {number} profileId
  212. * @returns
  213. */
  214. async deleteProfile(userId, profileId) {
  215. const { Profile } = this.server.models()
  216. const dedupedGroupings = await this._getProfileIdsForUserId(userId)
  217. /** Do NOTHING if NOT in Grouping */
  218. if (!dedupedGroupings.includes(profileId)) return
  219. return await Profile.query().delete().where('profile_id', profileId)
  220. }
  221. /**
  222. * Score a profile
  223. * @param {number} profileId
  224. * @returns {Array} Ordered and scored Profiles
  225. */
  226. async scoreProfilesFor(
  227. profileId,
  228. maxDistance,
  229. distanceUnit,
  230. duration,
  231. presence,
  232. certifications,
  233. ) {
  234. const { Profile } = this.server.models()
  235. await this._setScoreLookup()
  236. // Our User Profile to score for
  237. const userProfile = await Profile.query()
  238. .findOne('profile_id', profileId)
  239. .withGraphFetched('responses')
  240. .withGraphFetched('user')
  241. const userZip = zipcoder.getZipCodeFromProfile(userProfile)
  242. // preprocess potential match pool with filter service methods
  243. let matchPool = await Profile.query()
  244. .withGraphFetched('responses')
  245. .withGraphFetched('user')
  246. matchPool = filter.byProfileType(matchPool, userProfile.user)
  247. matchPool = filter.byNullZip(matchPool)
  248. // attach distance to pool profiles for max distance filter
  249. matchPool = await this.calcProfileDistances(
  250. matchPool,
  251. distanceUnit,
  252. userZip,
  253. )
  254. matchPool = filter.byDistance(matchPool, maxDistance)
  255. matchPool = filter.byDuration(matchPool, duration)
  256. matchPool = filter.byPresence(matchPool, presence)
  257. // TODO: Incorporate filtering by certifications (see filter.js)
  258. // matchPool = filter.byCertifications(matchPool, certifications)
  259. const scoredProfilesWithDistance = scoring.scoreAll(
  260. matchPool,
  261. userProfile,
  262. this.scoreLookup,
  263. )
  264. // Order by score
  265. return scoredProfilesWithDistance.sort(
  266. (a, b) => b.score.total - a.score.total,
  267. )
  268. }
  269. async calcProfileDistances(matchPool, distanceUnit, userZip) {
  270. const returnVal = await Promise.all(
  271. matchPool.map(async profile => {
  272. const targetZip = zipcoder.getZipCodeFromProfile(profile)
  273. if (!userZip || !targetZip)
  274. return { ...profile, distance: [9999, distanceUnit] }
  275. const distance = await this._compareDistance(
  276. userZip,
  277. targetZip,
  278. distanceUnit,
  279. )
  280. return {
  281. ...profile,
  282. distance: [distance.toFixed(2), distanceUnit],
  283. }
  284. }),
  285. )
  286. return returnVal
  287. }
  288. /**
  289. * Use the db for zipcode info
  290. * @param {string} zipCode
  291. * @param {object}
  292. */
  293. async _latLonForZip(zipCode) {
  294. const { ZipCode } = this.server.models()
  295. const zipInfo = await ZipCode.query().findOne(
  296. 'zip_code_id',
  297. parseInt(zipCode),
  298. )
  299. if (!zipInfo) {
  300. throw new Error(
  301. `ERROR :=> no zipInfo found for zipCode: ${zipCode}`,
  302. )
  303. } else {
  304. return {
  305. latitude: parseFloat(zipInfo.latitude),
  306. longitude: parseFloat(zipInfo.longitude),
  307. }
  308. }
  309. }
  310. /**
  311. * Get the distance between two zipcodes
  312. * using the haversine formula
  313. * @param {string} start_zip
  314. * @param {string} end_zip
  315. * @param {number} distance in miles
  316. */
  317. async _compareDistance(start_zip, end_zip, distanceUnit) {
  318. if (
  319. !start_zip ||
  320. !end_zip ||
  321. Number.isNaN(start_zip) ||
  322. Number.isNaN(end_zip)
  323. )
  324. return
  325. const start = await this._latLonForZip(start_zip)
  326. const end = await this._latLonForZip(end_zip)
  327. return haversine(start, end, { unit: distanceUnit })
  328. }
  329. /**
  330. * Use the db to grab tag associations
  331. * by profile and match them to tag types
  332. * @param {number} profileId
  333. * @param {object}
  334. */
  335. async getTagsFor(profileId, groupingId, category) {
  336. const { TagAssociation } = this.server.models()
  337. await this._setTagLookup()
  338. let associations = []
  339. if (!profileId.length || !groupingId.length) {
  340. return associations
  341. } else {
  342. associations = groupingId
  343. ? await TagAssociation.query()
  344. .where('grouping_id', groupingId)
  345. .andWhere('profile_id', profileId)
  346. : await TagAssociation.query().andWhere('profile_id', profileId)
  347. return associations
  348. .map(assoc => ({
  349. ...assoc,
  350. tag: this.tagLookup[assoc.tag_id],
  351. }))
  352. .filter(tagWithAssoc => {
  353. return category
  354. ? tagWithAssoc.tag.tag_category == category
  355. : true
  356. })
  357. }
  358. }
  359. /**
  360. * Use the db to grab tag associations
  361. * by profile, grouping, tag, and insert
  362. * it if it already exists
  363. * @param {object} association
  364. */
  365. async revealProfileInfo(association) {
  366. const { TagAssociation } = this.server.models()
  367. const existingAssociations = await TagAssociation.query()
  368. .where('profile_id', `${association.profile_id}`)
  369. .where('grouping_id', `${association.grouping_id}`)
  370. .where('tag_id', `${association.tag_id}`)
  371. .where('is_deleted', 0)
  372. if (!existingAssociations.length) {
  373. await TagAssociation.query().insert(association)
  374. return await this.getTagsFor(association.profile_id)
  375. } else {
  376. return console.error('ERROR =>: tag association already exists')
  377. }
  378. }
  379. }