mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 06:03:45 +01:00
Merge pull request #30788 from overleaf/rh-cio-sub-topics
Replace Mailchimp newsletter subscriptions with customer.io topic subscriptions GitOrigin-RevId: de536fb7ef24ff44c494659b2ef5ede5b33cc977
This commit is contained in:
Generated
+8
-7
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}}();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
+85
@@ -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 && (
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={getUserFacingMessage(error)}
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<OLNotification
|
||||
type="success"
|
||||
content={t('thanks_settings_updated')}
|
||||
className="mb-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<p>
|
||||
{subscribed ? (
|
||||
<Trans
|
||||
i18nKey="newsletter_info_subscribed"
|
||||
components={{ 0: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
i18nKey="newsletter_info_unsubscribed"
|
||||
components={{ 0: <strong /> }}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<p className="text-center">
|
||||
{subscribed ? (
|
||||
<OLButton
|
||||
variant="danger"
|
||||
onClick={handleToggleSubscription}
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={`${t('saving')}…`}
|
||||
>
|
||||
{t('unsubscribe')}
|
||||
</OLButton>
|
||||
) : (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleToggleSubscription}
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={`${t('saving')}…`}
|
||||
>
|
||||
{t('subscribe')}
|
||||
</OLButton>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{subscribed && <p>{t('newsletter_info_note')}</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailPreferencesForm
|
||||
@@ -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 (
|
||||
<div className="container">
|
||||
<OLRow>
|
||||
<OLCol xl={{ span: 10, offset: 1 }}>
|
||||
{isReady ? <EmailPreferencesContent /> : null}
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailPreferencesContent() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<OLPageContentCard>
|
||||
<div className="page-header">
|
||||
<h1>{t('newsletter_info_title')}</h1>
|
||||
</div>
|
||||
<p>{t('newsletter_info_summary')}</p>
|
||||
<EmailPreferencesForm />
|
||||
</OLPageContentCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmailPreferencesRoot
|
||||
@@ -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', () => (
|
||||
<UserProvider>
|
||||
<EmailPreferencesRoot />
|
||||
</UserProvider>
|
||||
))
|
||||
@@ -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[]
|
||||
|
||||
@@ -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",
|
||||
|
||||
+147
@@ -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('<EmailPreferencesForm />', function () {
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-newsletter-subscribed', true)
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fetchMock.removeRoutes().clearHistory()
|
||||
})
|
||||
|
||||
it('shows subscribed state with unsubscribe button', function () {
|
||||
render(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
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(<EmailPreferencesForm />)
|
||||
|
||||
// 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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user