diff --git a/package-lock.json b/package-lock.json index f88a888ef6..c21c6ca33b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10483,9 +10483,9 @@ } }, "node_modules/@customerio/cdp-analytics-core": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-core/-/cdp-analytics-core-0.3.0.tgz", - "integrity": "sha512-5BJ8VgUkLT2YuDZ7Dr+oWnpFNObF5tgfj1hU/E01+kxUAvzUoOiLNL6NjKy9AeZYIFuzqgzYCfmR/1mGYdngsw==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-core/-/cdp-analytics-core-0.3.9.tgz", + "integrity": "sha512-AjMB48tTu8JN4NAgmbOoGAS1veE7AvLUyZD+qKXZLD/muWy6Vou4MEfXLVzhQP/cIbf3VBPGUxMnugjwZFc6Sw==", "license": "MIT", "dependencies": { "@lukeed/uuid": "^2.0.0", @@ -10494,11 +10494,11 @@ } }, "node_modules/@customerio/cdp-analytics-node": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-node/-/cdp-analytics-node-0.3.0.tgz", - "integrity": "sha512-p8WCtj+O3JoOooaFeENQzGPD8SDJ8x7z3P6s6xHtIaxsYjPFVfFVRV1avJQb3YTA+Z4Ak5h1oK/zayTIBJA6pQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@customerio/cdp-analytics-node/-/cdp-analytics-node-0.3.9.tgz", + "integrity": "sha512-+4DPQDCt0y+KhzqHoCrokicJhchiCPsnaC7W7h4ebrahc0zoLuovLKEn4pXeygi6yS5D2oKJ0bzi6xisb21mtQ==", "dependencies": { - "@customerio/cdp-analytics-core": "0.3.0", + "@customerio/cdp-analytics-core": "0.3.9", "@lukeed/uuid": "^2.0.0", "buffer": "^6.0.3", "node-fetch": "^2.6.7", @@ -58218,6 +58218,7 @@ "@aws-sdk/client-ses": "^3.864.0", "@contentful/rich-text-html-renderer": "^16.0.2", "@contentful/rich-text-types": "^16.0.2", + "@customerio/cdp-analytics-node": "^0.3.9", "@google-cloud/bigquery": "^6.0.1", "@google-cloud/storage": "^6.10.1", "@node-oauth/oauth2-server": "^5.1.0", diff --git a/services/web/app/src/Features/Newsletter/MailChimpClient.mjs b/services/web/app/src/Features/Newsletter/MailChimpClient.mjs deleted file mode 100644 index 492a2e6eb3..0000000000 --- a/services/web/app/src/Features/Newsletter/MailChimpClient.mjs +++ /dev/null @@ -1,68 +0,0 @@ -import { fetchJson, fetchNothing } from '@overleaf/fetch-utils' - -class MailChimpClient { - constructor(apiKey) { - this.apiKey = apiKey - this.dc = apiKey.split('-')[1] - this.baseUrl = `https://${this.dc}.api.mailchimp.com/3.0/` - this.fetchOptions = { - method: 'GET', - basicAuth: { - user: 'any', - password: this.apiKey, - }, - } - } - - async request(path, options) { - try { - const requestUrl = `${this.baseUrl}${path}` - if (options.method === 'GET') { - return await fetchJson(requestUrl, options) - } - await fetchNothing(requestUrl, options) - } catch (err) { - // if there's a json body in the response, expose it in the error (for compatibility with node-mailchimp) - const errorBody = err.body ? JSON.parse(err.body) : {} - const errWithBody = Object.assign(err, errorBody) - throw errWithBody - } - } - - async get(path) { - return await this.request(path, this.fetchOptions) - } - - async put(path, body) { - const options = Object.assign({}, this.fetchOptions) - options.method = 'PUT' - options.json = body - - return await this.request(path, options) - } - - async post(path, body) { - const options = Object.assign({}, this.fetchOptions) - options.method = 'POST' - options.json = body - - return await this.request(path, options) - } - - async delete(path) { - const options = Object.assign({}, this.fetchOptions) - options.method = 'DELETE' - - return await this.request(path, options) - } - - async patch(path, body) { - const options = Object.assign({}, this.fetchOptions) - options.method = 'PATCH' - options.json = body - - return await this.request(path, options) - } -} - -export default MailChimpClient diff --git a/services/web/app/src/Features/Newsletter/MailChimpProvider.mjs b/services/web/app/src/Features/Newsletter/MailChimpProvider.mjs deleted file mode 100644 index 5f7f5163da..0000000000 --- a/services/web/app/src/Features/Newsletter/MailChimpProvider.mjs +++ /dev/null @@ -1,315 +0,0 @@ -import logger from '@overleaf/logger' -import Settings from '@overleaf/settings' -import crypto from 'node:crypto' -import OError from '@overleaf/o-error' -import { callbackify } from 'node:util' -import MailChimpClient from './MailChimpClient.mjs' - -function mailchimpIsConfigured() { - return Settings.mailchimp != null && Settings.mailchimp.api_key != null -} - -function make(listName, listId) { - let provider - if (!mailchimpIsConfigured() || !listId) { - logger.debug({ listName }, 'Using newsletter provider: none') - provider = makeNullProvider(listName) - } else { - logger.debug({ listName }, 'Using newsletter provider: mailchimp') - provider = makeMailchimpProvider(listName, listId) - } - return { - subscribed: callbackify(provider.subscribed), - subscribe: callbackify(provider.subscribe), - unsubscribe: callbackify(provider.unsubscribe), - changeEmail: callbackify(provider.changeEmail), - tag: callbackify(provider.tag), - removeTag: callbackify(provider.removeTag), - promises: provider, - } -} - -export default { - make, -} - -class NonFatalEmailUpdateError extends OError { - constructor(message, oldEmail, newEmail) { - super(message, { oldEmail, newEmail }) - } -} - -function makeMailchimpProvider(listName, listId) { - const mailchimp = new MailChimpClient(Settings.mailchimp.api_key) - const MAILCHIMP_LIST_ID = listId - - return { - subscribed, - subscribe, - unsubscribe, - changeEmail, - tag, - removeTag, - } - - async function subscribed(user) { - try { - const path = getSubscriberPath(user.email) - const result = await mailchimp.get(path) - return result?.status === 'subscribed' - } catch (err) { - if (err?.response?.status === 404) { - return false - } - throw OError.tag(err, 'error getting newsletter subscriptions status', { - userId: user._id, - listName, - }) - } - } - - async function subscribe(user) { - try { - const path = getSubscriberPath(user.email) - await mailchimp.put(path, { - email_address: user.email, - status: 'subscribed', - status_if_new: 'subscribed', - merge_fields: getMergeFields(user), - }) - logger.debug( - { user, listName }, - 'finished subscribing user to newsletter' - ) - } catch (err) { - throw OError.tag(err, 'error subscribing user to newsletter', { - userId: user._id, - listName, - }) - } - } - - async function tag(user, tag) { - try { - const path = getMemberTagsPath(user.email) - await mailchimp.post(path, { - tags: [{ name: tag, status: 'active' }], - }) - logger.debug({ user, listName }, `finished adding ${tag} to user`) - } catch (err) { - throw OError.tag(err, `error adding ${tag} to user`, { - userId: user._id, - listName, - tag, - }) - } - } - - async function removeTag(user, tag) { - try { - const path = getMemberTagsPath(user.email) - await mailchimp.post(path, { - tags: [{ name: tag, status: 'inactive' }], - }) - logger.debug({ user, listName }, `finished removing ${tag} from user`) - } catch (err) { - throw OError.tag(err, `error removing ${tag} from user`, { - userId: user._id, - listName, - tag, - }) - } - } - - async function unsubscribe(user, options = {}) { - try { - const path = getSubscriberPath(user.email) - if (options.delete) { - await mailchimp.delete(path) - } else { - await mailchimp.patch(path, { - status: 'unsubscribed', - merge_fields: getMergeFields(user), - }) - } - logger.debug( - { user, options, listName }, - 'finished unsubscribing user from newsletter' - ) - } catch (err) { - if ([404, 405].includes(err?.response?.status)) { - // silently ignore users who were never subscribed (404) or previously deleted (405) - return - } - - if (err.message.includes('looks fake or invalid')) { - logger.debug( - { err, user, options, listName }, - 'Mailchimp declined to unsubscribe user because it finds the email looks fake' - ) - return - } - - throw OError.tag(err, 'error unsubscribing user from newsletter', { - userId: user._id, - listName, - }) - } - } - - async function changeEmail(user, newEmail) { - const oldEmail = user.email - - try { - await updateEmailInMailchimp(user, newEmail) - } catch (updateError) { - // if we failed to update the user, delete their old email address so that - // we don't leave it stuck in mailchimp - logger.debug( - { oldEmail, newEmail, updateError, listName }, - 'unable to change email in newsletter, removing old mail' - ) - - try { - await unsubscribe(user, { delete: true }) - } catch (unsubscribeError) { - // something went wrong removing the user's address - throw OError.tag( - unsubscribeError, - 'error unsubscribing old email in response to email change failure', - { oldEmail, newEmail, updateError, listName } - ) - } - - if (!(updateError instanceof NonFatalEmailUpdateError)) { - throw updateError - } - } - } - - async function updateEmailInMailchimp(user, newEmail) { - const oldEmail = user.email - - // mailchimp doesn't give us error codes, so we have to parse the message :'( - const errors = { - 'merge fields were invalid': 'user has never subscribed', - 'could not be validated': - 'user has previously unsubscribed or new email already exist on list', - 'is already a list member': 'new email is already on mailing list', - 'looks fake or invalid': 'mail looks fake to mailchimp', - } - - try { - const path = getSubscriberPath(oldEmail) - await mailchimp.patch(path, { - email_address: newEmail, - merge_fields: getMergeFields(user), - }) - logger.debug( - { newEmail, listName }, - 'finished changing email in the newsletter' - ) - } catch (err) { - // silently ignore users who were never subscribed - if (err.status === 404) { - return - } - - // look through expected mailchimp errors and log if we find one - Object.keys(errors).forEach(key => { - if (err.message.includes(key)) { - const message = `unable to change email in newsletter, ${errors[key]}` - - logger.debug({ oldEmail, newEmail, listName }, message) - - throw new NonFatalEmailUpdateError( - message, - oldEmail, - newEmail - ).withCause(err) - } - }) - - // if we didn't find an expected error, generate something to throw - throw OError.tag(err, 'error changing email in newsletter', { - oldEmail, - newEmail, - listName, - }) - } - } - - function getSubscriberPath(email) { - const emailHash = hashEmail(email) - return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}` - } - - function getMemberTagsPath(email) { - const emailHash = hashEmail(email) - return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}/tags` - } - - function hashEmail(email) { - return crypto.createHash('md5').update(email.toLowerCase()).digest('hex') - } - - function getMergeFields(user) { - return { - FNAME: user.first_name, - LNAME: user.last_name, - MONGO_ID: user._id.toString(), - } - } -} - -function makeNullProvider(listName) { - return { - subscribed, - subscribe, - unsubscribe, - changeEmail, - tag, - removeTag, - } - - async function subscribed(user) { - logger.debug( - { user, listName }, - 'Not checking user because no newsletter provider is configured' - ) - return false - } - - async function subscribe(user) { - logger.debug( - { user, listName }, - 'Not subscribing user to newsletter because no newsletter provider is configured' - ) - } - - async function unsubscribe(user) { - logger.debug( - { user, listName }, - 'Not unsubscribing user from newsletter because no newsletter provider is configured' - ) - } - - async function changeEmail(user, newEmail) { - logger.debug( - { userId: user._id, newEmail, listName }, - 'Not changing email in newsletter for user because no newsletter provider is configured' - ) - } - async function tag(user, tag) { - logger.debug( - { userId: user._id, tag, listName }, - 'Not tagging user because no newsletter provider is configured' - ) - } - async function removeTag(user, tag) { - logger.debug( - { userId: user._id, tag, listName }, - 'Not removing tag for user because no newsletter provider is configured' - ) - } -} diff --git a/services/web/app/src/Features/Newsletter/NewsletterManager.mjs b/services/web/app/src/Features/Newsletter/NewsletterManager.mjs deleted file mode 100644 index 1b7f638c4a..0000000000 --- a/services/web/app/src/Features/Newsletter/NewsletterManager.mjs +++ /dev/null @@ -1,9 +0,0 @@ -import Settings from '@overleaf/settings' -import MailchimpProvider from './MailChimpProvider.mjs' - -const provider = MailchimpProvider.make( - 'newsletter', - Settings.mailchimp ? Settings.mailchimp.list_id : null -) - -export default provider diff --git a/services/web/app/src/Features/User/UserController.mjs b/services/web/app/src/Features/User/UserController.mjs index ac1ca8870b..37cc016f9c 100644 --- a/services/web/app/src/Features/User/UserController.mjs +++ b/services/web/app/src/Features/User/UserController.mjs @@ -2,7 +2,6 @@ import UserHandler from './UserHandler.mjs' import UserDeleter from './UserDeleter.mjs' import UserGetter from './UserGetter.mjs' import { User } from '../../models/User.mjs' -import NewsletterManager from '../Newsletter/NewsletterManager.mjs' import logger from '@overleaf/logger' import metrics from '@overleaf/metrics' import AuthenticationManager from '../Authentication/AuthenticationManager.mjs' @@ -300,15 +299,16 @@ async function subscribe(req, res, next) { const userId = SessionManager.getLoggedInUserId(req.session) req.logger.addFields({ userId }) - const user = await UserGetter.promises.getUser(userId, { - _id: 1, - email: 1, - first_name: 1, - last_name: 1, - }) - await NewsletterManager.promises.subscribe(user) + await Modules.promises.hooks.fire( + 'updateTopicSubscription', + userId, + 'newsletter', + true + ) + res.json({ message: req.i18n.translate('thanks_settings_updated'), + subscribed: true, }) } @@ -316,16 +316,16 @@ async function unsubscribe(req, res, next) { const userId = SessionManager.getLoggedInUserId(req.session) req.logger.addFields({ userId }) - const user = await UserGetter.promises.getUser(userId, { - _id: 1, - email: 1, - first_name: 1, - last_name: 1, - }) - await NewsletterManager.promises.unsubscribe(user) - await Modules.promises.hooks.fire('newsletterUnsubscribed', user) + await Modules.promises.hooks.fire( + 'updateTopicSubscription', + userId, + 'newsletter', + false + ) + res.json({ message: req.i18n.translate('thanks_settings_updated'), + subscribed: false, }) } diff --git a/services/web/app/src/Features/User/UserDeleter.mjs b/services/web/app/src/Features/User/UserDeleter.mjs index d2a47e745c..3ba04c7ed6 100644 --- a/services/web/app/src/Features/User/UserDeleter.mjs +++ b/services/web/app/src/Features/User/UserDeleter.mjs @@ -4,7 +4,6 @@ import Settings from '@overleaf/settings' import { User } from '../../models/User.mjs' import { DeletedUser } from '../../models/DeletedUser.mjs' import { UserAuditLogEntry } from '../../models/UserAuditLogEntry.mjs' -import NewsletterManager from '../Newsletter/NewsletterManager.mjs' import ProjectDeleter from '../Project/ProjectDeleter.mjs' import SubscriptionHandler from '../Subscription/SubscriptionHandler.mjs' import SubscriptionUpdater from '../Subscription/SubscriptionUpdater.mjs' @@ -212,8 +211,6 @@ async function _cleanupUser(user) { logger.info({ userId }, '[cleanupUser] removing user sessions from Redis') await UserSessionsManager.promises.removeSessionsFromRedis(user) if (Features.hasFeature('saas')) { - logger.info({ userId }, '[cleanupUser] unsubscribing from newsletters') - await NewsletterManager.promises.unsubscribe(user, { delete: true }) logger.info({ userId }, '[cleanupUser] cancelling subscription') await SubscriptionHandler.promises.cancelSubscription(user) logger.info({ userId }, '[cleanupUser] deleting affiliations') diff --git a/services/web/app/src/Features/User/UserPagesController.mjs b/services/web/app/src/Features/User/UserPagesController.mjs index e25c1ca8f8..16774e307d 100644 --- a/services/web/app/src/Features/User/UserPagesController.mjs +++ b/services/web/app/src/Features/User/UserPagesController.mjs @@ -5,8 +5,8 @@ import logger from '@overleaf/logger' import Settings from '@overleaf/settings' import AuthenticationController from '../Authentication/AuthenticationController.mjs' import SessionManager from '../Authentication/SessionManager.mjs' -import NewsletterManager from '../Newsletter/NewsletterManager.mjs' import SubscriptionLocator from '../Subscription/SubscriptionLocator.mjs' +import UserAnalyticsIdCache from '../Analytics/UserAnalyticsIdCache.mjs' import _ from 'lodash' import { expressify } from '@overleaf/promise-utils' import Features from '../../infrastructure/Features.mjs' @@ -199,6 +199,39 @@ async function reconfirmAccountPage(req, res) { res.render('user/reconfirm', pageData) } +async function emailPreferencesPage(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const user = await UserGetter.promises.getUser(userId, { + _id: 1, + email: 1, + first_name: 1, + last_name: 1, + }) + + if (!user) { + throw new Error('User not found') + } + + let subscribed = false + + const analyticsId = await UserAnalyticsIdCache.get(userId) + if (analyticsId) { + const [preferences] = await Modules.promises.hooks.fire( + 'getSubscriptionPreferences', + analyticsId + ) + + subscribed = Boolean(preferences?.newsletter) + } + + res.render('user/email-preferences', { + title: 'newsletter_info_title', + customerIoEnabled: true, + subscribed, + user, + }) +} + const UserPagesController = { accountSuspended: expressify(accountSuspended), @@ -279,28 +312,7 @@ const UserPagesController = { ) }, - emailPreferencesPage(req, res, next) { - const userId = SessionManager.getLoggedInUserId(req.session) - UserGetter.getUser( - userId, - { _id: 1, email: 1, first_name: 1, last_name: 1 }, - (err, user) => { - if (err != null) { - return next(err) - } - NewsletterManager.subscribed(user, (err, subscribed) => { - if (err != null) { - OError.tag(err, 'error getting newsletter subscription status') - return next(err) - } - res.render('user/email-preferences', { - title: 'newsletter_info_title', - subscribed, - }) - }) - } - ) - }, + emailPreferencesPage: expressify(emailPreferencesPage), async compromisedPasswordPage(req, res) { res.render('user/compromised_password') diff --git a/services/web/app/src/Features/User/UserRegistrationHandler.mjs b/services/web/app/src/Features/User/UserRegistrationHandler.mjs index 5e77955da2..ab78de2bf7 100644 --- a/services/web/app/src/Features/User/UserRegistrationHandler.mjs +++ b/services/web/app/src/Features/User/UserRegistrationHandler.mjs @@ -2,7 +2,7 @@ import { User } from '../../models/User.mjs' import UserCreator from './UserCreator.mjs' import UserGetter from './UserGetter.mjs' import AuthenticationManager from '../Authentication/AuthenticationManager.mjs' -import NewsletterManager from '../Newsletter/NewsletterManager.mjs' +import Modules from '../../infrastructure/Modules.mjs' import logger from '@overleaf/logger' import crypto from 'node:crypto' import EmailHandler from '../Email/EmailHandler.mjs' @@ -72,7 +72,12 @@ const UserRegistrationHandler = { if (userDetails.subscribeToNewsletter === 'true') { try { - NewsletterManager.subscribe(user) + await Modules.promises.hooks.fire( + 'updateTopicSubscription', + user._id, + 'newsletter', + true + ) } catch (error) { logger.warn( { err: error, user }, diff --git a/services/web/app/src/Features/User/UserUpdater.mjs b/services/web/app/src/Features/User/UserUpdater.mjs index d27aff6ffe..c23ae8793c 100644 --- a/services/web/app/src/Features/User/UserUpdater.mjs +++ b/services/web/app/src/Features/User/UserUpdater.mjs @@ -10,7 +10,6 @@ import FeaturesUpdater from '../Subscription/FeaturesUpdater.mjs' import EmailHandler from '../Email/EmailHandler.mjs' import EmailHelper from '../Helpers/EmailHelper.mjs' import Errors from '../Errors/Errors.js' -import NewsletterManager from '../Newsletter/NewsletterManager.mjs' import UserAuditLogHandler from './UserAuditLogHandler.mjs' import AnalyticsManager from '../Analytics/AnalyticsManager.mjs' import EmailChangeHelper from '../Analytics/EmailChangeHelper.mjs' @@ -295,14 +294,6 @@ async function setDefaultEmailAddress( }) } - try { - await NewsletterManager.promises.changeEmail(user, email) - } catch (error) { - logger.warn( - { err: error, oldEmail, newEmail: email }, - 'Failed to change email in newsletter subscription' - ) - } try { await Modules.promises.hooks.fire('userEmailChanged', user, email) } catch (err) { diff --git a/services/web/app/views/_customer_io.pug b/services/web/app/views/_customer_io.pug index 2b17d93fb3..e9506bbac1 100644 --- a/services/web/app/views/_customer_io.pug +++ b/services/web/app/views/_customer_io.pug @@ -3,7 +3,7 @@ if(customerIoEnabled && ExposedSettings.cioWriteKey && ExposedSettings.cioSiteId function boolAttr(value) { return value !== undefined ? String(value) : null; } - script(type="text/javascript", id="cio-loader", nonce=scriptNonce, data-best-subscription=(usersBestSubscription && usersBestSubscription.type), data-ai-blocked=boolAttr(aiBlocked), data-has-ai-assist=boolAttr(hasAiAssist), data-cio-write-key=ExposedSettings.cioWriteKey, data-cio-site-id=ExposedSettings.cioSiteId, data-session-analytics-id=getSessionAnalyticsId(), data-user-id=getLoggedInUserId(), data-last-active=lastActive, data-sign-up-date=signUpDate, data-subject-area=subjectArea, data-role=role, data-used-latex=usedLatex, data-primary-occupation=primaryOccupation, data-country=countryCode, data-commons-institution=commonsInstitution, data-group-role=groupRole, data-features=user.features, data-is-managed-user=boolAttr(isManagedUser)). + script(type="text/javascript", id="cio-loader", nonce=scriptNonce, data-best-subscription=(usersBestSubscription && usersBestSubscription.type), data-ai-blocked=boolAttr(aiBlocked), data-has-ai-assist=boolAttr(hasAiAssist), data-cio-write-key=ExposedSettings.cioWriteKey, data-cio-site-id=ExposedSettings.cioSiteId, data-session-analytics-id=getSessionAnalyticsId(), data-user-id=getLoggedInUserId(), data-last-active=lastActive, data-sign-up-date=signUpDate, data-subject-area=subjectArea, data-role=role, data-used-latex=usedLatex, data-primary-occupation=primaryOccupation, data-country=countryCode, data-commons-institution=commonsInstitution, data-group-role=groupRole, data-features=user.features, data-email=user.email, data-is-managed-user=boolAttr(isManagedUser)). function parseBool(value) { return value === 'true' ? true : value === 'false' ? false : undefined; @@ -24,6 +24,7 @@ if(customerIoEnabled && ExposedSettings.cioWriteKey && ExposedSettings.cioSiteId var primaryOccupation = cioSettings.primaryOccupation; var usedLatex = cioSettings.usedLatex; var features = cioSettings.features; + var email = cioSettings.email; var countryCode = cioSettings.country; var commonsInstitution = cioSettings.commonsInstitution; @@ -65,6 +66,7 @@ if(customerIoEnabled && ExposedSettings.cioWriteKey && ExposedSettings.cioSiteId addIfDefined(identifyData, 'groupRole', groupRole); addIfDefined(identifyData, 'isManagedUser', isManagedUser); addIfDefined(identifyData, 'features', features); + addIfDefined(identifyData, 'email', email); analytics.identify(analyticsId, identifyData); }}(); diff --git a/services/web/app/views/user/email-preferences.pug b/services/web/app/views/user/email-preferences.pug index f0213bb8da..b799067434 100644 --- a/services/web/app/views/user/email-preferences.pug +++ b/services/web/app/views/user/email-preferences.pug @@ -1,48 +1,16 @@ extends ../layout-website-redesign include ../_mixins/back_to_btns +block entrypointVar + - entrypoint = 'pages/user/email-preferences' + +block append meta + meta(name='ol-newsletter-subscribed' data-type='boolean' content=subscribed) + meta(name='ol-user' data-type='json' content=user) + block vars - isWebsiteRedesign = true block content - main#main-content.content - .container - .row - .col-lg-10.offset-lg-1.col-xl-8.offset-xl-2 - .page-header - h1 #{translate("newsletter_info_title")} - - p #{translate("newsletter_info_summary")} - - - var submitAction - if subscribed - - submitAction = '/user/newsletter/unsubscribe' - p !{translate("newsletter_info_subscribed", {}, ['strong'])} - else - - submitAction = '/user/newsletter/subscribe' - p !{translate("newsletter_info_unsubscribed", {}, ['strong'])} - - form( - name='newsletterForm' - data-ol-async-form - data-ol-reload-on-success - action=submitAction - method='POST' - ) - input(name='_csrf' type='hidden' value=csrfToken) - +formMessages - p.actions.text-center - if subscribed - button.btn-danger.btn(type='submit' data-ol-disabled-inflight) - span(data-ol-inflight='idle') #{translate("unsubscribe")} - span(hidden data-ol-inflight='pending') #{translate("saving")}… - else - button.btn-primary.btn(type='submit' data-ol-disabled-inflight) - span(data-ol-inflight='idle') #{translate("subscribe")} - span(hidden data-ol-inflight='pending') #{translate("saving")}… - - if subscribed - p #{translate("newsletter_info_note")} - - .page-separator - +back-to-btns + main#main-content.content.content-alt + #email-preferences-root diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index c9b9001337..cc25083b9f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1158,6 +1158,10 @@ "new_tag_name": "", "newsletter": "", "newsletter_info_note": "", + "newsletter_info_subscribed": "", + "newsletter_info_summary": "", + "newsletter_info_title": "", + "newsletter_info_unsubscribed": "", "newsletter_onboarding_accept": "", "next": "", "next_page": "", diff --git a/services/web/frontend/js/features/settings/components/email-preferences/email-preferences-form.tsx b/services/web/frontend/js/features/settings/components/email-preferences/email-preferences-form.tsx new file mode 100644 index 0000000000..039a354913 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/email-preferences/email-preferences-form.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react' +import { useTranslation, Trans } from 'react-i18next' +import { postJSON, getUserFacingMessage } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import OLButton from '@/shared/components/ol/ol-button' +import OLNotification from '@/shared/components/ol/ol-notification' +import getMeta from '@/utils/meta' + +function EmailPreferencesForm() { + const { t } = useTranslation() + const initialSubscribed = getMeta('ol-newsletter-subscribed') + const [subscribed, setSubscribed] = useState(initialSubscribed) + const { isLoading, isSuccess, isError, error, runAsync } = useAsync() + + const handleToggleSubscription = () => { + const endpoint = subscribed + ? '/user/newsletter/unsubscribe' + : '/user/newsletter/subscribe' + + runAsync(postJSON<{ subscribed: boolean }>(endpoint)) + .then(response => setSubscribed(response.subscribed)) + .catch(() => {}) + } + + return ( + <> + {isError && ( + + )} + {isSuccess && ( + + )} + +

+ {subscribed ? ( + }} + /> + ) : ( + }} + /> + )} +

