InstitutionsManager.mjs 11 KB

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