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
This commit is contained in:
Jimmy Domagala-Tang
2025-11-25 04:51:34 -08:00
committed by Copybot
parent 4186321ed7
commit 2aa2862a77
4 changed files with 94 additions and 3 deletions
@@ -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 || {}
}
@@ -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 }
}
@@ -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
@@ -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
})