Przeglądaj źródła

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

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

GitOrigin-RevId: c45ed280c8ef2dbdf9f3b84488e767c06fcc1ae1
M Fahru 1 rok temu
rodzic
commit
16ba4b0ddf

+ 22 - 1
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,

+ 11 - 1
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)
 }
 

+ 20 - 3
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)
       }

+ 2 - 0
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

+ 1 - 0
services/web/config/settings.defaults.js

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

+ 2 - 0
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": "",

+ 6 - 0
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<Record<string, never>> =
+  usGovBannerModule?.import.default
+
 function UserNotifications() {
   const groupSubscriptionsPendingEnrollment =
     getMeta('ol-groupSubscriptionsPendingEnrollment') || []
@@ -75,6 +80,7 @@ function UserNotifications() {
         <ReconfirmationInfo />
         <GeoBanners />
         {!showWritefull && !dismissedWritefull && <GroupsAndEnterpriseBanner />}
+        <USGovBanner />
 
         <AccessibilitySurveyBanner />
 

+ 3 - 0
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

+ 2 - 0
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. </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_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",

+ 108 - 5
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 () {

+ 28 - 1
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)
     })
   })
 })

+ 6 - 0
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]