diff --git a/services/web/app/src/Features/Project/ProjectListController.mjs b/services/web/app/src/Features/Project/ProjectListController.mjs index b65e145b53..b321ef2f68 100644 --- a/services/web/app/src/Features/Project/ProjectListController.mjs +++ b/services/web/app/src/Features/Project/ProjectListController.mjs @@ -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 }), diff --git a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs b/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs index 54025c923c..e70f5b33e6 100644 --- a/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs +++ b/services/web/app/src/Features/Subscription/CustomerIoPlanHelpers.mjs @@ -7,6 +7,7 @@ import FeaturesHelper from './FeaturesHelper.mjs' * @typedef {InstanceType} 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, } diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx index 14ee4734ce..59e34c5b8d 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-add-new-email.test.tsx @@ -26,6 +26,7 @@ const userEmailData: UserEmailData & { affiliation: Affiliation } = { department: 'Art History', institution: { commonsAccount: false, + enterpriseCommons: false, writefullCommonsAccount: false, confirmed: true, id: 1, diff --git a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx index 3d6bfedc35..a12179e720 100644 --- a/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx +++ b/services/web/test/frontend/features/settings/components/emails/emails-section-institution-and-role.test.tsx @@ -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, diff --git a/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts b/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts index 857a3dc77d..5a9c8fa00b 100644 --- a/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts +++ b/services/web/test/frontend/features/settings/fixtures/test-user-email-data.ts @@ -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, diff --git a/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx b/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx index 36d690a5b0..c5933163e9 100644 --- a/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx +++ b/services/web/test/frontend/features/subscription/components/dashboard/institution-memberships.test.tsx @@ -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, diff --git a/services/web/test/unit/src/Project/ProjectListController.test.mjs b/services/web/test/unit/src/Project/ProjectListController.test.mjs index 35cdca7786..c9b874c414 100644 --- a/services/web/test/unit/src/Project/ProjectListController.test.mjs +++ b/services/web/test/unit/src/Project/ProjectListController.test.mjs @@ -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) diff --git a/services/web/test/unit/src/Subscription/CustomerIoPlanHelpers.test.mjs b/services/web/test/unit/src/Subscription/CustomerIoPlanHelpers.test.mjs index 941a2e706a..1548d73385 100644 --- a/services/web/test/unit/src/Subscription/CustomerIoPlanHelpers.test.mjs +++ b/services/web/test/unit/src/Subscription/CustomerIoPlanHelpers.test.mjs @@ -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) + }) + }) }) diff --git a/services/web/types/institution.ts b/services/web/types/institution.ts index 734ae1524c..085f2c2e63 100644 --- a/services/web/types/institution.ts +++ b/services/web/types/institution.ts @@ -2,6 +2,7 @@ import { Nullable } from './utils' export type Institution = { commonsAccount: boolean + enterpriseCommons: boolean writefullCommonsAccount: boolean confirmed: boolean id: number