diff --git a/services/web/app/src/Features/Project/ProjectListController.js b/services/web/app/src/Features/Project/ProjectListController.js index 3340c62639..93d541e910 100644 --- a/services/web/app/src/Features/Project/ProjectListController.js +++ b/services/web/app/src/Features/Project/ProjectListController.js @@ -26,6 +26,7 @@ const GeoIpLookup = require('../../infrastructure/GeoIpLookup') const SplitTestHandler = require('../SplitTests/SplitTestHandler') const SplitTestSessionHandler = require('../SplitTests/SplitTestSessionHandler') const SubscriptionLocator = require('../Subscription/SubscriptionLocator') +const TutorialHandler = require('../Tutorial/TutorialHandler') /** * @import { GetProjectsRequest, GetProjectsResponse, AllUsersProjects, MongoProject } from "./types" @@ -114,7 +115,7 @@ async function projectListPage(req, res, next) { const user = await User.findById( userId, `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' ) + 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 = Features.hasFeature('saas') && + !showUSGovBanner && !userIsMemberOfGroupSubscription && !hasPaidAffiliation @@ -455,6 +474,8 @@ async function projectListPage(req, res, next) { prefetchedProjectsBlob, showGroupsAndEnterpriseBanner, groupsAndEnterpriseBannerVariant, + showUSGovBanner, + usGovBannerVariant, showWritefullPromoBanner, showLATAMBanner, recommendedCurrency, diff --git a/services/web/app/src/Features/Tutorial/TutorialController.js b/services/web/app/src/Features/Tutorial/TutorialController.js index 0c01bcdfb6..41a20a6803 100644 --- a/services/web/app/src/Features/Tutorial/TutorialController.js +++ b/services/web/app/src/Features/Tutorial/TutorialController.js @@ -10,6 +10,7 @@ const VALID_KEYS = [ 'ai-error-assistant-consent', 'code-editor-mode-prompt', 'history-restore-promo', + 'us-gov-banner', ] async function completeTutorial(req, res, next) { @@ -27,12 +28,21 @@ async function completeTutorial(req, res, next) { async function postponeTutorial(req, res, next) { const userId = SessionManager.getLoggedInUserId(req.session) const tutorialKey = req.params.tutorialKey + let postponedUntil + if (req.body.postponedUntil) { + postponedUntil = new Date(req.body.postponedUntil) + } if (!VALID_KEYS.includes(tutorialKey)) { return res.sendStatus(404) } - await TutorialHandler.setTutorialState(userId, tutorialKey, 'postponed') + await TutorialHandler.setTutorialState( + userId, + tutorialKey, + 'postponed', + postponedUntil + ) res.sendStatus(204) } diff --git a/services/web/app/src/Features/Tutorial/TutorialHandler.js b/services/web/app/src/Features/Tutorial/TutorialHandler.js index d5819cf354..6ffe9aa89c 100644 --- a/services/web/app/src/Features/Tutorial/TutorialHandler.js +++ b/services/web/app/src/Features/Tutorial/TutorialHandler.js @@ -8,11 +8,26 @@ const POSTPONE_DURATION_MS = 24 * 60 * 60 * 1000 // 1 day * @param {string} userId * @param {string} tutorialKey * @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, { $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 inactiveTutorials.push(key) } else if (record.state === 'postponed') { - const postponedUntil = new Date( + const defaultPostponedUntil = new Date( record.updatedAt.getTime() + POSTPONE_DURATION_MS ) + + const postponedUntil = record.postponedUntil ?? defaultPostponedUntil if (new Date() < postponedUntil) { inactiveTutorials.push(key) } diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index ae8e350db7..8c42569a05 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -37,6 +37,8 @@ block append meta meta(name="ol-groupSubscriptionsPendingEnrollment" data-type="json" content=groupSubscriptionsPendingEnrollment) meta(name="ol-hasIndividualRecurlySubscription" data-type="boolean" content=hasIndividualRecurlySubscription) 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 main.content.content-alt.project-list-react#main-content diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index e1f22ef40d..d707304c51 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -959,6 +959,7 @@ module.exports = { ssoCertificateInfo: [], v1ImportDataScreen: [], snapshotUtils: [], + usGovBanner: [], offlineModeToolbarButtons: [], settingsEntries: [], }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 84ed0c33dd..ca506ca5c7 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1687,6 +1687,8 @@ "upload_project": "", "upload_zipped_project": "", "url_to_fetch_the_file_from": "", + "us_gov_banner_government_purchasing": "", + "us_gov_banner_small_business_reseller": "", "use_a_different_password": "", "use_saml_metadata_to_configure_sso_with_idp": "", "use_your_own_machine": "", diff --git a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx index 34ca1b82d7..2c8f332331 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/user-notifications.tsx @@ -17,6 +17,8 @@ const [enrollmentNotificationModule] = importOverleafModules( 'managedGroupSubscriptionEnrollmentNotification' ) +const [usGovBannerModule] = importOverleafModules('usGovBanner') + const moduleNotifications = importOverleafModules('userNotifications') as { import: { default: ElementType } path: string @@ -27,6 +29,9 @@ const EnrollmentNotification: JSXElementConstructor<{ groupName: string }> = enrollmentNotificationModule?.import.default +const USGovBanner: JSXElementConstructor> = + usGovBannerModule?.import.default + function UserNotifications() { const groupSubscriptionsPendingEnrollment = getMeta('ol-groupSubscriptionsPendingEnrollment') || [] @@ -75,6 +80,7 @@ function UserNotifications() { {!showWritefull && !dismissedWritefull && } + diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index b6c110ae2e..b9faf471fc 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -20,6 +20,7 @@ import { Institution as InstitutionType, Notification as NotificationType, PendingGroupSubscriptionEnrollment, + USGovBannerVariant, } from '../../../types/project/dashboard/notification' import { Survey } from '../../../types/project/dashboard/survey' import { GetProjectsResponseBody } from '../../../types/project/dashboard/api' @@ -180,6 +181,7 @@ export interface Meta { 'ol-showSupport': boolean 'ol-showSymbolPalette': boolean 'ol-showTemplatesServerPro': boolean + 'ol-showUSGovBanner': boolean 'ol-showUpgradePrompt': boolean 'ol-skipUrl': string 'ol-splitTestInfo': { [name: string]: SplitTestInfo } @@ -198,6 +200,7 @@ export interface Meta { 'ol-translationLoadErrorMessage': string 'ol-translationMaintenance': string 'ol-translationUnableToJoin': string + 'ol-usGovBannerVariant': USGovBannerVariant 'ol-useShareJsHash': boolean 'ol-usedLatex': 'never' | 'occasionally' | 'often' | undefined 'ol-user': User diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 5ffcebadbb..76c964798d 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -2300,6 +2300,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": "<0>Get __appName__ for US federal government. 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. We partner with small business resellers to help you buy Overleaf organizational plans. Talk to our government team.", "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.", "use_a_different_password": "Please use a different password", diff --git a/services/web/test/unit/src/Project/ProjectListControllerTests.js b/services/web/test/unit/src/Project/ProjectListControllerTests.js index 53c2b941c1..95131400d4 100644 --- a/services/web/test/unit/src/Project/ProjectListControllerTests.js +++ b/services/web/test/unit/src/Project/ProjectListControllerTests.js @@ -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, { requires: { @@ -158,17 +169,14 @@ describe('ProjectListController', function () { '../User/UserGetter': this.UserGetter, '../Subscription/SubscriptionViewModelBuilder': this.SubscriptionViewModelBuilder, - '../../infrastructure/Modules': { - promises: { - hooks: { fire: sinon.stub().resolves([]) }, - }, - }, + '../../infrastructure/Modules': this.Modules, '../Survey/SurveyHandler': this.SurveyHandler, '../User/UserPrimaryEmailCheckHandler': this.UserPrimaryEmailCheckHandler, '../Notifications/NotificationsBuilder': this.NotificationBuilder, '../Subscription/SubscriptionLocator': this.SubscriptionLocator, '../../infrastructure/GeoIpLookup': this.GeoIpLookup, + '../Tutorial/TutorialHandler': this.TutorialHandler, }, }) @@ -594,6 +602,101 @@ describe('ProjectListController', function () { 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 () { diff --git a/services/web/test/unit/src/Tutorial/TutorialHandlerTests.js b/services/web/test/unit/src/Tutorial/TutorialHandlerTests.js index 88a0d0cc2c..a58ff9d6f5 100644 --- a/services/web/test/unit/src/Tutorial/TutorialHandlerTests.js +++ b/services/web/test/unit/src/Tutorial/TutorialHandlerTests.js @@ -9,6 +9,10 @@ describe('TutorialHandler', function () { beforeEach(function () { 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 = { _id: new ObjectId(), completedTutorials: { @@ -23,7 +27,17 @@ describe('TutorialHandler', function () { }, 'postponed-long-ago': { 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', 'completed', '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) }) }) }) diff --git a/services/web/types/project/dashboard/notification.ts b/services/web/types/project/dashboard/notification.ts index 6fc868e6ff..70408df57b 100644 --- a/services/web/types/project/dashboard/notification.ts +++ b/services/web/types/project/dashboard/notification.ts @@ -105,3 +105,9 @@ export type PendingGroupSubscriptionEnrollment = { export const GroupsAndEnterpriseBannerVariants = ['on-premise', 'FOMO'] as const export type GroupsAndEnterpriseBannerVariant = (typeof GroupsAndEnterpriseBannerVariants)[number] + +export const USGovBannerVariants = [ + 'government-purchasing', + 'small-business-reseller', +] as const +export type USGovBannerVariant = (typeof USGovBannerVariants)[number]