diff --git a/services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs b/services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs index 233525b33a..b206606a43 100644 --- a/services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs +++ b/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 || {} } diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs b/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs index 1304c5b35c..c4bd3d7ee6 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs +++ b/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 } } diff --git a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs b/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs index fd5a2f905f..fd2339f4fa 100644 --- a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs +++ b/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 diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs index 515b44ef70..abaebbbba7 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs +++ b/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 })