Merge pull request #20371 from overleaf/mf-us-gov-banner

[web] Add US gov banner based on inclusion and exclusion criteria

GitOrigin-RevId: c45ed280c8ef2dbdf9f3b84488e767c06fcc1ae1
This commit is contained in:
M Fahru 2024-10-03 10:01:32 -07:00 committed by Copybot
parent f3cb79c12f
commit 16ba4b0ddf
12 changed files with 211 additions and 11 deletions

View file

@ -26,6 +26,7 @@ const GeoIpLookup = require('../../infrastructure/GeoIpLookup')
const SplitTestHandler = require('../SplitTests/SplitTestHandler') const SplitTestHandler = require('../SplitTests/SplitTestHandler')
const SplitTestSessionHandler = require('../SplitTests/SplitTestSessionHandler') const SplitTestSessionHandler = require('../SplitTests/SplitTestSessionHandler')
const SubscriptionLocator = require('../Subscription/SubscriptionLocator') const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
const TutorialHandler = require('../Tutorial/TutorialHandler')
/** /**
* @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types" * @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types"
@ -114,7 +115,7 @@ async function projectListPage(req, res, next) {
const user = await User.findById( const user = await User.findById(
userId, userId,
`email emails features alphaProgram betaProgram lastPrimaryEmailCheck labsProgram signUpDate${ `email emails features alphaProgram betaProgram lastPrimaryEmailCheck labsProgram signUpDate${
isSaas ? ' enrollment writefull' : '' isSaas ? ' enrollment writefull completedTutorials' : ''
}` }`
) )
@ -351,8 +352,26 @@ async function projectListPage(req, res, next) {
affiliation => affiliation.licence && affiliation.licence !== 'free' affiliation => affiliation.licence && affiliation.licence !== 'free'
) )
const inactiveTutorials = TutorialHandler.getInactiveTutorials(user)
const usGovBannerHooksResponse = await Modules.promises.hooks.fire(
'getUSGovBanner',
userEmails,
hasPaidAffiliation,
inactiveTutorials.includes('us-gov-banner')
)
const usGovBanner = (usGovBannerHooksResponse &&
usGovBannerHooksResponse[0]) || {
showUSGovBanner: false,
usGovBannerVariant: null,
}
const { showUSGovBanner, usGovBannerVariant } = usGovBanner
const showGroupsAndEnterpriseBanner = const showGroupsAndEnterpriseBanner =
Features.hasFeature('saas') && Features.hasFeature('saas') &&
!showUSGovBanner &&
!userIsMemberOfGroupSubscription && !userIsMemberOfGroupSubscription &&
!hasPaidAffiliation !hasPaidAffiliation
@ -455,6 +474,8 @@ async function projectListPage(req, res, next) {
prefetchedProjectsBlob, prefetchedProjectsBlob,
showGroupsAndEnterpriseBanner, showGroupsAndEnterpriseBanner,
groupsAndEnterpriseBannerVariant, groupsAndEnterpriseBannerVariant,
showUSGovBanner,
usGovBannerVariant,
showWritefullPromoBanner, showWritefullPromoBanner,
showLATAMBanner, showLATAMBanner,
recommendedCurrency, recommendedCurrency,

View file

@ -10,6 +10,7 @@ const VALID_KEYS = [
'ai-error-assistant-consent', 'ai-error-assistant-consent',
'code-editor-mode-prompt', 'code-editor-mode-prompt',
'history-restore-promo', 'history-restore-promo',
'us-gov-banner',
] ]
async function completeTutorial(req, res, next) { async function completeTutorial(req, res, next) {
@ -27,12 +28,21 @@ async function completeTutorial(req, res, next) {
async function postponeTutorial(req, res, next) { async function postponeTutorial(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session) const userId = SessionManager.getLoggedInUserId(req.session)
const tutorialKey = req.params.tutorialKey const tutorialKey = req.params.tutorialKey
let postponedUntil
if (req.body.postponedUntil) {
postponedUntil = new Date(req.body.postponedUntil)
}
if (!VALID_KEYS.includes(tutorialKey)) { if (!VALID_KEYS.includes(tutorialKey)) {
return res.sendStatus(404) return res.sendStatus(404)
} }
await TutorialHandler.setTutorialState(userId, tutorialKey, 'postponed') await TutorialHandler.setTutorialState(
userId,
tutorialKey,
'postponed',
postponedUntil
)
res.sendStatus(204) res.sendStatus(204)
} }

View file

@ -8,11 +8,26 @@ const POSTPONE_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day
* @param {string} userId * @param {string} userId
* @param {string} tutorialKey * @param {string} tutorialKey
* @param {'completed' | 'postponed'} state * @param {'completed' | 'postponed'} state
* @param {Date} [postponedUntil] - The date until which the tutorial is postponed
*/ */
async function setTutorialState(userId, tutorialKey, state) { async function setTutorialState(
userId,
tutorialKey,
state,
postponedUntil = null
) {
const updateData = {
state,
updatedAt: new Date(),
}
if (state === 'postponed' && postponedUntil) {
updateData.postponedUntil = postponedUntil
}
await UserUpdater.promises.updateUser(userId, { await UserUpdater.promises.updateUser(userId, {
$set: { $set: {
[`completedTutorials.${tutorialKey}`]: { state, updatedAt: new Date() }, [`completedTutorials.${tutorialKey}`]: updateData,
}, },
}) })
} }
@ -29,9 +44,11 @@ function getInactiveTutorials(user, tutorialKey) {
// Legacy format: single date means the tutorial was completed // Legacy format: single date means the tutorial was completed
inactiveTutorials.push(key) inactiveTutorials.push(key)
} else if (record.state === 'postponed') { } else if (record.state === 'postponed') {
const postponedUntil = new Date( const defaultPostponedUntil = new Date(
record.updatedAt.getTime() + POSTPONE_DURATION_MS record.updatedAt.getTime() + POSTPONE_DURATION_MS
) )
const postponedUntil = record.postponedUntil ?? defaultPostponedUntil
if (new Date() < postponedUntil) { if (new Date() < postponedUntil) {
inactiveTutorials.push(key) inactiveTutorials.push(key)
} }

View file

@ -37,6 +37,8 @@ block append meta
meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment) meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment)
meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription) meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription)
meta(name="ol-groupSsoSetupSuccess" data-type="boolean" content=groupSsoSetupSuccess) meta(name="ol-groupSsoSetupSuccess" data-type="boolean" content=groupSsoSetupSuccess)
meta(name="ol-showUSGovBanner" data-type="boolean" content=showUSGovBanner)
meta(name="ol-usGovBannerVariant" data-type="string" content=usGovBannerVariant)
block content block content
main.content.content-alt.project-list-react#main-content main.content.content-alt.project-list-react#main-content

