Merge pull request #34130 from overleaf/rh-enterprise-cio

Expose enterprise indicators and previous_plan_type for first subscriptions to customer.io

GitOrigin-RevId: 693db7f796609f00ecd31216a6d6be32c1f569c8
This commit is contained in:
roo hutton
2026-06-03 14:45:05 +01:00
committed by Copybot
parent d112271b1c
commit 06e99fe62a
9 changed files with 198 additions and 0 deletions
@@ -28,6 +28,7 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
import SplitTestSessionHandler from '../SplitTests/SplitTestSessionHandler.mjs'
import TutorialHandler from '../Tutorial/TutorialHandler.mjs'
import SubscriptionHelper from '../Subscription/SubscriptionHelper.mjs'
import CustomerIoPlanHelpers from '../Subscription/CustomerIoPlanHelpers.mjs'
import PermissionsManager from '../Authorization/PermissionsManager.mjs'
import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
import { OnboardingDataCollection } from '../../models/OnboardingDataCollection.mjs'
@@ -585,6 +586,7 @@ async function projectListPage(req, res, next) {
...(usedLatex && { used_latex: usedLatex }),
...(countryCode && { country: countryCode }),
...(commonsInstitution && { commons_institution: commonsInstitution }),
...CustomerIoPlanHelpers.getAffiliationProperties(userEmails),
...(groupRole && { group_role: groupRole }),
is_managed_user: Boolean(user.enrollment?.managedBy),
...(user.email && { email: user.email }),
@@ -7,6 +7,7 @@ import FeaturesHelper from './FeaturesHelper.mjs'
* @typedef {InstanceType<typeof import('../../models/Subscription.mjs').Subscription>} MongoSubscription
* @typedef {import('../../../../types/subscription/plan').Plan} Plan
* @typedef {import('../../../../modules/subscriptions/app/src/PaymentService.mjs').PaymentRecord} PaymentRecord
* @typedef {import('../../../../types/user-email').UserEmailData} UserEmailData
*/
/**
@@ -540,6 +541,28 @@ function getGroupRole(
return 'member'
}
/**
* Customer.io properties derived from a user's institutional affiliations.
*
* @param {UserEmailData[]} userEmails - email data from UserGetter.getUserFullEmails
* @returns {{ enterprise_commons: boolean, domain_capture: boolean }}
*/
function getAffiliationProperties(userEmails) {
const enterpriseCommons = userEmails.some(
emailData =>
emailData.emailHasInstitutionLicence &&
emailData.affiliation?.institution?.commonsAccount &&
emailData.affiliation?.institution?.enterpriseCommons
)
const domainCapture = userEmails.some(
emailData => emailData.affiliation?.group?.domainCaptureEnabled
)
return {
enterprise_commons: enterpriseCommons,
domain_capture: domainCapture,
}
}
/**
* Compute plan-related user properties for sending to customer.io.
*
@@ -657,4 +680,5 @@ export default {
shouldUseCommonsBestSubscription,
getGroupRole,
getPlanProperties,
getAffiliationProperties,
}
@@ -26,6 +26,7 @@ const userEmailData: UserEmailData & { affiliation: Affiliation } = {
department: 'Art History',
institution: {
commonsAccount: false,
enterpriseCommons: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
@@ -24,6 +24,7 @@ const userData1: UserEmailData & { affiliation: Affiliation } = {
department: null,
institution: {
commonsAccount: false,
enterpriseCommons: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
@@ -55,6 +56,7 @@ const userData2: UserEmailData & { affiliation: Affiliation } = {
department: 'Art History',
institution: {
commonsAccount: false,
enterpriseCommons: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
@@ -29,6 +29,7 @@ export const professionalUserData: UserEmailData & {
department: 'Art History',
institution: {
commonsAccount: false,
enterpriseCommons: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
@@ -62,6 +63,7 @@ export const unconfirmedCommonsUserData: UserEmailData & {
department: 'Art History',
institution: {
commonsAccount: true,
enterpriseCommons: false,
writefullCommonsAccount: false,
confirmed: true,
id: 1,
@@ -92,6 +94,7 @@ export const ssoUserData: UserEmailData = {
department: 'Art History',
institution: {
commonsAccount: true,
enterpriseCommons: false,
writefullCommonsAccount: false,
confirmed: true,
id: 2,
@@ -12,6 +12,7 @@ const memberships: Institution[] = [
id: 9258,
name: 'Test University',
commonsAccount: true,
enterpriseCommons: false,
isUniversity: true,
confirmed: true,
ssoBeta: false,
@@ -23,6 +24,7 @@ const memberships: Institution[] = [
id: 9259,
name: 'Example Institution',
commonsAccount: true,
enterpriseCommons: false,
isUniversity: true,
confirmed: true,
ssoBeta: false,
@@ -553,6 +553,107 @@ describe('ProjectListController', function () {
)
})
it('should send enterprise_commons=true when user has commons from an enterprise_commons institution', async function (ctx) {
ctx.Features.hasFeature.withArgs('saas').returns(true)
ctx.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@overleaf.com',
emailHasInstitutionLicence: true,
affiliation: {
institution: {
id: 1,
name: 'Overleaf',
commonsAccount: true,
enterpriseCommons: true,
},
},
},
])
ctx.res.render = () => {}
await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({ enterprise_commons: true })
)
})
it('should send enterprise_commons=false when affiliated with an enterprise_commons institution but without active commons access', async function (ctx) {
ctx.Features.hasFeature.withArgs('saas').returns(true)
ctx.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@overleaf.com',
emailHasInstitutionLicence: false,
affiliation: {
institution: {
id: 1,
name: 'Overleaf',
commonsAccount: true,
enterpriseCommons: true,
},
},
},
])
ctx.res.render = () => {}
await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({ enterprise_commons: false })
)
})
it('should send domain_capture=true when user has an affiliation with domain capture enabled', async function (ctx) {
ctx.Features.hasFeature.withArgs('saas').returns(true)
ctx.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@overleaf.com',
affiliation: {
institution: { id: 1, name: 'Overleaf' },
group: {
_id: 'g1',
domainCaptureEnabled: true,
managedUsersEnabled: false,
},
},
},
])
ctx.res.render = () => {}
await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({ domain_capture: true })
)
})
it('should send domain_capture=false when no affiliation has domain capture enabled', async function (ctx) {
ctx.Features.hasFeature.withArgs('saas').returns(true)
ctx.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@overleaf.com',
affiliation: {
institution: { id: 1, name: 'Overleaf' },
},
},
])
ctx.res.render = () => {}
await ctx.ProjectListController.projectListPage(ctx.req, ctx.res)
expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith(
'setUserProperties',
ctx.user._id,
sinon.match({ domain_capture: false })
)
})
it('should show INR Banner for Indian users with free account', async function (ctx) {
// usersBestSubscription is only available when saas feature is present
ctx.Features.hasFeature.withArgs('saas').returns(true)
@@ -80,4 +80,66 @@ describe('CustomerIoPlanHelpers', function () {
expect(properties.past_due).to.equal(false)
})
})
describe('getAffiliationProperties', function () {
it('sets enterprise_commons=true when the user has active commons access at an enterprise_commons institution', function () {
const properties = CustomerIoPlanHelpers.getAffiliationProperties([
{
emailHasInstitutionLicence: true,
affiliation: {
institution: { commonsAccount: true, enterpriseCommons: true },
},
},
])
expect(properties.enterprise_commons).to.equal(true)
})
it('sets enterprise_commons=false when affiliated with an enterprise_commons institution but without active commons access', function () {
const properties = CustomerIoPlanHelpers.getAffiliationProperties([
{
emailHasInstitutionLicence: false,
affiliation: {
institution: { commonsAccount: true, enterpriseCommons: true },
},
},
])
expect(properties.enterprise_commons).to.equal(false)
})
it('sets enterprise_commons=false when active access is not at an enterprise_commons institution', function () {
const properties = CustomerIoPlanHelpers.getAffiliationProperties([
{
emailHasInstitutionLicence: true,
affiliation: {
institution: { commonsAccount: true, enterpriseCommons: false },
},
},
])
expect(properties.enterprise_commons).to.equal(false)
})
it('sets enterprise_commons=false when there are no emails', function () {
const properties = CustomerIoPlanHelpers.getAffiliationProperties([])
expect(properties.enterprise_commons).to.equal(false)
})
it('sets domain_capture=true when an affiliation has domain capture enabled', function () {
const properties = CustomerIoPlanHelpers.getAffiliationProperties([
{
affiliation: {
institution: {},
group: { domainCaptureEnabled: true },
},
},
])
expect(properties.domain_capture).to.equal(true)
})
it('sets domain_capture=false when no affiliation has domain capture enabled', function () {
const properties = CustomerIoPlanHelpers.getAffiliationProperties([
{ affiliation: { institution: {} } },
])
expect(properties.domain_capture).to.equal(false)
})
})
})
+1
View File
@@ -2,6 +2,7 @@ import { Nullable } from './utils'
export type Institution = {
commonsAccount: boolean
enterpriseCommons: boolean
writefullCommonsAccount: boolean
confirmed: boolean
id: number