|
|
@@ -1,18 +1,198 @@
|
|
|
const async = require('async')
|
|
|
const _ = require('underscore')
|
|
|
+const { callbackify } = require('util')
|
|
|
const { ObjectId } = require('mongodb')
|
|
|
-const { getInstitutionAffiliations } = require('./InstitutionsAPI')
|
|
|
+const Settings = require('@overleaf/settings')
|
|
|
+const {
|
|
|
+ getInstitutionAffiliations,
|
|
|
+ promises: InstitutionsAPIPromises,
|
|
|
+} = require('./InstitutionsAPI')
|
|
|
const FeaturesUpdater = require('../Subscription/FeaturesUpdater')
|
|
|
const UserGetter = require('../User/UserGetter')
|
|
|
-const SAMLIdentityManager = require('../User/SAMLIdentityManager')
|
|
|
const NotificationsBuilder = require('../Notifications/NotificationsBuilder')
|
|
|
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
|
|
|
const { Institution } = require('../../models/Institution')
|
|
|
const { Subscription } = require('../../models/Subscription')
|
|
|
-const OError = require('@overleaf/o-error')
|
|
|
|
|
|
const ASYNC_LIMIT = parseInt(process.env.ASYNC_LIMIT, 10) || 5
|
|
|
-module.exports = {
|
|
|
+
|
|
|
+async function _getSsoUsers(institutionId, lapsedUserIds) {
|
|
|
+ let currentNotEntitledCount = 0
|
|
|
+ const ssoNonEntitledUsersIds = []
|
|
|
+ const allSsoUsersByIds = {}
|
|
|
+
|
|
|
+ const allSsoUsers = await UserGetter.promises.getSsoUsersAtInstitution(
|
|
|
+ institutionId,
|
|
|
+ { samlIdentifiers: 1 }
|
|
|
+ )
|
|
|
+ allSsoUsers.forEach(user => {
|
|
|
+ allSsoUsersByIds[user._id] = user.samlIdentifiers.find(
|
|
|
+ identifer => identifer.providerId === institutionId.toString()
|
|
|
+ )
|
|
|
+ })
|
|
|
+ for (const userId in allSsoUsersByIds) {
|
|
|
+ if (!allSsoUsersByIds[userId].hasEntitlement) {
|
|
|
+ ssoNonEntitledUsersIds.push(userId)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (ssoNonEntitledUsersIds.length > 0) {
|
|
|
+ currentNotEntitledCount = ssoNonEntitledUsersIds.filter(
|
|
|
+ id => !lapsedUserIds.includes(id)
|
|
|
+ ).length
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ allSsoUsers,
|
|
|
+ allSsoUsersByIds,
|
|
|
+ currentNotEntitledCount,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function _checkUsersFeatures(userIds) {
|
|
|
+ const users = await UserGetter.promises.getUsers(userIds, { features: 1 })
|
|
|
+ const result = {
|
|
|
+ proUserIds: [],
|
|
|
+ nonProUserIds: [],
|
|
|
+ }
|
|
|
+
|
|
|
+ await new Promise((resolve, reject) => {
|
|
|
+ async.eachLimit(
|
|
|
+ users,
|
|
|
+ ASYNC_LIMIT,
|
|
|
+ (user, callback) => {
|
|
|
+ const hasProFeaturesOrBetter = FeaturesUpdater.isFeatureSetBetter(
|
|
|
+ user.features,
|
|
|
+ Settings.features.professional
|
|
|
+ )
|
|
|
+
|
|
|
+ if (hasProFeaturesOrBetter) {
|
|
|
+ result.proUserIds.push(user._id)
|
|
|
+ } else {
|
|
|
+ result.nonProUserIds.push(user._id)
|
|
|
+ }
|
|
|
+ callback()
|
|
|
+ },
|
|
|
+ error => {
|
|
|
+ if (error) return reject(error)
|
|
|
+ resolve()
|
|
|
+ }
|
|
|
+ )
|
|
|
+ })
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+async function checkInstitutionUsers(institutionId) {
|
|
|
+ /*
|
|
|
+ v1 has affiliation data. Via getInstitutionAffiliationsCounts, v1 will send
|
|
|
+ lapsed_user_ids, which includes all user types
|
|
|
+ (not linked, linked and entitled, linked not entitled).
|
|
|
+ However, for SSO institutions, it does not know which email is linked
|
|
|
+ to SSO when the license is non-trivial. Here we need to split that
|
|
|
+ lapsed count into SSO (entitled and not) or just email users
|
|
|
+ */
|
|
|
+
|
|
|
+ const result = {
|
|
|
+ emailUsers: {
|
|
|
+ total: 0, // v1 all users - v2 all SSO users
|
|
|
+ current: 0, // v1 current - v1 SSO entitled - (v2 calculated not entitled current)
|
|
|
+ lapsed: 0, // v1 lapsed user IDs that are not in v2 SSO users
|
|
|
+ pro: {
|
|
|
+ current: 0,
|
|
|
+ lapsed: 0,
|
|
|
+ },
|
|
|
+ nonPro: {
|
|
|
+ current: 0,
|
|
|
+ lapsed: 0,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ssoUsers: {
|
|
|
+ total: 0, // only v2
|
|
|
+ current: {
|
|
|
+ entitled: 0, // only v1
|
|
|
+ notEntitled: 0, // v2 non-entitled SSO users - v1 lapsed user IDs
|
|
|
+ },
|
|
|
+ lapsed: 0, // v2 SSO users that are in v1 lapsed user IDs
|
|
|
+ pro: {
|
|
|
+ current: 0,
|
|
|
+ lapsed: 0,
|
|
|
+ },
|
|
|
+ nonPro: {
|
|
|
+ current: 0,
|
|
|
+ lapsed: 0,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+
|
|
|
+ const {
|
|
|
+ user_ids: userIds, // confirmed and not removed users. Includes users with lapsed reconfirmations
|
|
|
+ current_users_count: currentUsersCount, // all users not with lapsed reconfirmations
|
|
|
+ lapsed_user_ids: lapsedUserIds, // includes all user types that did not reconfirm (sso entitled, sso not entitled, email only)
|
|
|
+ with_confirmed_email: withConfirmedEmail, // same count as affiliation metrics
|
|
|
+ entitled_via_sso: entitled, // same count as affiliation metrics
|
|
|
+ } = await InstitutionsAPIPromises.getInstitutionAffiliationsCounts(
|
|
|
+ institutionId
|
|
|
+ )
|
|
|
+ result.ssoUsers.current.entitled = entitled
|
|
|
+
|
|
|
+ const {
|
|
|
+ allSsoUsers,
|
|
|
+ allSsoUsersByIds,
|
|
|
+ currentNotEntitledCount,
|
|
|
+ } = await _getSsoUsers(institutionId, lapsedUserIds)
|
|
|
+ result.ssoUsers.total = allSsoUsers.length
|
|
|
+ result.ssoUsers.current.notEntitled = currentNotEntitledCount
|
|
|
+
|
|
|
+ // check if lapsed user ID an SSO user
|
|
|
+ const lapsedUsersByIds = {}
|
|
|
+ lapsedUserIds.forEach(id => {
|
|
|
+ lapsedUsersByIds[id] = true // create a map for more performant lookups
|
|
|
+ if (allSsoUsersByIds[id]) {
|
|
|
+ ++result.ssoUsers.lapsed
|
|
|
+ } else {
|
|
|
+ ++result.emailUsers.lapsed
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ result.emailUsers.current =
|
|
|
+ currentUsersCount - entitled - result.ssoUsers.current.notEntitled
|
|
|
+ result.emailUsers.total = userIds.length - allSsoUsers.length
|
|
|
+
|
|
|
+ // compare v1 and v2 counts.
|
|
|
+ if (
|
|
|
+ result.ssoUsers.current.notEntitled + result.emailUsers.current !==
|
|
|
+ withConfirmedEmail
|
|
|
+ ) {
|
|
|
+ result.databaseMismatch = {
|
|
|
+ withConfirmedEmail: {
|
|
|
+ v1: withConfirmedEmail,
|
|
|
+ v2: result.ssoUsers.current.notEntitled + result.emailUsers.current,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Add Pro/NonPro status for users
|
|
|
+ // NOTE: Users not entitled via institution could have Pro via another method
|
|
|
+ const { proUserIds, nonProUserIds } = await _checkUsersFeatures(userIds)
|
|
|
+ proUserIds.forEach(id => {
|
|
|
+ const userType = lapsedUsersByIds[id] ? 'lapsed' : 'current'
|
|
|
+ if (allSsoUsersByIds[id]) {
|
|
|
+ result.ssoUsers.pro[userType]++
|
|
|
+ } else {
|
|
|
+ result.emailUsers.pro[userType]++
|
|
|
+ }
|
|
|
+ })
|
|
|
+ nonProUserIds.forEach(id => {
|
|
|
+ const userType = lapsedUsersByIds[id] ? 'lapsed' : 'current'
|
|
|
+ if (allSsoUsersByIds[id]) {
|
|
|
+ result.ssoUsers.nonPro[userType]++
|
|
|
+ } else {
|
|
|
+ result.emailUsers.nonPro[userType]++
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+const InstitutionsManager = {
|
|
|
refreshInstitutionUsers(institutionId, notify, callback) {
|
|
|
const refreshFunction = notify ? refreshFeaturesAndNotify : refreshFeatures
|
|
|
async.waterfall(
|
|
|
@@ -33,21 +213,7 @@ module.exports = {
|
|
|
)
|
|
|
},
|
|
|
|
|
|
- checkInstitutionUsers(institutionId, callback) {
|
|
|
- getInstitutionAffiliations(institutionId, (error, affiliations) => {
|
|
|
- if (error) {
|
|
|
- return callback(error)
|
|
|
- }
|
|
|
- UserGetter.getUsersByAnyConfirmedEmail(
|
|
|
- affiliations.map(affiliation => affiliation.email),
|
|
|
- { features: 1, samlIdentifiers: 1 },
|
|
|
- (error, users) => {
|
|
|
- if (error) return callback(OError.tag(error))
|
|
|
- callback(error, checkFeatures(institutionId, users))
|
|
|
- }
|
|
|
- )
|
|
|
- })
|
|
|
- },
|
|
|
+ checkInstitutionUsers: callbackify(checkInstitutionUsers),
|
|
|
|
|
|
getInstitutionUsersSubscriptions(institutionId, callback) {
|
|
|
getInstitutionAffiliations(institutionId, function (error, affiliations) {
|
|
|
@@ -153,46 +319,8 @@ var notifyUser = (user, affiliation, subscription, featuresChanged, callback) =>
|
|
|
callback
|
|
|
)
|
|
|
|
|
|
-var checkFeatures = function (institutionId, users) {
|
|
|
- const usersSummary = {
|
|
|
- confirmedEmailUsers: {
|
|
|
- total: users.length, // all users are confirmed email users
|
|
|
- totalProUsers: 0,
|
|
|
- totalNonProUsers: 0,
|
|
|
- nonProUsers: [],
|
|
|
- },
|
|
|
- entitledSSOUsers: {
|
|
|
- total: 0,
|
|
|
- totalProUsers: 0,
|
|
|
- totalNonProUsers: 0,
|
|
|
- nonProUsers: [],
|
|
|
- },
|
|
|
- }
|
|
|
- users.forEach(function (user) {
|
|
|
- const isSSOEntitled = SAMLIdentityManager.userHasEntitlement(
|
|
|
- user,
|
|
|
- institutionId
|
|
|
- )
|
|
|
-
|
|
|
- if (isSSOEntitled) {
|
|
|
- usersSummary.entitledSSOUsers.total += 1
|
|
|
- }
|
|
|
-
|
|
|
- if (user.features.collaborators === -1 && user.features.trackChanges) {
|
|
|
- // user is on Pro
|
|
|
- usersSummary.confirmedEmailUsers.totalProUsers += 1
|
|
|
- if (isSSOEntitled) {
|
|
|
- usersSummary.entitledSSOUsers.totalProUsers += 1
|
|
|
- }
|
|
|
- } else {
|
|
|
- // user is not on Pro
|
|
|
- usersSummary.confirmedEmailUsers.totalNonProUsers += 1
|
|
|
- usersSummary.confirmedEmailUsers.nonProUsers.push(user._id)
|
|
|
- if (isSSOEntitled) {
|
|
|
- usersSummary.entitledSSOUsers.totalNonProUsers += 1
|
|
|
- usersSummary.entitledSSOUsers.nonProUsers.push(user._id)
|
|
|
- }
|
|
|
- }
|
|
|
- })
|
|
|
- return usersSummary
|
|
|
+InstitutionsManager.promises = {
|
|
|
+ checkInstitutionUsers,
|
|
|
}
|
|
|
+
|
|
|
+module.exports = InstitutionsManager
|