+ +

+ {subscribed ? ( + + {t('unsubscribe')} + + ) : ( + + {t('subscribe')} + + )} +

+ + {subscribed &&

{t('newsletter_info_note')}

} + + ) +} + +export default EmailPreferencesForm diff --git a/services/web/frontend/js/features/settings/components/email-preferences/root.tsx b/services/web/frontend/js/features/settings/components/email-preferences/root.tsx new file mode 100644 index 0000000000..fa222c2a38 --- /dev/null +++ b/services/web/frontend/js/features/settings/components/email-preferences/root.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next' +import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLPageContentCard from '@/shared/components/ol/ol-page-content-card' +import EmailPreferencesForm from './email-preferences-form' + +function EmailPreferencesRoot() { + const { isReady } = useWaitForI18n() + + return ( +
+ + + {isReady ? : null} + + +
+ ) +} + +function EmailPreferencesContent() { + const { t } = useTranslation() + + return ( + +
+

{t('newsletter_info_title')}

+
+

{t('newsletter_info_summary')}

+ +
+ ) +} + +export default EmailPreferencesRoot diff --git a/services/web/frontend/js/pages/user/email-preferences.tsx b/services/web/frontend/js/pages/user/email-preferences.tsx new file mode 100644 index 0000000000..8f7d3db0c6 --- /dev/null +++ b/services/web/frontend/js/pages/user/email-preferences.tsx @@ -0,0 +1,13 @@ +import { renderInReactLayout } from '@/react' +import '@/utils/meta' +import '@/utils/webpack-public-path' +import '@/infrastructure/error-reporter' +import '@/i18n' +import EmailPreferencesRoot from '@/features/settings/components/email-preferences/root' +import { UserProvider } from '@/shared/context/user-context' + +renderInReactLayout('email-preferences-root', () => ( + + + +)) diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index cc1f523de9..533f3c1733 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -207,6 +207,7 @@ export interface Meta { 'ol-memberOfSSOEnabledGroups': GroupSSOLinkingStatus[] 'ol-members': MinimalUser[] 'ol-navbar': DefaultNavbarMetadata + 'ol-newsletter-subscribed': boolean 'ol-no-single-dollar': boolean 'ol-notifications': NotificationType[] 'ol-notificationsInstitution': InstitutionType[] diff --git a/services/web/package.json b/services/web/package.json index c869b314f3..a7afbe4896 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -85,6 +85,7 @@ "@aws-sdk/client-ses": "^3.864.0", "@contentful/rich-text-html-renderer": "^16.0.2", "@contentful/rich-text-types": "^16.0.2", + "@customerio/cdp-analytics-node": "^0.3.9", "@google-cloud/bigquery": "^6.0.1", "@google-cloud/storage": "^6.10.1", "@node-oauth/oauth2-server": "^5.1.0", @@ -133,10 +134,10 @@ "ejs": "^3.1.10", "email-addresses": "^5.0.0", "eventsource-parser": "^1.1.2", + "express": "4.22.1", "express-bearer-token": "^2.4.0", "express-http-proxy": "^1.6.0", "express-session": "^1.17.1", - "express": "4.22.1", "file-type": "^21.0.0", "focus-trap-react": "^11.0.4", "globby": "^5.0.0", @@ -198,7 +199,6 @@ "zod-validation-error": "^4.0.1" }, "devDependencies": { - "5to6-codemod": "^1.8.0", "@ai-sdk/react": "^3.0.2", "@babel/core": "^7.28.5", "@babel/plugin-proposal-decorators": "^7.28.0", @@ -284,6 +284,7 @@ "@writefull/core": "^1.27.27", "@writefull/ui": "^1.27.27", "@writefull/utils": "^1.27.27", + "5to6-codemod": "^1.8.0", "abort-controller": "^3.0.0", "acorn": "^7.1.1", "acorn-walk": "^7.1.1", diff --git a/services/web/test/frontend/features/settings/components/email-preferences/email-preferences-form.test.tsx b/services/web/test/frontend/features/settings/components/email-preferences/email-preferences-form.test.tsx new file mode 100644 index 0000000000..9a11abe6db --- /dev/null +++ b/services/web/test/frontend/features/settings/components/email-preferences/email-preferences-form.test.tsx @@ -0,0 +1,147 @@ +import { expect } from 'chai' +import { fireEvent, screen, render } from '@testing-library/react' +import fetchMock from 'fetch-mock' +import EmailPreferencesForm from '../../../../../../frontend/js/features/settings/components/email-preferences/email-preferences-form' + +describe('', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-newsletter-subscribed', true) + }) + + afterEach(function () { + fetchMock.removeRoutes().clearHistory() + }) + + it('shows subscribed state with unsubscribe button', function () { + render() + + screen.getByText('You are', { exact: false }) + screen.getByRole('button', { name: 'Unsubscribe' }) + screen.getByText('Please note: you will still receive important emails', { + exact: false, + }) + }) + + it('shows unsubscribed state with subscribe button', function () { + window.metaAttributesCache.set('ol-newsletter-subscribed', false) + render() + + screen.getByText('You are', { exact: false }) + screen.getByRole('button', { name: 'Subscribe' }) + expect( + screen.queryByText( + 'Please note: you will still receive important emails', + { exact: false } + ) + ).to.not.exist + }) + + it('calls unsubscribe endpoint when clicking unsubscribe', async function () { + const unsubscribeMock = fetchMock.post('/user/newsletter/unsubscribe', { + status: 200, + body: { subscribed: false }, + }) + render() + + const button = screen.getByRole('button', { name: 'Unsubscribe' }) + fireEvent.click(button) + + expect(unsubscribeMock.callHistory.called()).to.be.true + expect(unsubscribeMock.callHistory.calls().at(-1)?.url).to.equal( + 'https://www.test-overleaf.com/user/newsletter/unsubscribe' + ) + }) + + it('calls subscribe endpoint when clicking subscribe', async function () { + window.metaAttributesCache.set('ol-newsletter-subscribed', false) + const subscribeMock = fetchMock.post('/user/newsletter/subscribe', { + status: 200, + body: { subscribed: true }, + }) + render() + + const button = screen.getByRole('button', { name: 'Subscribe' }) + fireEvent.click(button) + + expect(subscribeMock.callHistory.called()).to.be.true + expect(subscribeMock.callHistory.calls().at(-1)?.url).to.equal( + 'https://www.test-overleaf.com/user/newsletter/subscribe' + ) + }) + + it('shows loading state while request is in flight', async function () { + let finishRequest: (value: any) => void = () => {} + fetchMock.post( + '/user/newsletter/unsubscribe', + new Promise(resolve => (finishRequest = resolve)) + ) + render() + + const button = screen.getByRole('button', { name: 'Unsubscribe' }) + fireEvent.click(button) + + await screen.findByRole('button', { name: 'Saving…' }) + + finishRequest({ status: 200, body: { subscribed: false } }) + await screen.findByRole('button', { name: 'Subscribe' }) + }) + + it('shows success notification after successful action', async function () { + fetchMock.post('/user/newsletter/unsubscribe', { + status: 200, + body: { subscribed: false }, + }) + render() + + const button = screen.getByRole('button', { name: 'Unsubscribe' }) + fireEvent.click(button) + + await screen.findByText('Thanks, your settings have been updated.') + }) + + it('shows error notification on server error', async function () { + fetchMock.post('/user/newsletter/unsubscribe', 500) + render() + + const button = screen.getByRole('button', { name: 'Unsubscribe' }) + fireEvent.click(button) + + await screen.findByText('Something went wrong. Please try again.') + }) + + it('shows error notification with message on 4xx error', async function () { + fetchMock.post('/user/newsletter/unsubscribe', { + status: 400, + body: { message: 'Unable to update preferences' }, + }) + render() + + const button = screen.getByRole('button', { name: 'Unsubscribe' }) + fireEvent.click(button) + + await screen.findByText('Unable to update preferences') + }) + + it('toggles between subscribed and unsubscribed states', async function () { + fetchMock.post('/user/newsletter/unsubscribe', { + status: 200, + body: { subscribed: false }, + }) + fetchMock.post('/user/newsletter/subscribe', { + status: 200, + body: { subscribed: true }, + }) + render() + + // Initially subscribed + screen.getByRole('button', { name: 'Unsubscribe' }) + + // Unsubscribe + fireEvent.click(screen.getByRole('button', { name: 'Unsubscribe' })) + await screen.findByRole('button', { name: 'Subscribe' }) + + // Subscribe again + fireEvent.click(screen.getByRole('button', { name: 'Subscribe' })) + await screen.findByRole('button', { name: 'Unsubscribe' }) + }) +}) diff --git a/services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs b/services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs deleted file mode 100644 index e3800ad214..0000000000 --- a/services/web/test/unit/src/Newsletter/NewsletterManager.test.mjs +++ /dev/null @@ -1,215 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import sinon from 'sinon' -import { RequestFailedError } from '@overleaf/fetch-utils' - -const MODULE_PATH = '../../../../app/src/Features/Newsletter/NewsletterManager' - -describe('NewsletterManager', function () { - beforeEach(async function (ctx) { - ctx.Settings = { - mailchimp: { - api_key: 'api_key', - list_id: 'list_id', - }, - } - ctx.mailchimp = { - get: sinon.stub(), - put: sinon.stub(), - patch: sinon.stub(), - delete: sinon.stub(), - } - ctx.Mailchimp = sinon.stub().returns(ctx.mailchimp) - - ctx.mergeFields = { - FNAME: 'Overleaf', - LNAME: 'Duck', - MONGO_ID: 'user_id', - } - - vi.doMock( - '../../../../app/src/Features/Newsletter/MailChimpClient', - () => ({ - default: ctx.Mailchimp, - }) - ) - - vi.doMock('@overleaf/settings', () => ({ default: ctx.Settings })) - - ctx.NewsletterManager = (await import(MODULE_PATH)).default.promises - - ctx.NewsletterManager.get = sinon.stub() - ctx.NewsletterManager.delete = sinon.stub() - - ctx.user = { - _id: 'user_id', - email: 'overleaf.duck@example.com', - first_name: 'Overleaf', - last_name: 'Duck', - } - // MD5 sum of the user email - ctx.emailHash = 'c02f60ed0ef51818186274e406c9a48f' - }) - - describe('subscribed', function () { - it('calls Mailchimp to get the user status', async function (ctx) { - await ctx.NewsletterManager.subscribed(ctx.user) - expect(ctx.mailchimp.get).to.have.been.calledWith( - `/lists/list_id/members/${ctx.emailHash}` - ) - }) - - it('returns true when subscribed', async function (ctx) { - ctx.mailchimp.get.resolves({ status: 'subscribed' }) - - const subscribed = await ctx.NewsletterManager.subscribed(ctx.user) - expect(subscribed).to.be.true - }) - - it('returns false on 404', async function (ctx) { - ctx.mailchimp.get.rejects( - new RequestFailedError( - 'http://some-url', - {}, - { status: 404 }, - 'Not found' - ) - ) - const subscribed = await ctx.NewsletterManager.subscribed(ctx.user) - expect(subscribed).to.be.false - }) - }) - - describe('subscribe', function () { - it('calls Mailchimp to subscribe the user', async function (ctx) { - await ctx.NewsletterManager.subscribe(ctx.user) - expect(ctx.mailchimp.put).to.have.been.calledWith( - `/lists/list_id/members/${ctx.emailHash}`, - { - email_address: ctx.user.email, - status: 'subscribed', - status_if_new: 'subscribed', - merge_fields: ctx.mergeFields, - } - ) - }) - }) - - describe('unsubscribe', function () { - describe('when unsubscribing normally', function () { - it('calls Mailchimp to unsubscribe the user', async function (ctx) { - await ctx.NewsletterManager.unsubscribe(ctx.user) - expect(ctx.mailchimp.patch).to.have.been.calledWith( - `/lists/list_id/members/${ctx.emailHash}`, - { - status: 'unsubscribed', - merge_fields: ctx.mergeFields, - } - ) - }) - - it('ignores a Mailchimp error about fake emails', async function (ctx) { - ctx.mailchimp.patch.rejects( - new Error( - 'overleaf.duck@example.com looks fake or invalid, please enter a real email address' - ) - ) - await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be - .fulfilled - }) - - it('rejects on other errors', async function (ctx) { - ctx.mailchimp.patch.rejects( - new Error('something really wrong is happening') - ) - await expect(ctx.NewsletterManager.unsubscribe(ctx.user)).to.be.rejected - }) - }) - - describe('when deleting', function () { - it('calls Mailchimp to delete the user', async function (ctx) { - await ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true }) - expect(ctx.mailchimp.delete).to.have.been.calledWith( - `/lists/list_id/members/${ctx.emailHash}` - ) - }) - - it('ignores a Mailchimp error about fake emails', async function (ctx) { - ctx.mailchimp.delete.rejects( - new Error( - 'overleaf.duck@example.com looks fake or invalid, please enter a real email address' - ) - ) - await expect( - ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true }) - ).to.be.fulfilled - }) - - it('rejects on other errors', async function (ctx) { - ctx.mailchimp.delete.rejects( - new Error('something really wrong is happening') - ) - await expect( - ctx.NewsletterManager.unsubscribe(ctx.user, { delete: true }) - ).to.be.rejected - }) - }) - }) - - describe('changeEmail', function () { - it('calls Mailchimp to change the subscriber email', async function (ctx) { - await ctx.NewsletterManager.changeEmail( - ctx.user, - 'overleaf.squirrel@example.com' - ) - expect(ctx.mailchimp.patch).to.have.been.calledWith( - `/lists/list_id/members/${ctx.emailHash}`, - { - email_address: 'overleaf.squirrel@example.com', - merge_fields: ctx.mergeFields, - } - ) - }) - - it('deletes the old email if changing the address fails', async function (ctx) { - ctx.mailchimp.patch - .withArgs(`/lists/list_id/members/${ctx.emailHash}`, { - email_address: 'overleaf.squirrel@example.com', - merge_fields: ctx.mergeFields, - }) - .rejects(new Error('that did not work')) - - await expect( - ctx.NewsletterManager.changeEmail( - ctx.user, - 'overleaf.squirrel@example.com' - ) - ).to.be.rejected - - expect(ctx.mailchimp.delete).to.have.been.calledWith( - `/lists/list_id/members/${ctx.emailHash}` - ) - }) - - it('does not reject on non-fatal error ', async function (ctx) { - const nonFatalError = new Error('merge fields were invalid') - ctx.mailchimp.patch.rejects(nonFatalError) - await expect( - ctx.NewsletterManager.changeEmail( - ctx.user, - 'overleaf.squirrel@example.com' - ) - ).to.be.fulfilled - }) - - it('rejects on any other error', async function (ctx) { - const fatalError = new Error('fatal error') - ctx.mailchimp.patch.rejects(fatalError) - await expect( - ctx.NewsletterManager.changeEmail( - ctx.user, - 'overleaf.squirrel@example.com' - ) - ).to.be.rejected - }) - }) -}) diff --git a/services/web/test/unit/src/User/UserController.test.mjs b/services/web/test/unit/src/User/UserController.test.mjs index 23b17b645a..cfc0e5849c 100644 --- a/services/web/test/unit/src/User/UserController.test.mjs +++ b/services/web/test/unit/src/User/UserController.test.mjs @@ -52,11 +52,8 @@ describe('UserController', function () { findById: sinon.stub().returns({ exec: sinon.stub().resolves(ctx.user) }), } - ctx.NewsLetterManager = { - promises: { - subscribe: sinon.stub().resolves(), - unsubscribe: sinon.stub().resolves(), - }, + ctx.AnalyticsManager = { + recordEventForUserInBackground: sinon.stub(), } ctx.SessionManager = { @@ -143,6 +140,13 @@ describe('UserController', function () { }, } + vi.doMock( + '../../../../app/src/Features/Analytics/AnalyticsManager', + () => ({ + default: ctx.AnalyticsManager, + }) + ) + vi.doMock('../../../../app/src/Features/Helpers/UrlHelper', () => ({ default: ctx.UrlHelper, })) @@ -163,13 +167,6 @@ describe('UserController', function () { User: ctx.User, })) - vi.doMock( - '../../../../app/src/Features/Newsletter/NewsletterManager', - () => ({ - default: ctx.NewsLetterManager, - }) - ) - vi.doMock( '../../../../app/src/Features/Authentication/AuthenticationController', () => ({ @@ -429,36 +426,6 @@ describe('UserController', function () { }) }) - describe('subscribe', function () { - it('should send the user to subscribe', function (ctx) { - return new Promise(resolve => { - ctx.res.json = data => { - expect(data.message).to.equal('thanks_settings_updated') - ctx.NewsLetterManager.promises.subscribe.should.have.been.calledWith( - ctx.user - ) - resolve() - } - ctx.UserController.subscribe(ctx.req, ctx.res) - }) - }) - }) - - describe('unsubscribe', function () { - it('should send the user to unsubscribe', function (ctx) { - return new Promise(resolve => { - ctx.res.json = data => { - expect(data.message).to.equal('thanks_settings_updated') - ctx.NewsLetterManager.promises.unsubscribe.should.have.been.calledWith( - ctx.user - ) - resolve() - } - ctx.UserController.unsubscribe(ctx.req, ctx.res, ctx.next) - }) - }) - }) - describe('updateUserSettings', function () { beforeEach(function (ctx) { ctx.auditLog = { initiatorId: ctx.user_id, ipAddress: ctx.req.ip } diff --git a/services/web/test/unit/src/User/UserDeleter.test.mjs b/services/web/test/unit/src/User/UserDeleter.test.mjs index efda58c232..68f5e207eb 100644 --- a/services/web/test/unit/src/User/UserDeleter.test.mjs +++ b/services/web/test/unit/src/User/UserDeleter.test.mjs @@ -42,12 +42,6 @@ describe('UserDeleter', function () { ) ctx.user = ctx.mockedUser.object - ctx.NewsletterManager = { - promises: { - unsubscribe: sinon.stub().resolves(), - }, - } - ctx.ProjectDeleter = { promises: { deleteUsersProjects: sinon.stub().resolves(), @@ -123,13 +117,6 @@ describe('UserDeleter', function () { DeletedUser, })) - vi.doMock( - '../../../../app/src/Features/Newsletter/NewsletterManager', - () => ({ - default: ctx.NewsletterManager, - }) - ) - vi.doMock('../../../../app/src/Features/User/UserSessionsManager', () => ({ default: ctx.UserSessionsManager, })) @@ -261,15 +248,6 @@ describe('UserDeleter', function () { ctx.UserMock.verify() }) - it('should delete the user from mailchimp', async function (ctx) { - await ctx.UserDeleter.promises.deleteUser(ctx.userId, { - ipAddress: ctx.ipAddress, - }) - expect( - ctx.NewsletterManager.promises.unsubscribe - ).to.have.been.calledWith(ctx.user, { delete: true }) - }) - it('should delete all the projects of a user', async function (ctx) { await ctx.UserDeleter.promises.deleteUser(ctx.userId, { ipAddress: ctx.ipAddress, @@ -426,23 +404,6 @@ describe('UserDeleter', function () { }) }) - describe('when unsubscribing from mailchimp fails', function () { - beforeEach(function (ctx) { - ctx.NewsletterManager.promises.unsubscribe.rejects( - new Error('something went wrong') - ) - }) - - it('should return an error and not delete the user', async function (ctx) { - await expect( - ctx.UserDeleter.promises.deleteUser(ctx.userId, { - ipAddress: ctx.ipAddress, - }) - ).to.be.rejected - ctx.UserMock.verify() - }) - }) - describe('when called as a callback', function () { beforeEach(function (ctx) { ctx.UserMock.expects('deleteOne') diff --git a/services/web/test/unit/src/User/UserPagesController.test.mjs b/services/web/test/unit/src/User/UserPagesController.test.mjs index 8fca549554..df22e25bc8 100644 --- a/services/web/test/unit/src/User/UserPagesController.test.mjs +++ b/services/web/test/unit/src/User/UserPagesController.test.mjs @@ -51,9 +51,6 @@ describe('UserPagesController', function () { getLoggedInUserId: sinon.stub().returns(ctx.user._id), getSessionUser: sinon.stub().returns(ctx.user), } - ctx.NewsletterManager = { - subscribed: sinon.stub().yields(), - } ctx.AuthenticationController = { getRedirectFromSession: sinon.stub(), setRedirectInSession: sinon.stub(), @@ -95,13 +92,6 @@ describe('UserPagesController', function () { default: ctx.UserSessionsManager, })) - vi.doMock( - '../../../../app/src/Features/Newsletter/NewsletterManager', - () => ({ - default: ctx.NewsletterManager, - }) - ) - vi.doMock('../../../../app/src/Features/Errors/ErrorController', () => ({ default: ctx.ErrorController, })) @@ -350,46 +340,6 @@ describe('UserPagesController', function () { }) }) - describe('emailPreferencesPage', function () { - beforeEach(function (ctx) { - ctx.UserGetter.getUser = sinon.stub().yields(null, ctx.user) - }) - - it('render page with subscribed status', async function (ctx) { - ctx.NewsletterManager.subscribed.yields(null, true) - await new Promise((resolve, reject) => { - ctx.res.callback = () => { - ctx.res.renderedTemplate.should.equal('user/email-preferences') - ctx.res.renderedVariables.title.should.equal('newsletter_info_title') - ctx.res.renderedVariables.subscribed.should.equal(true) - resolve() - } - ctx.UserPagesController.emailPreferencesPage( - ctx.req, - ctx.res, - ctx.rejectOnError(reject) - ) - }) - }) - - it('render page with unsubscribed status', async function (ctx) { - ctx.NewsletterManager.subscribed.yields(null, false) - await new Promise((resolve, reject) => { - ctx.res.callback = () => { - ctx.res.renderedTemplate.should.equal('user/email-preferences') - ctx.res.renderedVariables.title.should.equal('newsletter_info_title') - ctx.res.renderedVariables.subscribed.should.equal(false) - resolve() - } - ctx.UserPagesController.emailPreferencesPage( - ctx.req, - ctx.res, - ctx.rejectOnError(reject) - ) - }) - }) - }) - describe('settingsPage', function () { beforeEach(function (ctx) { ctx.request.get = sinon diff --git a/services/web/test/unit/src/User/UserRegistrationHandler.test.mjs b/services/web/test/unit/src/User/UserRegistrationHandler.test.mjs index 733e12a121..563caa2eb6 100644 --- a/services/web/test/unit/src/User/UserRegistrationHandler.test.mjs +++ b/services/web/test/unit/src/User/UserRegistrationHandler.test.mjs @@ -32,8 +32,12 @@ describe('UserRegistrationHandler', function () { setUserPassword: sinon.stub().resolves(ctx.user), }, } - ctx.NewsLetterManager = { - subscribe: sinon.stub(), + ctx.Modules = { + promises: { + hooks: { + fire: sinon.stub().resolves([]), + }, + }, } ctx.EmailHandler = { promises: { sendEmail: sinon.stub().resolves() }, @@ -59,12 +63,9 @@ describe('UserRegistrationHandler', function () { }) ) - vi.doMock( - '../../../../app/src/Features/Newsletter/NewsletterManager', - () => ({ - default: ctx.NewsLetterManager, - }) - ) + vi.doMock('../../../../app/src/infrastructure/Modules', () => ({ + default: ctx.Modules, + })) vi.doMock('crypto', () => ({ default: (ctx.crypto = {}), @@ -238,12 +239,22 @@ describe('UserRegistrationHandler', function () { it('should add the user to the newsletter if accepted terms', async function (ctx) { ctx.passingRequest.subscribeToNewsletter = 'true' await ctx.handler.promises.registerNewUser(ctx.passingRequest) - ctx.NewsLetterManager.subscribe.calledWith(ctx.user).should.equal(true) + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'updateTopicSubscription', + ctx.user._id, + 'newsletter', + true + ) }) it('should not add the user to the newsletter if not accepted terms', async function (ctx) { await ctx.handler.promises.registerNewUser(ctx.passingRequest) - ctx.NewsLetterManager.subscribe.calledWith(ctx.user).should.equal(false) + expect(ctx.Modules.promises.hooks.fire).to.not.have.been.calledWith( + 'updateTopicSubscription', + sinon.match.any, + sinon.match.any, + true + ) }) }) }) diff --git a/services/web/test/unit/src/User/UserUpdater.test.mjs b/services/web/test/unit/src/User/UserUpdater.test.mjs index be53b84435..322ed30a62 100644 --- a/services/web/test/unit/src/User/UserUpdater.test.mjs +++ b/services/web/test/unit/src/User/UserUpdater.test.mjs @@ -60,11 +60,6 @@ describe('UserUpdater', function () { .withArgs(ctx.user._id) .resolves(ctx.user.email) - ctx.NewsletterManager = { - promises: { - changeEmail: sinon.stub().resolves(), - }, - } ctx.AnalyticsManager = { recordEventForUserInBackground: sinon.stub(), } @@ -162,13 +157,6 @@ describe('UserUpdater', function () { default: (ctx.settings = {}), })) - vi.doMock( - '../../../../app/src/Features/Newsletter/NewsletterManager', - () => ({ - default: ctx.NewsletterManager, - }) - ) - vi.doMock( '../../../../app/src/Features/Subscription/RecurlyWrapper', () => ({ @@ -349,10 +337,12 @@ describe('UserUpdater', function () { ) }) - it('sets the new email in the newsletter', function (ctx) { - expect( - ctx.NewsletterManager.promises.changeEmail - ).to.have.been.calledWith(ctx.user, ctx.newEmail) + it('fires userEmailChanged hook', function (ctx) { + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'userEmailChanged', + ctx.user, + ctx.newEmail + ) expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'updateAccountEmailAddress', ctx.user._id, @@ -715,16 +705,18 @@ describe('UserUpdater', function () { ) }) - it('sets the changed email in the newsletter', async function (ctx) { + it('fires userEmailChanged hook', async function (ctx) { await ctx.UserUpdater.promises.setDefaultEmailAddress( ctx.user._id, ctx.newEmail, false, ctx.auditLog ) - expect( - ctx.NewsletterManager.promises.changeEmail - ).to.have.been.calledWith(ctx.user, ctx.newEmail) + expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( + 'userEmailChanged', + ctx.user, + ctx.newEmail + ) expect(ctx.Modules.promises.hooks.fire).to.have.been.calledWith( 'updateAccountEmailAddress', ctx.user._id, @@ -833,8 +825,6 @@ describe('UserUpdater', function () { ) ).to.be.rejectedWith(Errors.UnconfirmedEmailError) expect(ctx.db.users.updateOne).to.not.have.been.called - expect(ctx.NewsletterManager.promises.changeEmail).to.not.have.been - .called }) }) @@ -854,7 +844,6 @@ describe('UserUpdater', function () { expect(error).to.exist expect(error.name).to.equal('Error') ctx.UserUpdater.promises.updateUser.callCount.should.equal(0) - ctx.NewsletterManager.promises.changeEmail.callCount.should.equal(0) } ) })