From e48be7b195bc846145ffac65897c6556eaa06cbd Mon Sep 17 00:00:00 2001 From: Jimmy Domagala-Tang Date: Tue, 11 Mar 2025 13:10:28 -0400 Subject: [PATCH] Merge pull request #23972 from overleaf/jdt-grant-assist-via-wf-set-trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enable granting of premium error assist based on WF entitlement to bu… GitOrigin-RevId: 9d21cf8755c881bdc698c0cf9891076ecefd34eb --- .../src/Features/Project/ProjectController.js | 17 ++++++++- .../Features/Subscription/FeaturesHelper.js | 8 ++++ services/web/app/src/models/User.js | 1 + .../src/Project/ProjectControllerTests.js | 38 +++++++++++++++++++ .../src/Subscription/FeaturesHelperTests.js | 28 ++++++++++++++ 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 313d259f0b..8ff63d2593 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -15,6 +15,7 @@ const metrics = require('@overleaf/metrics') const { User } = require('../../models/User') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') const LimitationsManager = require('../Subscription/LimitationsManager') +const FeaturesHelper = require('../Subscription/FeaturesHelper') const Settings = require('@overleaf/settings') const AuthorizationManager = require('../Authorization/AuthorizationManager') const InactiveProjectManager = require('../InactiveData/InactiveProjectManager') @@ -760,6 +761,20 @@ const _ProjectController = { const isOverleafAssistBundleEnabled = splitTestAssignments['overleaf-assist-bundle']?.variant === 'enabled' + let fullFeatureSet = user?.features + if (!anonymous) { + // generate users feature set including features added, or overriden via modules + const moduleFeatures = + (await Modules.promises.hooks.fire( + 'getModuleProvidedFeatures', + userId + )) || [] + fullFeatureSet = FeaturesHelper.computeFeatureSet([ + user.features, + ...moduleFeatures, + ]) + } + const isPaywallChangeCompileTimeoutEnabled = splitTestAssignments['paywall-change-compile-timeout']?.variant === 'enabled' @@ -802,7 +817,7 @@ const _ProjectController = { allowedFreeTrial, hasRecurlySubscription: subscription?.recurlySubscription_id != null, featureSwitches: user.featureSwitches, - features: user.features, + features: fullFeatureSet, featureUsage, refProviders: _.mapValues(user.refProviders, Boolean), writefull: { diff --git a/services/web/app/src/Features/Subscription/FeaturesHelper.js b/services/web/app/src/Features/Subscription/FeaturesHelper.js index fe90391c86..b948815477 100644 --- a/services/web/app/src/Features/Subscription/FeaturesHelper.js +++ b/services/web/app/src/Features/Subscription/FeaturesHelper.js @@ -1,6 +1,13 @@ const _ = require('lodash') const Settings = require('@overleaf/settings') +/** + * merges an array of feature sets to produce a final feature set + */ +function computeFeatureSet(featureSets) { + return featureSets.reduce(mergeFeatures, {}) +} + /** * Merge feature sets coming from different sources */ @@ -108,6 +115,7 @@ function getMatchedFeatureSet(features) { module.exports = { mergeFeatures, + computeFeatureSet, isFeatureSetBetter, compareFeatures, getMatchedFeatureSet, diff --git a/services/web/app/src/models/User.js b/services/web/app/src/models/User.js index 7f6081efe2..41e502c4d8 100644 --- a/services/web/app/src/models/User.js +++ b/services/web/app/src/models/User.js @@ -194,6 +194,7 @@ const UserSchema = new Schema( writefull: { enabled: { type: Boolean, default: null }, autoCreatedAccount: { type: Boolean, default: false }, + isPremium: { type: Boolean, default: false }, }, aiErrorAssistant: { enabled: { type: Boolean, default: true }, diff --git a/services/web/test/unit/src/Project/ProjectControllerTests.js b/services/web/test/unit/src/Project/ProjectControllerTests.js index 46427171da..71de5023b1 100644 --- a/services/web/test/unit/src/Project/ProjectControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectControllerTests.js @@ -1121,6 +1121,44 @@ describe('ProjectController', function () { this.ProjectController.loadEditor(this.req, this.res) }) }) + + describe('when fetching the users featureSet', function () { + beforeEach(function () { + this.Modules.promises.hooks.fire = sinon.stub().resolves() + this.user.features = {} + }) + + it('should take into account features overrides from modules', function (done) { + // this case occurs when the user has bought the ai bundle on WF, which should include our error assistant + const bundleFeatures = { aiErrorAssistant: true } + this.user.features = { aiErrorAssistant: false } + this.Modules.promises.hooks.fire = sinon + .stub() + .resolves([bundleFeatures]) + this.res.render = (pageName, opts) => { + expect(opts.user.features).to.deep.equal(bundleFeatures) + this.Modules.promises.hooks.fire.should.have.been.calledWith( + 'getModuleProvidedFeatures', + this.user._id + ) + done() + } + this.ProjectController.loadEditor(this.req, this.res) + }) + + it('should handle modules not returning any features', function (done) { + this.Modules.promises.hooks.fire = sinon.stub().resolves([]) + this.res.render = (pageName, opts) => { + expect(opts.user.features).to.deep.equal({}) + this.Modules.promises.hooks.fire.should.have.been.calledWith( + 'getModuleProvidedFeatures', + this.user._id + ) + done() + } + this.ProjectController.loadEditor(this.req, this.res) + }) + }) }) describe('userProjectsJson', function () { diff --git a/services/web/test/unit/src/Subscription/FeaturesHelperTests.js b/services/web/test/unit/src/Subscription/FeaturesHelperTests.js index 755ad08b88..84ef9bc854 100644 --- a/services/web/test/unit/src/Subscription/FeaturesHelperTests.js +++ b/services/web/test/unit/src/Subscription/FeaturesHelperTests.js @@ -88,6 +88,34 @@ describe('FeaturesHelper', function () { }) }) + describe('computeFeatureSet', function () { + it('should handle only one featureSet', function () { + expect( + this.FeaturesHelper.computeFeatureSet([ + { github: true, feat1: true, feat2: false }, + ]) + ).to.deep.equal({ github: true, feat1: true, feat2: false }) + }) + it('should handle an empty array of featureSets', function () { + expect(this.FeaturesHelper.computeFeatureSet([])).to.deep.equal({}) + }) + + it('should handle 3+ featureSets', function () { + const featureSets = [ + { github: true, feat1: false, feat2: false }, + { github: false, feat1: true, feat2: false, feat3: false }, + { github: false, feat1: false, feat2: true, feat4: true }, + ] + expect(this.FeaturesHelper.computeFeatureSet(featureSets)).to.deep.equal({ + github: true, + feat1: true, + feat2: true, + feat3: false, + feat4: true, + }) + }) + }) + describe('isFeatureSetBetter', function () { it('simple comparisons', function () { const result1 = this.FeaturesHelper.isFeatureSetBetter(