Просмотр исходного кода

Feat: Allow Ai Bundle for Commons Licenses (#29413)

* allowing for writefullCommonsAccount within v1 affiliates to signal a commons ai bundle

* feat: update wf when features change

* feat: replace call to wf with metric to give them estimate of number of calls

* fix: acceptance tests for GroupDomainCaptureTests rely on features outlined in settings, where aiErrorAssistant feature is not listed since it is delivered through a module hook

GitOrigin-RevId: 8c2470c7e73b8a1e080bfc977469d35e66ca9db4
Jimmy Domagala-Tang 10 месяцев назад
Родитель
Сommit
2aa2862a77

+ 18 - 1
services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs

@@ -2,11 +2,28 @@ import { callbackifyAll } from '@overleaf/promise-utils'
 import UserGetter from '../User/UserGetter.mjs'
 import PlansLocator from '../Subscription/PlansLocator.mjs'
 import Settings from '@overleaf/settings'
+import InstitutionsGetter from './InstitutionsGetter.mjs'
+import FeaturesHelper from '../Subscription/FeaturesHelper.mjs'
+
+async function _getInstitutionsAddons(userId) {
+  const affiliates =
+    await InstitutionsGetter.promises.getCurrentAffiliations(userId)
+  // currently only addOn available to institutions is assist/WF bundle,
+  //  which is denoted by the presence of writefullCommonsAccount on the institution
+  const hasAssistBundle = affiliates.some(
+    affiliate => affiliate?.institution?.writefullCommonsAccount === true
+  )
+  return hasAssistBundle ? { aiErrorAssistant: true } : {}
+}
 
 async function getInstitutionsFeatures(userId) {
   const planCode = await getInstitutionsPlan(userId)
   const plan = planCode && PlansLocator.findLocalPlanInSettings(planCode)
-  const features = plan && plan.features
+  let features = plan && plan.features
+
+  const addOns = await _getInstitutionsAddons(userId)
+  features = FeaturesHelper.mergeFeatures(features, addOns)
+
   return features || {}
 }
 

+ 30 - 1
services/web/app/src/Features/Subscription/FeaturesUpdater.mjs

@@ -16,6 +16,8 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
 import Queues from '../../infrastructure/Queues.mjs'
 import Modules from '../../infrastructure/Modules.js'
 import { AI_ADD_ON_CODE } from './AiHelper.mjs'
+// import { fetchNothing } from '@overleaf/fetch-utils'
+import metrics from '@overleaf/metrics'
 
 /**
  * Enqueue a job for refreshing features for the given user
@@ -42,7 +44,7 @@ async function refreshFeatures(userId, reason) {
   })
   const oldFeatures = _.clone(user.features)
   const features = await computeFeatures(userId)
-  logger.debug({ userId, features }, 'updating user features')
+  logger.debug({ userId, features, reason }, 'updating user features')
 
   const matchedFeatureSet = FeaturesHelper.getMatchedFeatureSet(features)
   AnalyticsManager.setUserPropertyForUserInBackground(
@@ -71,6 +73,33 @@ async function refreshFeatures(userId, reason) {
     }
   }
 
+  // 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',
+      //   }
+      // )
+      // increment a metric instead of calling WF so we cna give them an idea of the # of requests they will recieve
+      metrics.inc('feature_sync_called_to_wf')
+    } catch (err) {
+      logger.warn(
+        { userId, reason },
+        'failed to sync entitlement to Writefull after a feature refresh'
+      )
+    }
+  }
   return { features: newFeatures, featuresChanged }
 }
 

+ 39 - 1
services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs

@@ -14,6 +14,9 @@ describe('InstitutionsFeatures', function () {
     }
     ctx.PlansLocator = { findLocalPlanInSettings: sinon.stub() }
     ctx.institutionPlanCode = 'institution_plan_code'
+    ctx.InstitutionsGetter = {
+      promises: { getCurrentAffiliations: sinon.stub().resolves([]) },
+    }
 
     vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({
       default: ctx.UserGetter,
@@ -29,10 +32,30 @@ describe('InstitutionsFeatures', function () {
       },
     }))
 
+    vi.doMock(
+      '../../../../app/src/Features/Institutions/InstitutionsGetter',
+      () => ({
+        default: ctx.InstitutionsGetter,
+      })
+    )
+
     ctx.InstitutionsFeatures = (await import(modulePath)).default
     ctx.emailDataWithLicense = [{ emailHasInstitutionLicence: true }]
     ctx.emailDataWithoutLicense = [{ emailHasInstitutionLicence: false }]
     ctx.userId = '12345abcde'
+    ctx.affiliateWithAiBundle = {
+      institution: { writefullCommonsAccount: true },
+    }
+    ctx.affiliateWithoutAiBundle = {
+      institution: { writefullCommonsAccount: false },
+    }
+    ctx.testFeatures = { features: { institution: 'all' } }
+    ctx.testFeaturesWithAiAddon = {
+      features: { institution: 'all', aiErrorAssistant: true },
+    }
+    ctx.testFeaturesWithNoAddon = {
+      features: { institution: 'all', aiErrorAssistant: false },
+    }
   })
 
   describe('hasLicence', function () {
@@ -87,7 +110,7 @@ describe('InstitutionsFeatures', function () {
       ).to.be.rejected
     })
 
-    it('should return no feaures if user has no plan code', async function (ctx) {
+    it('should return no features if user has no plan code', async function (ctx) {
       ctx.UserGetter.promises.getUserFullEmails.resolves(
         ctx.emailDataWithoutLicense
       )
@@ -98,6 +121,21 @@ describe('InstitutionsFeatures', function () {
       expect(features).to.deep.equal({})
     })
 
+    it('should return ai features if user has any affiliation with add-on bundle', async function (ctx) {
+      ctx.InstitutionsGetter.promises.getCurrentAffiliations = sinon
+        .stub()
+        .resolves([ctx.affiliateWithoutAiBundle, ctx.affiliateWithAiBundle])
+      ctx.UserGetter.promises.getUserFullEmails.resolves(
+        ctx.emailDataWithLicense
+      )
+
+      const features =
+        await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures(
+          ctx.userId
+        )
+      expect(features).to.deep.equal(ctx.testFeaturesWithAiAddon.features)
+    })
+
     it('should return feaures if user has affiliations plan code', async function (ctx) {
       ctx.UserGetter.promises.getUserFullEmails.resolves(
         ctx.emailDataWithLicense

+ 7 - 0
services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs

@@ -72,6 +72,9 @@ describe('FeaturesUpdater', function () {
           bonus: 'features',
         },
       },
+      writefull: {
+        overleafApiUrl: 'https://www.writefull.com',
+      },
     }
 
     ctx.ReferalFeatures = {
@@ -173,6 +176,10 @@ describe('FeaturesUpdater', function () {
 
     vi.doMock('../../../../app/src/models/Subscription', () => ({}))
 
+    vi.doMock('@overleaf/fetch-utils', () => ({
+      fetchNothing: sinon.stub().resolves(),
+    }))
+
     ctx.FeaturesUpdater = (await import(MODULE_PATH)).default
   })