FeaturesUpdater.mjs 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import _ from 'lodash'
  2. import { callbackify } from 'node:util'
  3. import { callbackifyMultiResult } from '@overleaf/promise-utils'
  4. import PlansLocator from './PlansLocator.mjs'
  5. import SubscriptionLocator from './SubscriptionLocator.mjs'
  6. import SubscriptionHelper from './SubscriptionHelper.mjs'
  7. import UserFeaturesUpdater from './UserFeaturesUpdater.mjs'
  8. import FeaturesHelper from './FeaturesHelper.mjs'
  9. import Settings from '@overleaf/settings'
  10. import logger from '@overleaf/logger'
  11. import ReferalFeatures from '../Referal/ReferalFeatures.mjs'
  12. import V1SubscriptionManager from './V1SubscriptionManager.mjs'
  13. import InstitutionsFeatures from '../Institutions/InstitutionsFeatures.mjs'
  14. import UserGetter from '../User/UserGetter.mjs'
  15. import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
  16. import Queues from '../../infrastructure/Queues.mjs'
  17. import Modules from '../../infrastructure/Modules.mjs'
  18. import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.mjs'
  19. import { AI_ADD_ON_CODE } from './AiHelper.mjs'
  20. import { fetchNothing } from '@overleaf/fetch-utils'
  21. /**
  22. * Enqueue a job for refreshing features for the given user
  23. */
  24. async function scheduleRefreshFeatures(userId, reason) {
  25. const queue = Queues.getQueue('refresh-features')
  26. await queue.add({ userId, reason })
  27. }
  28. /* Check if user features refresh if needed, based on the global featuresEpoch setting */
  29. function featuresEpochIsCurrent(user) {
  30. return Settings.featuresEpoch
  31. ? user.featuresEpoch === Settings.featuresEpoch
  32. : true
  33. }
  34. /**
  35. * Refresh features for the given user
  36. */
  37. async function refreshFeatures(userId, reason) {
  38. const user = await UserGetter.promises.getUser(userId, {
  39. _id: 1,
  40. features: 1,
  41. })
  42. const oldFeatures = _.clone(user.features)
  43. const features = await computeFeatures(userId)
  44. logger.debug({ userId, features, reason }, 'updating user features')
  45. const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(features)
  46. AnalyticsManager.setUserPropertyForUserInBackground(
  47. userId,
  48. 'feature-set',
  49. matchedFeatureSet
  50. )
  51. const { features: newFeatures, featuresChanged } =
  52. await UserFeaturesUpdater.promises.updateFeatures(userId, features)
  53. // TODO: this call is quite expensive, so ideally we'd update cio with something
  54. // that doesn't require the best subscription to be computed, ie. the plan code (or type)
  55. const bestSubscriptionType = await _getBestSubscriptionType(userId)
  56. Modules.promises.hooks
  57. .fire('setUserProperties', userId, {
  58. features,
  59. 'best-subscription-type': bestSubscriptionType,
  60. })
  61. .catch(err => {
  62. logger.error({ err, userId }, 'Failed to sync features to customer.io')
  63. })
  64. if (oldFeatures.dropbox === true && features.dropbox === false) {
  65. logger.debug({ userId }, '[FeaturesUpdater] must unlink dropbox')
  66. try {
  67. await Modules.promises.hooks.fire('removeDropbox', userId, reason)
  68. } catch (err) {
  69. logger.error({ err, userId }, 'removeDropbox hook failed')
  70. }
  71. }
  72. if (oldFeatures.github === true && features.github === false) {
  73. logger.debug({ userId }, '[FeaturesUpdater] must unlink github')
  74. try {
  75. await Modules.promises.hooks.fire('removeGithub', userId, reason)
  76. } catch (err) {
  77. logger.error({ err, userId }, 'removeGithub hook failed')
  78. }
  79. }
  80. // only update Writefull if the user's features have changed,
  81. // skip if they are the reason we are refreshing features (they'd already be up to date)
  82. if (featuresChanged && reason !== 'writefullEntitlementSynced') {
  83. try {
  84. // update WF with the current feature set for the user
  85. await fetchNothing(
  86. `${Settings.writefull.overleafApiUrl}/api/user/status/update-overleaf-status`,
  87. {
  88. headers: {
  89. 'x-api-key': Settings.writefull.overleafApiKey,
  90. },
  91. json: {
  92. userOverleafId: userId,
  93. hasAiAssist: newFeatures.aiErrorAssistant,
  94. },
  95. method: 'POST',
  96. }
  97. )
  98. } catch (err) {
  99. // continue with sync even if we cant communicate with wf
  100. logger.warn(
  101. { userId, reason },
  102. 'failed to sync entitlement to Writefull after a feature refresh'
  103. )
  104. }
  105. }
  106. return { features: newFeatures, featuresChanged }
  107. }
  108. async function _getBestSubscriptionType(userId) {
  109. try {
  110. const { bestSubscription } =
  111. await SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails({
  112. _id: userId,
  113. })
  114. return bestSubscription?.type || 'free'
  115. } catch (err) {
  116. logger.warn(
  117. { err, userId },
  118. 'Failed to calculate best-subscription-type for customer.io'
  119. )
  120. return 'free'
  121. }
  122. }
  123. /**
  124. * Return the features that the given user should have.
  125. */
  126. async function computeFeatures(userId) {
  127. const individualFeatures = await _getIndividualFeatures(userId)
  128. const groupFeatureSets = await _getGroupFeatureSets(userId)
  129. const institutionFeatures =
  130. await InstitutionsFeatures.promises.getInstitutionsFeatures(userId)
  131. const user = await UserGetter.promises.getUser(userId, {
  132. featuresOverrides: 1,
  133. 'overleaf.id': 1,
  134. })
  135. const v1Features = await _getV1Features(user)
  136. const bonusFeatures = await ReferalFeatures.promises.getBonusFeatures(userId)
  137. const featuresOverrides = await _getFeaturesOverrides(user)
  138. logger.debug(
  139. {
  140. userId,
  141. individualFeatures,
  142. groupFeatureSets,
  143. institutionFeatures,
  144. v1Features,
  145. bonusFeatures,
  146. featuresOverrides,
  147. },
  148. 'merging user features'
  149. )
  150. const featureSets = groupFeatureSets.concat([
  151. individualFeatures,
  152. institutionFeatures,
  153. v1Features,
  154. bonusFeatures,
  155. featuresOverrides,
  156. ])
  157. return _.reduce(
  158. featureSets,
  159. FeaturesHelper.mergeFeatures,
  160. Settings.defaultFeatures
  161. )
  162. }
  163. async function _getIndividualFeatures(userId) {
  164. const subscription =
  165. await SubscriptionLocator.promises.getUsersSubscription(userId)
  166. if (
  167. subscription == null ||
  168. SubscriptionHelper.getPaidSubscriptionState(subscription) === 'paused' ||
  169. subscription.userFeaturesDisabled
  170. ) {
  171. return {}
  172. }
  173. const featureSets = []
  174. // The plan doesn't apply to the group admin when the subscription
  175. // is a group subscription
  176. if (!subscription.groupPlan) {
  177. featureSets.push(_subscriptionToFeatures(subscription))
  178. }
  179. featureSets.push(_aiAddOnFeatures(subscription))
  180. return _.reduce(featureSets, FeaturesHelper.mergeFeatures, {})
  181. }
  182. async function _getGroupFeatureSets(userId) {
  183. const subs =
  184. await SubscriptionLocator.promises.getGroupSubscriptionsMemberOf(userId)
  185. return (subs || [])
  186. .filter(sub => sub.userFeaturesDisabled !== true)
  187. .map(_subscriptionToFeatures)
  188. }
  189. async function _getFeaturesOverrides(user) {
  190. if (!user || !user.featuresOverrides || user.featuresOverrides.length === 0) {
  191. return {}
  192. }
  193. const activeFeaturesOverrides = []
  194. for (const featuresOverride of user.featuresOverrides) {
  195. if (
  196. !featuresOverride.expiresAt ||
  197. featuresOverride.expiresAt > new Date()
  198. ) {
  199. activeFeaturesOverrides.push(featuresOverride.features)
  200. }
  201. }
  202. return _.reduce(activeFeaturesOverrides, FeaturesHelper.mergeFeatures, {})
  203. }
  204. async function _getV1Features(user) {
  205. const v1Id = user?.overleaf?.id
  206. return V1SubscriptionManager.getGrandfatheredFeaturesForV1User(v1Id) || {}
  207. }
  208. function _subscriptionToFeatures(subscription) {
  209. if (!subscription?.planCode) {
  210. return {}
  211. }
  212. const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
  213. if (!plan) {
  214. return {}
  215. } else {
  216. return plan.features
  217. }
  218. }
  219. function _aiAddOnFeatures(subscription) {
  220. if (subscription?.addOns?.some(addOn => addOn.addOnCode === AI_ADD_ON_CODE)) {
  221. return { aiErrorAssistant: true }
  222. } else {
  223. return {}
  224. }
  225. }
  226. async function doSyncFromV1(v1UserId) {
  227. logger.debug({ v1UserId }, '[AccountSync] starting account sync')
  228. const user = await UserGetter.promises.getUser(
  229. { 'overleaf.id': v1UserId },
  230. { _id: 1 }
  231. )
  232. if (user == null) {
  233. logger.warn({ v1UserId }, '[AccountSync] no user found for v1 id')
  234. return
  235. }
  236. logger.debug(
  237. { v1UserId, userId: user._id },
  238. '[AccountSync] updating user subscription and features'
  239. )
  240. return refreshFeatures(user._id, 'sync-v1')
  241. }
  242. export default {
  243. featuresEpochIsCurrent,
  244. computeFeatures: callbackify(computeFeatures),
  245. refreshFeatures: callbackifyMultiResult(refreshFeatures, [
  246. 'features',
  247. 'featuresChanged',
  248. ]),
  249. doSyncFromV1: callbackifyMultiResult(doSyncFromV1, [
  250. 'features',
  251. 'featuresChanged',
  252. ]),
  253. scheduleRefreshFeatures: callbackify(scheduleRefreshFeatures),
  254. promises: {
  255. computeFeatures,
  256. refreshFeatures,
  257. scheduleRefreshFeatures,
  258. doSyncFromV1,
  259. },
  260. }