diff --git a/services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs b/services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs index f0dba423d9..77598734c2 100644 --- a/services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs +++ b/services/web/app/src/Features/Institutions/InstitutionsFeatures.mjs @@ -9,9 +9,10 @@ import Features from '../../infrastructure/Features.mjs' async function _getInstitutionsAddons(userId) { if (!Features.hasFeature('saas')) return {} const affiliates = - await InstitutionsGetter.promises.getCurrentAffiliations(userId) + await InstitutionsGetter.promises.getCurrentEntitledAffiliations(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 ) diff --git a/services/web/app/src/Features/Institutions/InstitutionsGetter.mjs b/services/web/app/src/Features/Institutions/InstitutionsGetter.mjs index ddc88f093d..8882206bb3 100644 --- a/services/web/app/src/Features/Institutions/InstitutionsGetter.mjs +++ b/services/web/app/src/Features/Institutions/InstitutionsGetter.mjs @@ -3,9 +3,41 @@ import UserGetter from '../User/UserGetter.mjs' import UserMembershipsHandler from '../UserMembership/UserMembershipsHandler.mjs' import UserMembershipEntityConfigs from '../UserMembership/UserMembershipEntityConfigs.mjs' +// The affiliation getters below all derive from UserGetter.getUserFullEmails +// and differ only in filter strictness. Terms used in their names: +// - "confirmed": email confirmed AND its institution's domain confirmed. +// - "current": confirmed AND not past the reconfirmation deadline. +// - "entitled": current AND the user holds an institution licence for that +// email. Being current is not sufficient: some commons subscriptions only +// entitle specific SSO users. Use the entitled getter for licence-gated +// benefits so unentitled users don't receive them. + +/** + * See "entitled" above. Use for licence-gated benefits (e.g. the institution + * plan, the AI assist add-on bundle). Same selection as + * getCurrentInstitutionsWithLicence, but returns one affiliation object per + * matching email (not deduplicated by institution). + * @param {string} userId + * @returns {Promise} affiliation objects + */ +async function getCurrentEntitledAffiliations(userId) { + const fullEmails = await UserGetter.promises.getUserFullEmails(userId) + // emailHasInstitutionLicence already implies a confirmed email at a confirmed + // institution that is not past reconfirmation (see InstitutionsHelper), so no + // further filtering is needed. + return fullEmails + .filter(emailData => emailData.emailHasInstitutionLicence) + .map(emailData => emailData.affiliation) +} + +/** + * Does not check entitlement, so may include affiliations the user holds no + * licence for; use getCurrentEntitledAffiliations for licence-gated benefits. + * @param {string} userId + * @returns {Promise} affiliation objects + */ async function getCurrentAffiliations(userId) { const fullEmails = await UserGetter.promises.getUserFullEmails(userId) - // current are those confirmed and not with lapsed reconfirmations return fullEmails .filter( emailData => @@ -18,22 +50,26 @@ async function getCurrentAffiliations(userId) { .map(emailData => emailData.affiliation) } +/** + * @param {string} userId + * @returns {Promise>} deduplicated institution ids + */ async function getCurrentAndPastAffiliationIds(userId) { let fullEmails = await UserGetter.promises.getUserFullEmails(userId) - // current are those confirmed and not with lapsed reconfirmations fullEmails = fullEmails .filter( emailData => emailData.confirmedAt && emailData.affiliation?.institution?.confirmed ) .map(emailData => emailData.affiliation.institution.id) - // remove dupes return [...new Set(fullEmails)] } +/** + * @param {string} userId + * @returns {Promise>} deduplicated institution ids + */ async function getCurrentInstitutionIds(userId) { - // current are those confirmed and not with lapsed reconfirmations - // only 1 record returned per current institutionId const institutionIds = new Set() const currentAffiliations = await getCurrentAffiliations(userId) currentAffiliations.forEach(affiliation => { @@ -42,9 +78,15 @@ async function getCurrentInstitutionIds(userId) { return [...institutionIds] } +/** + * A non-free affiliation licence is exactly what "entitled" means (see above), + * so this selects the same set as getCurrentEntitledAffiliations. The + * difference is the return value: institution objects deduplicated per + * institution, rather than one affiliation object per matching email. + * @param {string} userId + * @returns {Promise} institution objects, deduplicated + */ async function getCurrentInstitutionsWithLicence(userId) { - // current are those confirmed and not with lapsed reconfirmations - // only 1 record returned per current institution const institutions = {} const currentAffiliations = await getCurrentAffiliations(userId) currentAffiliations.forEach(affiliation => { @@ -55,6 +97,11 @@ async function getCurrentInstitutionsWithLicence(userId) { return Object.values(institutions) } +/** + * Loosest getter: no reconfirmation or entitlement filtering (see above). + * @param {string} userId + * @returns {Promise} affiliation objects + */ async function getConfirmedAffiliations(userId) { const emailsData = await UserGetter.promises.getUserFullEmails(userId) @@ -71,6 +118,12 @@ async function getConfirmedAffiliations(userId) { return confirmedAffiliations } +/** + * Institutions the user administers (via user memberships), not their email + * affiliations. + * @param {string} userId + * @returns {Promise} managed institution entities + */ async function getManagedInstitutions(userId) { return await UserMembershipsHandler.promises.getEntitiesByUser( UserMembershipEntityConfigs.institution, @@ -89,6 +142,7 @@ const InstitutionsGetter = { InstitutionsGetter.promises = { getCurrentAffiliations, + getCurrentEntitledAffiliations, getCurrentInstitutionIds, getCurrentInstitutionsWithLicence, getCurrentAndPastAffiliationIds, diff --git a/services/web/test/acceptance/src/mocks/MockV1Api.mjs b/services/web/test/acceptance/src/mocks/MockV1Api.mjs index a0c7e35e9e..2ec9c16315 100644 --- a/services/web/test/acceptance/src/mocks/MockV1Api.mjs +++ b/services/web/test/acceptance/src/mocks/MockV1Api.mjs @@ -252,6 +252,8 @@ class MockV1Api extends AbstractMockApi { id: institutionId, name: institutionData.name, commonsAccount: institutionData.commonsAccount, + writefullCommonsAccount: + institutionData.writefullCommonsAccount || false, isUniversity: !institutionData.institution, ssoBeta: institutionData.sso_beta || false, ssoEnabled: institutionData.sso_enabled || false, diff --git a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs b/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs index f73dab3834..622254418d 100644 --- a/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs +++ b/services/web/test/unit/src/Institutions/InstitutionsFeatures.test.mjs @@ -15,7 +15,7 @@ describe('InstitutionsFeatures', function () { ctx.PlansLocator = { findLocalPlanInSettings: sinon.stub() } ctx.institutionPlanCode = 'institution_plan_code' ctx.InstitutionsGetter = { - promises: { getCurrentAffiliations: sinon.stub().resolves([]) }, + promises: { getCurrentEntitledAffiliations: sinon.stub().resolves([]) }, } vi.doMock('../../../../app/src/Features/User/UserGetter', () => ({ @@ -147,10 +147,14 @@ 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]) + it('should return ai features if the user is entitled to an affiliation with the add-on bundle', async function (ctx) { + // Only affiliations the user is entitled to reach this point, since the + // entitlement filtering lives in getCurrentEntitledAffiliations (covered + // by its own unit tests). + ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations.resolves([ + ctx.affiliateWithoutAiBundle, + ctx.affiliateWithAiBundle, + ]) ctx.UserGetter.promises.getUserFullEmails.resolves( ctx.emailDataWithLicense ) @@ -162,6 +166,25 @@ describe('InstitutionsFeatures', function () { expect(features).to.deep.equal(ctx.testFeaturesWithAiAddon.features) }) + it('should not return ai features if no entitled affiliation has the add-on bundle', async function (ctx) { + // The user is entitled to a licence (so gets the institution plan) but + // none of their entitled affiliations provide the AI bundle. + ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations.resolves([ + ctx.affiliateWithoutAiBundle, + ]) + ctx.UserGetter.promises.getUserFullEmails.resolves( + ctx.emailDataWithLicense + ) + + const features = + await ctx.InstitutionsFeatures.promises.getInstitutionsFeatures( + ctx.userId + ) + expect(features).to.deep.equal(ctx.testFeatures.features) + expect(features).to.not.have.property('aiUsageQuota') + expect(features).to.not.have.property('aiErrorAssistant') + }) + it('should return feaures if user has affiliations plan code', async function (ctx) { ctx.UserGetter.promises.getUserFullEmails.resolves( ctx.emailDataWithLicense diff --git a/services/web/test/unit/src/Institutions/InstitutionsGetter.test.mjs b/services/web/test/unit/src/Institutions/InstitutionsGetter.test.mjs index 6a116e2b6b..1af8e6ad08 100644 --- a/services/web/test/unit/src/Institutions/InstitutionsGetter.test.mjs +++ b/services/web/test/unit/src/Institutions/InstitutionsGetter.test.mjs @@ -141,6 +141,68 @@ describe('InstitutionsGetter', function () { ctx.unconfirmedDomainLicensedAffiliation, ctx.unconfirmedEmailLicensedAffiliation, ] + + // Fixtures for getCurrentEntitledAffiliations, which keys off + // emailHasInstitutionLicence (the user is entitled to a licence with that + // institution). That flag is derived by UserGetter via + // InstitutionsHelper.emailHasLicence, whose own conditions (confirmed + // email, confirmed institution, non-free licence, not past reconfirmation) + // are covered in InstitutionHelper.test.mjs. + ctx.entitledAffiliation = { + emailHasInstitutionLicence: true, + affiliation: { institution: { id: 456, confirmed: true } }, + } + ctx.unentitledAffiliation = { + emailHasInstitutionLicence: false, + affiliation: { institution: { id: 999, confirmed: true } }, + } + }) + + describe('getCurrentEntitledAffiliations', function () { + it('returns the affiliations for emails the user is entitled to a licence for and filters out the rest', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves([ + ctx.entitledAffiliation, + ctx.unentitledAffiliation, + ]) + const affiliations = + await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations( + ctx.userId + ) + expect(affiliations.map(a => a.institution.id)).to.deep.equal([456]) + }) + + it('returns no affiliations when the user is not entitled to any', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves([ + ctx.unentitledAffiliation, + ]) + const affiliations = + await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations( + ctx.userId + ) + expect(affiliations).to.deep.equal([]) + }) + + it('handles empty response', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.resolves([]) + const affiliations = + await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations( + ctx.userId + ) + expect(affiliations).to.deep.equal([]) + }) + + it('handles errors', async function (ctx) { + ctx.UserGetter.promises.getUserFullEmails.rejects(new Error('oops')) + let e + try { + await ctx.InstitutionsGetter.promises.getCurrentEntitledAffiliations( + ctx.userId + ) + } catch (error) { + e = error + } + expect(e.message).to.equal('oops') + }) }) describe('getCurrentInstitutionIds', function () {