| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- import _ from 'lodash'
- import { callbackify } from 'node:util'
- import { callbackifyMultiResult } from '@overleaf/promise-utils'
- import PlansLocator from './PlansLocator.mjs'
- import SubscriptionLocator from './SubscriptionLocator.mjs'
- import SubscriptionHelper from './SubscriptionHelper.mjs'
- import UserFeaturesUpdater from './UserFeaturesUpdater.mjs'
- import FeaturesHelper from './FeaturesHelper.mjs'
- import Settings from '@overleaf/settings'
- import logger from '@overleaf/logger'
- import ReferalFeatures from '../Referal/ReferalFeatures.mjs'
- import V1SubscriptionManager from './V1SubscriptionManager.mjs'
- import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.mjs'
- import UserGetter from '../User/UserGetter.mjs'
- import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
- import Queues from '../../infrastructure/Queues.mjs'
- import Modules from '../../infrastructure/Modules.mjs'
- import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.mjs'
- import { AI_ADD_ON_CODE } from './AiHelper.mjs'
- import { fetchNothing } from '@overleaf/fetch-utils'
- /**
- * Enqueue a job for refreshing features for the given user
- */
- async function scheduleRefreshFeatures(userId, reason) {
- const queue = Queues.getQueue('refresh-features')
- await queue.add({ userId, reason })
- }
- /* Check if user features refresh if needed, based on the global featuresEpoch setting */
- function featuresEpochIsCurrent(user) {
- return Settings.featuresEpoch
- ? user.featuresEpoch === Settings.featuresEpoch
- : true
- }
- /**
- * Refresh features for the given user
- */
- async function refreshFeatures(userId, reason) {
- const user = await UserGetter.promises.getUser(userId, {
- _id: 1,
- features: 1,
- })
- const oldFeatures = _.clone(user.features)
- const features = await computeFeatures(userId)
- logger.debug({ userId, features, reason }, 'updating user features')
- const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(features)
- AnalyticsManager.setUserPropertyForUserInBackground(
- userId,
- 'feature-set',
- matchedFeatureSet
- )
- const { features: newFeatures, featuresChanged } =
- await UserFeaturesUpdater.promises.updateFeatures(userId, features)
- // TODO: this call is quite expensive, so ideally we'd update cio with something
- // that doesn't require the best subscription to be computed, ie. the plan code (or type)
- const bestSubscriptionType = await _getBestSubscriptionType(userId)
- Modules.promises.hooks
- .fire('setUserProperties', userId, {
- features,
- 'best-subscription-type': bestSubscriptionType,
- })
- .catch(err => {
- logger.error({ err, userId }, 'Failed to sync features to customer.io')
- })
- if (oldFeatures.dropbox === true && features.dropbox === false) {
- logger.debug({ userId }, '[FeaturesUpdater] must unlink dropbox')
- try {
- await Modules.promises.hooks.fire('removeDropbox', userId, reason)
- } catch (err) {
- logger.error({ err, userId }, 'removeDropbox hook failed')
- }
- }
- if (oldFeatures.github === true && features.github === false) {
- logger.debug({ userId }, '[FeaturesUpdater] must unlink github')
- try {
- await Modules.promises.hooks.fire('removeGithub', userId, reason)
- } catch (err) {
- logger.error({ err, userId }, 'removeGithub hook failed')
- }
- }
- // only update Writefull if the user's features have changed,
- // skip if they are the reason we are refreshing features (they'd already be up to date)
- if (featuresChanged && reason !== 'writefullEntitlementSynced') {
- try {
- // update WF with the current feature set for the user
- await fetchNothing(
- `${Settings.writefull.overleafApiUrl}/api/user/status/update-overleaf-status`,
- {
- headers: {
- 'x-api-key': Settings.writefull.overleafApiKey,
- },
- json: {
- userOverleafId: userId,
- hasAiAssist: newFeatures.aiErrorAssistant,
- },
- method: 'POST',
- }
- )
- } catch (err) {
- // continue with sync even if we cant communicate with wf
- logger.warn(
- { userId, reason },
- 'failed to sync entitlement to Writefull after a feature refresh'
- )
- }
- }
- return { features: newFeatures, featuresChanged }
- }
- async function _getBestSubscriptionType(userId) {
- try {
- const { bestSubscription } =
- await SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails({
- _id: userId,
- })
- return bestSubscription?.type || 'free'
- } catch (err) {
- logger.warn(
- { err, userId },
- 'Failed to calculate best-subscription-type for customer.io'
- )
- return 'free'
- }
- }
- /**
- * Return the features that the given user should have.
- */
- async function computeFeatures(userId) {
- const individualFeatures = await _getIndividualFeatures(userId)
- const groupFeatureSets = await _getGroupFeatureSets(userId)
- const institutionFeatures =
- await InstitutionsFeatures.promises.getInstitutionsFeatures(userId)
- const user = await UserGetter.promises.getUser(userId, {
- featuresOverrides: 1,
- 'overleaf.id': 1,
- })
- const v1Features = await _getV1Features(user)
- const bonusFeatures = await ReferalFeatures.promises.getBonusFeatures(userId)
- const featuresOverrides = await _getFeaturesOverrides(user)
- logger.debug(
- {
- userId,
- individualFeatures,
- groupFeatureSets,
- institutionFeatures,
- v1Features,
- bonusFeatures,
- featuresOverrides,
- },
- 'merging user features'
- )
- const featureSets = groupFeatureSets.concat([
- individualFeatures,
- institutionFeatures,
- v1Features,
- bonusFeatures,
- featuresOverrides,
- ])
- return _.reduce(
- featureSets,
- FeaturesHelper.mergeFeatures,
- Settings.defaultFeatures
- )
- }
- async function _getIndividualFeatures(userId) {
- const subscription =
- await SubscriptionLocator.promises.getUsersSubscription(userId)
- if (
- subscription == null ||
- SubscriptionHelper.getPaidSubscriptionState(subscription) === 'paused' ||
- subscription.userFeaturesDisabled
- ) {
- return {}
- }
- const featureSets = []
- // The plan doesn't apply to the group admin when the subscription
- // is a group subscription
- if (!subscription.groupPlan) {
- featureSets.push(_subscriptionToFeatures(subscription))
- }
- featureSets.push(_aiAddOnFeatures(subscription))
- return _.reduce(featureSets, FeaturesHelper.mergeFeatures, {})
- }
- async function _getGroupFeatureSets(userId) {
- const subs =
- await SubscriptionLocator.promises.getGroupSubscriptionsMemberOf(userId)
- return (subs || [])
- .filter(sub => sub.userFeaturesDisabled !== true)
- .map(_subscriptionToFeatures)
- }
- async function _getFeaturesOverrides(user) {
- if (!user || !user.featuresOverrides || user.featuresOverrides.length === 0) {
- return {}
- }
- const activeFeaturesOverrides = []
- for (const featuresOverride of user.featuresOverrides) {
- if (
- !featuresOverride.expiresAt ||
- featuresOverride.expiresAt > new Date()
- ) {
- activeFeaturesOverrides.push(featuresOverride.features)
- }
- }
- return _.reduce(activeFeaturesOverrides, FeaturesHelper.mergeFeatures, {})
- }
- async function _getV1Features(user) {
- const v1Id = user?.overleaf?.id
- return V1SubscriptionManager.getGrandfatheredFeaturesForV1User(v1Id) || {}
- }
- function _subscriptionToFeatures(subscription) {
- if (!subscription?.planCode) {
- return {}
- }
- const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
- if (!plan) {
- return {}
- } else {
- return plan.features
- }
- }
- function _aiAddOnFeatures(subscription) {
- if (subscription?.addOns?.some(addOn => addOn.addOnCode === AI_ADD_ON_CODE)) {
- return { aiErrorAssistant: true }
- } else {
- return {}
- }
- }
- async function doSyncFromV1(v1UserId) {
- logger.debug({ v1UserId }, '[AccountSync] starting account sync')
- const user = await UserGetter.promises.getUser(
- { 'overleaf.id': v1UserId },
- { _id: 1 }
- )
- if (user == null) {
- logger.warn({ v1UserId }, '[AccountSync] no user found for v1 id')
- return
- }
- logger.debug(
- { v1UserId, userId: user._id },
- '[AccountSync] updating user subscription and features'
- )
- return refreshFeatures(user._id, 'sync-v1')
- }
- export default {
- featuresEpochIsCurrent,
- computeFeatures: callbackify(computeFeatures),
- refreshFeatures: callbackifyMultiResult(refreshFeatures, [
- 'features',
- 'featuresChanged',
- ]),
- doSyncFromV1: callbackifyMultiResult(doSyncFromV1, [
- 'features',
- 'featuresChanged',
- ]),
- scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures),
- promises: {
- computeFeatures,
- refreshFeatures,
- scheduleRefreshFeatures,
- doSyncFromV1,
- },
- }
|