From 970bc85b78ca9365d12f9fb222aeead63dfa6ff0 Mon Sep 17 00:00:00 2001 From: roo hutton Date: Thu, 30 Apr 2026 09:32:52 +0100 Subject: [PATCH] Merge pull request #33247 from overleaf/rh-cio-fix-ai-group-enabled Base group ai enabled cio attribute on group policy GitOrigin-RevId: 2b2411aec3ffc694d2570e6031e9a876a1575e2c --- .../Subscription/CustomerIoPlanHelpers.mjs | 17 +++--- .../Features/Subscription/FeaturesUpdater.mjs | 30 +++++++++++ .../src/Subscription/FeaturesUpdater.test.mjs | 53 ++++++++++++++++++- 3 files changed, 92 insertions(+), 8 deletions(-) diff --git a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs b/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs index ee6cc88524..7e8ea5b357 100644 --- a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs +++ b/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs @@ -250,7 +250,8 @@ function hasPlanAiEnabled(plan) { function getGroupAiEnabled( memberGroupSubscriptions = [], managedGroupSubscriptions = [], - userIsMemberOfGroupSubscription + userIsMemberOfGroupSubscription, + aiBlockedByPolicyId = new Map() ) { if (!userIsMemberOfGroupSubscription) { return null @@ -261,12 +262,12 @@ function getGroupAiEnabled( ...managedGroupSubscriptions, ] - return allGroupSubscriptions.some(subscription => { - const plan = Settings.plans.find( - candidate => candidate.planCode === subscription.planCode - ) - return hasPlanAiEnabled(plan) + const someBlocked = allGroupSubscriptions.some(subscription => { + const policyId = subscription.groupPolicy?.toString() + return policyId ? aiBlockedByPolicyId.get(policyId) : false }) + + return !someBlocked } function getGroupSize( @@ -373,6 +374,7 @@ function getPlanProperties({ userIsMemberOfGroupSubscription, hasCommons, writefullData, + aiBlockedByPolicyId, }) { const planType = normalizePlanType(bestSubscription) const displayPlanType = getFriendlyPlanName(planType) @@ -392,7 +394,8 @@ function getPlanProperties({ const groupAiEnabled = getGroupAiEnabled( memberGroupSubscriptions, managedGroupSubscriptions, - userIsMemberOfGroupSubscription + userIsMemberOfGroupSubscription, + aiBlockedByPolicyId ) const nextRenewalDate = getNextRenewalDateFromPaymentRecord( individualPaymentRecord diff --git a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs b/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs index 3035a05a29..e91deec0e6 100644 --- a/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs +++ b/services/web/app/src/Features/Subscription/FeaturesUpdater.mjs @@ -17,6 +17,7 @@ import Queues from '../../infrastructure/Queues.mjs' import Modules from '../../infrastructure/Modules.mjs' import SubscriptionViewModelBuilder from './SubscriptionViewModelBuilder.mjs' import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.mjs' +import { GroupPolicy } from '../../models/GroupPolicy.mjs' import { AI_ADD_ON_CODE } from './AiHelper.mjs' import { fetchNothing } from '@overleaf/fetch-utils' import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs' @@ -169,6 +170,11 @@ async function _updateCustomerIoSubscriptionProperties(user, features) { ) } + const aiBlockedByPolicyId = await _loadAiBlockedByPolicyId([ + ...memberGroupSubscriptions, + ...managedGroupSubscriptions, + ]) + const planProperties = CustomerIoPlanHelpers.getPlanProperties({ bestSubscription, individualSubscription, @@ -178,6 +184,7 @@ async function _updateCustomerIoSubscriptionProperties(user, features) { userIsMemberOfGroupSubscription, hasCommons, writefullData, + aiBlockedByPolicyId, }) await Modules.promises.hooks.fire('setUserProperties', userId, { @@ -188,6 +195,29 @@ async function _updateCustomerIoSubscriptionProperties(user, features) { }) } +async function _loadAiBlockedByPolicyId(groupSubscriptions) { + const policyIds = [ + ...new Set( + groupSubscriptions.map(sub => sub.groupPolicy?.toString()).filter(Boolean) + ), + ] + + if (policyIds.length === 0) { + return new Map() + } + + const policies = await GroupPolicy.find( + { _id: { $in: policyIds } }, + { _id: 1, userCannotUseAIFeatures: 1 } + ).exec() + return new Map( + policies.map(policy => [ + policy._id.toString(), + Boolean(policy.userCannotUseAIFeatures), + ]) + ) +} + /** * Return the features that the given user should have. */ diff --git a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs index 473df52f73..a0f2923aff 100644 --- a/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs +++ b/services/web/test/unit/src/Subscription/FeaturesUpdater.test.mjs @@ -167,6 +167,10 @@ describe('FeaturesUpdater', function () { }, } + ctx.GroupPolicy = { + find: sinon.stub().returns({ exec: sinon.stub().resolves([]) }), + } + vi.doMock( '../../../../app/src/Features/Subscription/UserFeaturesUpdater', () => ({ @@ -231,6 +235,10 @@ describe('FeaturesUpdater', function () { vi.doMock('../../../../app/src/models/Subscription', () => ({})) + vi.doMock('../../../../app/src/models/GroupPolicy', () => ({ + GroupPolicy: ctx.GroupPolicy, + })) + vi.doMock('@overleaf/fetch-utils', () => ({ fetchNothing: sinon.stub().resolves(), })) @@ -730,7 +738,7 @@ describe('FeaturesUpdater', function () { display_plan_type: 'Group Standard', plan_term_label: 'monthly', ai_plan: 'none', - group_ai_enabled: false, + group_ai_enabled: true, group_size: 8, next_renewal_date: '', expiry_date: '', @@ -745,6 +753,49 @@ describe('FeaturesUpdater', function () { }) }) + describe('when the group subscription has a policy that blocks AI', function () { + beforeEach(async function (ctx) { + const policyId = new ObjectId() + ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves( + { + bestSubscription: { + type: 'group', + plan: { + planCode: 'group-plan-1', + groupPlan: true, + membersLimit: 5, + }, + subscription: { teamName: 'Team Alpha' }, + }, + memberGroupSubscriptions: [ + { + planCode: 'group-plan-1', + teamName: 'Team Alpha', + membersLimit: 8, + groupPolicy: policyId, + }, + ], + managedGroupSubscriptions: [], + individualSubscription: null, + } + ) + ctx.GroupPolicy.find.returns({ + exec: sinon + .stub() + .resolves([{ _id: policyId, userCannotUseAIFeatures: true }]), + }) + await ctx.FeaturesUpdater.promises.refreshFeatures(ctx.user._id, 'test') + }) + + it('should set group_ai_enabled to false', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'setUserProperties', + ctx.user._id, + sinon.match({ group_ai_enabled: false }) + ) + }) + }) + describe('when the user is in a stripe group subscription', function () { beforeEach(async function (ctx) { ctx.SubscriptionViewModelBuilder.promises.getUsersSubscriptionDetails.resolves(