InstitutionsManager.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. const async = require('async')
  2. const { callbackify, promisify } = require('util')
  3. const { ObjectId } = require('mongodb')
  4. const Settings = require('@overleaf/settings')
  5. const logger = require('@overleaf/logger')
  6. const { fetchJson } = require('@overleaf/fetch-utils')
  7. const {
  8. getInstitutionAffiliations,
  9. getConfirmedInstitutionAffiliations,
  10. addAffiliation,
  11. promises: InstitutionsAPIPromises,
  12. } = require('./InstitutionsAPI')
  13. const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
  14. const FeaturesHelper = require('../Subscription/FeaturesHelper')
  15. const UserGetter = require('../User/UserGetter')
  16. const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
  17. const NotificationsHandler = require('../Notifications/NotificationsHandler')
  18. const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
  19. const { Institution } = require('../../models/Institution')
  20. const { Subscription } = require('../../models/Subscription')
  21. const Queues = require('../../infrastructure/Queues')
  22. const OError = require('@overleaf/o-error')
  23. const ASYNC_LIMIT = parseInt(process.env.ASYNC_LIMIT, 10) || 5
  24. async function _getSsoUsers(institutionId, lapsedUserIds) {
  25. let currentNotEntitledCount = 0
  26. const ssoNonEntitledUsersIds = []
  27. const allSsoUsersByIds = {}
  28. const allSsoUsers = await UserGetter.promises.getSsoUsersAtInstitution(
  29. institutionId,
  30. { samlIdentifiers: 1 }
  31. )
  32. allSsoUsers.forEach(user => {
  33. allSsoUsersByIds[user._id] = user.samlIdentifiers.find(
  34. identifer => identifer.providerId === institutionId.toString()
  35. )
  36. })
  37. for (const userId in allSsoUsersByIds) {
  38. if (!allSsoUsersByIds[userId].hasEntitlement) {
  39. ssoNonEntitledUsersIds.push(userId)
  40. }
  41. }
  42. if (ssoNonEntitledUsersIds.length > 0) {
  43. currentNotEntitledCount = ssoNonEntitledUsersIds.filter(
  44. id => !lapsedUserIds.includes(id)
  45. ).length
  46. }
  47. return {
  48. allSsoUsers,
  49. allSsoUsersByIds,
  50. currentNotEntitledCount,
  51. }
  52. }
  53. async function _checkUsersFeatures(userIds) {
  54. const users = await UserGetter.promises.getUsers(userIds, { features: 1 })
  55. const result = {
  56. proUserIds: [],
  57. nonProUserIds: [],
  58. }
  59. users.forEach(user => {
  60. const hasProFeaturesOrBetter = FeaturesHelper.isFeatureSetBetter(
  61. user.features,
  62. Settings.features.professional
  63. )
  64. if (hasProFeaturesOrBetter) {
  65. result.proUserIds.push(user._id)
  66. } else {
  67. result.nonProUserIds.push(user._id)
  68. }
  69. })
  70. return result
  71. }
  72. async function checkInstitutionUsers(institutionId, emitNonProUserIds) {
  73. /*
  74. v1 has affiliation data. Via getInstitutionAffiliationsCounts, v1 will send
  75. lapsed_user_ids, which includes all user types
  76. (not linked, linked and entitled, linked not entitled).
  77. However, for SSO institutions, it does not know which email is linked
  78. to SSO when the license is non-trivial. Here we need to split that
  79. lapsed count into SSO (entitled and not) or just email users
  80. */
  81. const result = {
  82. emailUsers: {
  83. total: 0, // v1 all users - v2 all SSO users
  84. current: 0, // v1 current - v1 SSO entitled - (v2 calculated not entitled current)
  85. lapsed: 0, // v1 lapsed user IDs that are not in v2 SSO users
  86. pro: {
  87. current: 0,
  88. lapsed: 0,
  89. },
  90. nonPro: {
  91. current: 0,
  92. lapsed: 0,
  93. },
  94. },
  95. ssoUsers: {
  96. total: 0, // only v2
  97. current: {
  98. entitled: 0, // only v1
  99. notEntitled: 0, // v2 non-entitled SSO users - v1 lapsed user IDs
  100. },
  101. lapsed: 0, // v2 SSO users that are in v1 lapsed user IDs
  102. pro: {
  103. current: 0,
  104. lapsed: 0,
  105. },
  106. nonPro: {
  107. current: 0,
  108. lapsed: 0,
  109. },
  110. },
  111. }
  112. const {
  113. user_ids: userIds, // confirmed and not removed users. Includes users with lapsed reconfirmations
  114. current_users_count: currentUsersCount, // all users not with lapsed reconfirmations
  115. lapsed_user_ids: lapsedUserIds, // includes all user types that did not reconfirm (sso entitled, sso not entitled, email only)
  116. with_confirmed_email: withConfirmedEmail, // same count as affiliation metrics
  117. entitled_via_sso: entitled, // same count as affiliation metrics
  118. } = await InstitutionsAPIPromises.getInstitutionAffiliationsCounts(
  119. institutionId
  120. )
  121. result.ssoUsers.current.entitled = entitled
  122. const { allSsoUsers, allSsoUsersByIds, currentNotEntitledCount } =
  123. await _getSsoUsers(institutionId, lapsedUserIds)
  124. result.ssoUsers.total = allSsoUsers.length
  125. result.ssoUsers.current.notEntitled = currentNotEntitledCount
  126. // check if lapsed user ID an SSO user
  127. const lapsedUsersByIds = {}
  128. lapsedUserIds.forEach(id => {
  129. lapsedUsersByIds[id] = true // create a map for more performant lookups
  130. if (allSsoUsersByIds[id]) {
  131. ++result.ssoUsers.lapsed
  132. } else {
  133. ++result.emailUsers.lapsed
  134. }
  135. })
  136. result.emailUsers.current =
  137. currentUsersCount - entitled - result.ssoUsers.current.notEntitled
  138. result.emailUsers.total = userIds.length - allSsoUsers.length
  139. // compare v1 and v2 counts.
  140. if (
  141. result.ssoUsers.current.notEntitled + result.emailUsers.current !==
  142. withConfirmedEmail
  143. ) {
  144. result.databaseMismatch = {
  145. withConfirmedEmail: {
  146. v1: withConfirmedEmail,
  147. v2: result.ssoUsers.current.notEntitled + result.emailUsers.current,
  148. },
  149. }
  150. }
  151. // Add Pro/NonPro status for users
  152. // NOTE: Users not entitled via institution could have Pro via another method
  153. const { proUserIds, nonProUserIds } = await _checkUsersFeatures(userIds)
  154. proUserIds.forEach(id => {
  155. const userType = lapsedUsersByIds[id] ? 'lapsed' : 'current'
  156. if (allSsoUsersByIds[id]) {
  157. result.ssoUsers.pro[userType]++
  158. } else {
  159. result.emailUsers.pro[userType]++
  160. }
  161. })
  162. nonProUserIds.forEach(id => {
  163. const userType = lapsedUsersByIds[id] ? 'lapsed' : 'current'
  164. if (allSsoUsersByIds[id]) {
  165. result.ssoUsers.nonPro[userType]++
  166. } else {
  167. result.emailUsers.nonPro[userType]++
  168. }
  169. })
  170. if (emitNonProUserIds) {
  171. result.nonProUserIds = nonProUserIds
  172. }
  173. return result
  174. }
  175. const InstitutionsManager = {
  176. clearInstitutionNotifications(institutionId, dryRun, callback) {
  177. function clear(key, cb) {
  178. const run = dryRun
  179. ? NotificationsHandler.previewMarkAsReadByKeyOnlyBulk
  180. : NotificationsHandler.markAsReadByKeyOnlyBulk
  181. run(key, cb)
  182. }
  183. async.series(
  184. {
  185. ipMatcherAffiliation(cb) {
  186. const key = `ip-matched-affiliation-${institutionId}`
  187. clear(key, cb)
  188. },
  189. featuresUpgradedByAffiliation(cb) {
  190. const key = `features-updated-by=${institutionId}`
  191. clear(key, cb)
  192. },
  193. redundantPersonalSubscription(cb) {
  194. const key = `redundant-personal-subscription-${institutionId}`
  195. clear(key, cb)
  196. },
  197. },
  198. callback
  199. )
  200. },
  201. refreshInstitutionUsers(institutionId, notify, callback) {
  202. const refreshFunction = notify ? refreshFeaturesAndNotify : refreshFeatures
  203. async.waterfall(
  204. [
  205. cb => fetchInstitutionAndAffiliations(institutionId, cb),
  206. function (institution, affiliations, cb) {
  207. for (const affiliation of affiliations) {
  208. affiliation.institutionName = institution.name
  209. affiliation.institutionId = institutionId
  210. }
  211. async.eachLimit(affiliations, ASYNC_LIMIT, refreshFunction, err =>
  212. cb(err)
  213. )
  214. },
  215. ],
  216. callback
  217. )
  218. },
  219. checkInstitutionUsers: callbackify(checkInstitutionUsers),
  220. getInstitutionUsersSubscriptions(institutionId, callback) {
  221. getInstitutionAffiliations(institutionId, function (error, affiliations) {
  222. if (error) {
  223. return callback(error)
  224. }
  225. const userIds = affiliations.map(
  226. affiliation => new ObjectId(affiliation.user_id)
  227. )
  228. Subscription.find({ admin_id: userIds })
  229. .populate('admin_id', 'email')
  230. .exec(callback)
  231. })
  232. },
  233. affiliateUsers(hostname, callback) {
  234. const reversedHostname = hostname.trim().split('').reverse().join('')
  235. UserGetter.getInstitutionUsersByHostname(hostname, (error, users) => {
  236. if (error) {
  237. OError.tag(error, 'problem fetching users by hostname')
  238. return callback(error)
  239. }
  240. async.mapLimit(
  241. users,
  242. ASYNC_LIMIT,
  243. (user, innerCallback) => {
  244. affiliateUserByReversedHostname(user, reversedHostname, innerCallback)
  245. },
  246. callback
  247. )
  248. })
  249. },
  250. confirmDomain: callbackify(confirmDomain),
  251. }
  252. const fetchInstitutionAndAffiliations = (institutionId, callback) =>
  253. async.waterfall(
  254. [
  255. cb =>
  256. Institution.findOne({ v1Id: institutionId }, (err, institution) =>
  257. cb(err, institution)
  258. ),
  259. (institution, cb) =>
  260. institution.fetchV1Data((err, institution) => cb(err, institution)),
  261. (institution, cb) =>
  262. getConfirmedInstitutionAffiliations(
  263. institutionId,
  264. (err, affiliations) => cb(err, institution, affiliations)
  265. ),
  266. ],
  267. callback
  268. )
  269. function refreshFeatures(affiliation, callback) {
  270. const userId = new ObjectId(affiliation.user_id)
  271. FeaturesUpdater.refreshFeatures(userId, 'refresh-institution-users', callback)
  272. }
  273. function refreshFeaturesAndNotify(affiliation, callback) {
  274. const userId = new ObjectId(affiliation.user_id)
  275. async.waterfall(
  276. [
  277. cb =>
  278. FeaturesUpdater.refreshFeatures(
  279. userId,
  280. 'refresh-institution-users',
  281. (err, features, featuresChanged) => cb(err, featuresChanged)
  282. ),
  283. (featuresChanged, cb) =>
  284. getUserInfo(userId, (error, user, subscription) =>
  285. cb(error, user, subscription, featuresChanged)
  286. ),
  287. (user, subscription, featuresChanged, cb) =>
  288. notifyUser(user, affiliation, subscription, featuresChanged, cb),
  289. ],
  290. callback
  291. )
  292. }
  293. const getUserInfo = (userId, callback) =>
  294. async.waterfall(
  295. [
  296. cb => UserGetter.getUser(userId, { _id: 1 }, cb),
  297. (user, cb) =>
  298. SubscriptionLocator.getUsersSubscription(user, (err, subscription) =>
  299. cb(err, user, subscription)
  300. ),
  301. ],
  302. callback
  303. )
  304. const notifyUser = (
  305. user,
  306. affiliation,
  307. subscription,
  308. featuresChanged,
  309. callback
  310. ) =>
  311. async.parallel(
  312. [
  313. function (cb) {
  314. if (featuresChanged) {
  315. NotificationsBuilder.featuresUpgradedByAffiliation(
  316. affiliation,
  317. user
  318. ).create(cb)
  319. } else {
  320. cb()
  321. }
  322. },
  323. function (cb) {
  324. if (subscription && !subscription.groupPlan) {
  325. NotificationsBuilder.redundantPersonalSubscription(
  326. affiliation,
  327. user
  328. ).create(cb)
  329. } else {
  330. cb()
  331. }
  332. },
  333. ],
  334. callback
  335. )
  336. async function fetchV1Data(institution) {
  337. const url = `${Settings.apis.v1.url}/universities/list/${institution.v1Id}`
  338. try {
  339. const data = await fetchJson(url, {
  340. signal: AbortSignal.timeout(Settings.apis.v1.timeout),
  341. })
  342. institution.name = data?.name
  343. institution.countryCode = data?.country_code
  344. institution.departments = data?.departments
  345. institution.portalSlug = data?.portal_slug
  346. } catch (error) {
  347. logger.err(
  348. { model: 'Institution', v1Id: institution.v1Id, error },
  349. '[fetchV1DataError]'
  350. )
  351. }
  352. }
  353. /**
  354. * Enqueue a job for adding affiliations for when a domain is confirmed
  355. */
  356. async function confirmDomain(hostname) {
  357. const queue = Queues.getQueue('confirm-institution-domain')
  358. await queue.add({ hostname })
  359. }
  360. function affiliateUserByReversedHostname(user, reversedHostname, callback) {
  361. const matchingEmails = user.emails.filter(
  362. email => email.reversedHostname === reversedHostname
  363. )
  364. async.mapSeries(
  365. matchingEmails,
  366. (email, innerCallback) => {
  367. addAffiliation(
  368. user._id,
  369. email.email,
  370. {
  371. confirmedAt: email.confirmedAt,
  372. entitlement:
  373. email.samlIdentifier && email.samlIdentifier.hasEntitlement,
  374. },
  375. error => {
  376. if (error) {
  377. OError.tag(
  378. error,
  379. 'problem adding affiliation while confirming hostname'
  380. )
  381. return innerCallback(error)
  382. }
  383. innerCallback()
  384. }
  385. )
  386. },
  387. err => {
  388. if (err) {
  389. return callback(err)
  390. }
  391. FeaturesUpdater.refreshFeatures(
  392. user._id,
  393. 'affiliate-user-by-reversed-hostname',
  394. callback
  395. )
  396. }
  397. )
  398. }
  399. InstitutionsManager.promises = {
  400. affiliateUsers: promisify(InstitutionsManager.affiliateUsers),
  401. confirmDomain,
  402. checkInstitutionUsers,
  403. clearInstitutionNotifications: promisify(
  404. InstitutionsManager.clearInstitutionNotifications
  405. ),
  406. fetchV1Data,
  407. }
  408. module.exports = InstitutionsManager