View file

@ -959,6 +959,7 @@ module.exports = {
ssoCertificateInfo: [], ssoCertificateInfo: [],
v1ImportDataScreen: [], v1ImportDataScreen: [],
snapshotUtils: [], snapshotUtils: [],
usGovBanner: [],
offlineModeToolbarButtons: [], offlineModeToolbarButtons: [],
settingsEntries: [], settingsEntries: [],
}, },

View file

@ -1687,6 +1687,8 @@
"upload_project": "", "upload_project": "",
"upload_zipped_project": "", "upload_zipped_project": "",
"url_to_fetch_the_file_from": "", "url_to_fetch_the_file_from": "",
"us_gov_banner_government_purchasing": "",
"us_gov_banner_small_business_reseller": "",
"use_a_different_password": "", "use_a_different_password": "",
"use_saml_metadata_to_configure_sso_with_idp": "", "use_saml_metadata_to_configure_sso_with_idp": "",
"use_your_own_machine": "", "use_your_own_machine": "",

View file

@ -17,6 +17,8 @@ const [enrollmentNotificationModule] = importOverleafModules(
'managedGroupSubscriptionEnrollmentNotification' 'managedGroupSubscriptionEnrollmentNotification'
) )
const [usGovBannerModule] = importOverleafModules('usGovBanner')
const moduleNotifications = importOverleafModules('userNotifications') as { const moduleNotifications = importOverleafModules('userNotifications') as {
import: { default: ElementType } import: { default: ElementType }
path: string path: string
@ -27,6 +29,9 @@ const EnrollmentNotification: JSXElementConstructor<{
groupName: string groupName: string
}> = enrollmentNotificationModule?.import.default }> = enrollmentNotificationModule?.import.default
const USGovBanner: JSXElementConstructor<Record<string, never>> =
usGovBannerModule?.import.default
function UserNotifications() { function UserNotifications() {
const groupSubscriptionsPendingEnrollment = const groupSubscriptionsPendingEnrollment =
getMeta('ol-groupSubscriptionsPendingEnrollment') || [] getMeta('ol-groupSubscriptionsPendingEnrollment') || []
@ -75,6 +80,7 @@ function UserNotifications() {
<ReconfirmationInfo /> <ReconfirmationInfo />
<GeoBanners /> <GeoBanners />
{!showWritefull && !dismissedWritefull && <GroupsAndEnterpriseBanner />} {!showWritefull && !dismissedWritefull && <GroupsAndEnterpriseBanner />}
<USGovBanner />
<AccessibilitySurveyBanner /> <AccessibilitySurveyBanner />

View file

@ -20,6 +20,7 @@ import {
Institution as InstitutionType, Institution as InstitutionType,
Notification as NotificationType, Notification as NotificationType,
PendingGroupSubscriptionEnrollment, PendingGroupSubscriptionEnrollment,
USGovBannerVariant,
} from '../../../types/project/dashboard/notification' } from '../../../types/project/dashboard/notification'
import { Survey } from '../../../types/project/dashboard/survey' import { Survey } from '../../../types/project/dashboard/survey'
import { GetProjectsResponseBody } from '../../../types/project/dashboard/api' import { GetProjectsResponseBody } from '../../../types/project/dashboard/api'
@ -180,6 +181,7 @@ export interface Meta {
'ol-showSupport': boolean 'ol-showSupport': boolean
'ol-showSymbolPalette': boolean 'ol-showSymbolPalette': boolean
'ol-showTemplatesServerPro': boolean 'ol-showTemplatesServerPro': boolean
'ol-showUSGovBanner': boolean
'ol-showUpgradePrompt': boolean 'ol-showUpgradePrompt': boolean
'ol-skipUrl': string 'ol-skipUrl': string
'ol-splitTestInfo': { [name: string]: SplitTestInfo } 'ol-splitTestInfo': { [name: string]: SplitTestInfo }
@ -198,6 +200,7 @@ export interface Meta {
'ol-translationLoadErrorMessage': string 'ol-translationLoadErrorMessage': string
'ol-translationMaintenance': string 'ol-translationMaintenance': string
'ol-translationUnableToJoin': string 'ol-translationUnableToJoin': string
'ol-usGovBannerVariant': USGovBannerVariant
'ol-useShareJsHash': boolean 'ol-useShareJsHash': boolean
'ol-usedLatex': 'never' | 'occasionally' | 'often' | undefined 'ol-usedLatex': 'never' | 'occasionally' | 'often' | undefined
'ol-user': User 'ol-user': User

View file

@ -2300,6 +2300,8 @@
"upload_project": "Upload Project", "upload_project": "Upload Project",
"upload_zipped_project": "Upload Zipped Project", "upload_zipped_project": "Upload Zipped Project",
"url_to_fetch_the_file_from": "URL to fetch the file from", "url_to_fetch_the_file_from": "URL to fetch the file from",
"us_gov_banner_government_purchasing": "<0>Get __appName__ for US federal government. </0>Move faster through procurement with our tailored purchasing options. Talk to our government team.",
"us_gov_banner_small_business_reseller": "<0>Easy procurement for US federal government. </0>We partner with small business resellers to help you buy Overleaf organizational plans. Talk to our government team.",
"usage_metrics": "Usage metrics", "usage_metrics": "Usage metrics",
"usage_metrics_info": "Metrics that show how many users are accessing the licence, how many projects are being created and worked on, and how much collaboration is happening in Overleaf.", "usage_metrics_info": "Metrics that show how many users are accessing the licence, how many projects are being created and worked on, and how much collaboration is happening in Overleaf.",
"use_a_different_password": "Please use a different password", "use_a_different_password": "Please use a different password",

View file

@ -138,6 +138,17 @@ describe('ProjectListController', function () {
}), }),
}, },
} }
this.TutorialHandler = {
getInactiveTutorials: sinon.stub().returns([]),
}
this.Modules = {
promises: {
hooks: {
fire: sinon.stub().resolves([]),
},
},
}
this.ProjectListController = SandboxedModule.require(MODULE_PATH, { this.ProjectListController = SandboxedModule.require(MODULE_PATH, {
requires: { requires: {
@ -158,17 +169,14 @@ describe('ProjectListController', function () {
'../User/UserGetter': this.UserGetter, '../User/UserGetter': this.UserGetter,
'../Subscription/SubscriptionViewModelBuilder': '../Subscription/SubscriptionViewModelBuilder':
this.SubscriptionViewModelBuilder, this.SubscriptionViewModelBuilder,
'../../infrastructure/Modules': { '../../infrastructure/Modules': this.Modules,
promises: {
hooks: { fire: sinon.stub().resolves([]) },
},
},
'../Survey/SurveyHandler': this.SurveyHandler, '../Survey/SurveyHandler': this.SurveyHandler,
'../User/UserPrimaryEmailCheckHandler': '../User/UserPrimaryEmailCheckHandler':
this.UserPrimaryEmailCheckHandler, this.UserPrimaryEmailCheckHandler,
'../Notifications/NotificationsBuilder': this.NotificationBuilder, '../Notifications/NotificationsBuilder': this.NotificationBuilder,
'../Subscription/SubscriptionLocator': this.SubscriptionLocator, '../Subscription/SubscriptionLocator': this.SubscriptionLocator,
'../../infrastructure/GeoIpLookup': this.GeoIpLookup, '../../infrastructure/GeoIpLookup': this.GeoIpLookup,
'../Tutorial/TutorialHandler': this.TutorialHandler,
}, },
}) })
@ -594,6 +602,101 @@ describe('ProjectListController', function () {
this.ProjectListController.projectListPage(this.req, this.res) this.ProjectListController.projectListPage(this.req, this.res)
}) })
}) })
describe('enterprise banner', function () {
beforeEach(function (done) {
this.Features.hasFeature.withArgs('saas').returns(true)
this.LimitationsManager.promises.userIsMemberOfGroupSubscription.resolves(
{ isMember: false }
)
this.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@test-domain.com',
},
])
done()
})
describe('normal enterprise banner', function () {
it('shows banner', function () {
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.true
}
this.ProjectListController.projectListPage(this.req, this.res)
})
it('does not show banner if user is part of any affiliation', function () {
this.UserGetter.promises.getUserFullEmails.resolves([
{
email: 'test@overleaf.com',
affiliation: {
licence: 'pro_plus',
institution: {
id: 1,
confirmed: true,
name: 'Overleaf',
ssoBeta: false,
ssoEnabled: true,
},
},
},
])
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.false
}
this.ProjectListController.projectListPage(this.req, this.res)
})
it('does not show banner if user is part of any group subscription', function () {
this.LimitationsManager.promises.userIsMemberOfGroupSubscription.resolves(
{ isMember: true }
)
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.false
}
this.ProjectListController.projectListPage(this.req, this.res)
})
it('have a banner variant of "FOMO" or "on-premise"', function () {
this.res.render = (pageName, opts) => {
expect(opts.groupsAndEnterpriseBannerVariant).to.be.oneOf([
'FOMO',
'on-premise',
])
}
this.ProjectListController.projectListPage(this.req, this.res)
})
})
describe('US government enterprise banner', function () {
it('does not show enterprise banner if US government enterprise banner is shown', function () {
const emails = [
{
email: 'test@test.mil',
confirmedAt: new Date('2024-01-01'),
},
]
this.UserGetter.promises.getUserFullEmails.resolves(emails)
this.Modules.promises.hooks.fire
.withArgs('getUSGovBanner', emails, false, false)
.resolves([
{
showUSGovBanner: true,
usGovBannerVariant: 'variant',
},
])
this.res.render = (pageName, opts) => {
expect(opts.showGroupsAndEnterpriseBanner).to.be.false
expect(opts.showUSGovBanner).to.be.true
}
this.ProjectListController.projectListPage(this.req, this.res)
})
})
})
}) })
describe('projectListReactPage with duplicate projects', function () { describe('projectListReactPage with duplicate projects', function () {

View file

@ -9,6 +9,10 @@ describe('TutorialHandler', function () {
beforeEach(function () { beforeEach(function () {
this.clock = sinon.useFakeTimers() this.clock = sinon.useFakeTimers()
const THIRTY_DAYS_AGO = Date.now() - 30 * 24 * 60 * 60 * 1000
const TOMORROW = Date.now() + 24 * 60 * 60 * 1000
const YESTERDAY = Date.now() - 24 * 60 * 60 * 1000
this.user = { this.user = {
_id: new ObjectId(), _id: new ObjectId(),
completedTutorials: { completedTutorials: {
@ -23,7 +27,17 @@ describe('TutorialHandler', function () {
}, },
'postponed-long-ago': { 'postponed-long-ago': {
state: 'postponed', state: 'postponed',
updatedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), updatedAt: new Date(THIRTY_DAYS_AGO),
},
'postponed-until-tomorrow': {
state: 'postponed',
updatedAt: new Date(THIRTY_DAYS_AGO),
postponedUntil: new Date(TOMORROW),
},
'postponed-until-yesterday': {
state: 'postponed',
updatedAt: new Date(THIRTY_DAYS_AGO),
postponedUntil: new Date(YESTERDAY),
}, },
}, },
} }
@ -54,7 +68,20 @@ describe('TutorialHandler', function () {
'legacy-format', 'legacy-format',
'completed', 'completed',
'postponed-recently', 'postponed-recently',
'postponed-until-tomorrow',
]) ])
expect(hiddenTutorials).to.have.lengthOf(4)
const shownTutorials = Object.keys(this.user.completedTutorials).filter(
key => !hiddenTutorials.includes(key)
)
expect(shownTutorials).to.have.members([
'postponed-long-ago',
'postponed-until-yesterday',
])
expect(shownTutorials).to.have.lengthOf(2)
}) })
}) })
}) })

View file

@ -105,3 +105,9 @@ export type PendingGroupSubscriptionEnrollment = {
export const GroupsAndEnterpriseBannerVariants = ['on-premise', 'FOMO'] as const export const GroupsAndEnterpriseBannerVariants = ['on-premise', 'FOMO'] as const
export type GroupsAndEnterpriseBannerVariant = export type GroupsAndEnterpriseBannerVariant =
(typeof GroupsAndEnterpriseBannerVariants)[number] (typeof GroupsAndEnterpriseBannerVariants)[number]
export const USGovBannerVariants = [
'government-purchasing',
'small-business-reseller',
] as const
export type USGovBannerVariant = (typeof USGovBannerVariants)[number]