Files
overleaf-cep/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs
T
Jimmy Domagala-Tang 892047fcf6 Revert "[Web] Enable Quota System for AI Features (#31544)" (#31767)
This reverts commit 17763447965aae5777053b783d2601517bfe6b12.

GitOrigin-RevId: f6589bdbf0ac7e71313739e3e3f4fb5bedd48c22
2026-02-24 09:07:06 +00:00

284 lines
8.5 KiB
JavaScript

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,
},
}