Merge pull request #9466 from overleaf/jk-add-labs-program-for-galileo

[web] Add a new "Overleaf Labs" program, for Galileo

GitOrigin-RevId: 8f6c79c37c1719a59bd8405998cc3de2fd29960d
This commit is contained in:
June Kelly
2022-09-22 08:03:59 +00:00
committed by Copybot
parent 8388d808a5
commit 73e8fd115b
18 changed files with 388 additions and 252 deletions
@@ -0,0 +1,260 @@
const logger = require('@overleaf/logger')
const Settings = require('@overleaf/settings')
const crypto = require('crypto')
const Mailchimp = require('mailchimp-api-v3')
const OError = require('@overleaf/o-error')
const { callbackify } = require('util')
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),
promises: provider,
}
}
module.exports = {
make,
}
class NonFatalEmailUpdateError extends OError {
constructor(message, oldEmail, newEmail) {
super(message, { oldEmail, newEmail })
}
}
function makeMailchimpProvider(listName, listId) {
const mailchimp = new Mailchimp(Settings.mailchimp.api_key)
const MAILCHIMP_LIST_ID = listId
return {
subscribed,
subscribe,
unsubscribe,
changeEmail,
}
async function subscribed(user) {
try {
const path = getSubscriberPath(user.email)
const result = await mailchimp.get(path)
return result?.status === 'subscribed'
} catch (err) {
if (err.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 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 (err.status === 404 || err.status === 405) {
// 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 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,
}
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'
)
}
}
@@ -1,248 +1,9 @@
const { callbackify } = require('util')
const logger = require('@overleaf/logger')
const Settings = require('@overleaf/settings')
const crypto = require('crypto')
const Mailchimp = require('mailchimp-api-v3')
const OError = require('@overleaf/o-error')
const MailchimpProvider = require('./MailChimpProvider')
const provider = getProvider()
const provider = MailchimpProvider.make(
'newsletter',
Settings.mailchimp ? Settings.mailchimp.list_id : null
)
module.exports = {
subscribed: callbackify(provider.subscribed),
subscribe: callbackify(provider.subscribe),
unsubscribe: callbackify(provider.unsubscribe),
changeEmail: callbackify(provider.changeEmail),
promises: provider,
}
class NonFatalEmailUpdateError extends OError {
constructor(message, oldEmail, newEmail) {
super(message, { oldEmail, newEmail })
}
}
function getProvider() {
if (mailchimpIsConfigured()) {
logger.debug('Using newsletter provider: mailchimp')
return makeMailchimpProvider()
} else {
logger.debug('Using newsletter provider: none')
return makeNullProvider()
}
}
function mailchimpIsConfigured() {
return Settings.mailchimp != null && Settings.mailchimp.api_key != null
}
function makeMailchimpProvider() {
const mailchimp = new Mailchimp(Settings.mailchimp.api_key)
const MAILCHIMP_LIST_ID = Settings.mailchimp.list_id
return {
subscribed,
subscribe,
unsubscribe,
changeEmail,
}
async function subscribed(user) {
try {
const path = getSubscriberPath(user.email)
const result = await mailchimp.get(path)
return result?.status === 'subscribed'
} catch (err) {
if (err.status === 404) {
return false
}
throw OError.tag(err, 'error getting newsletter subscriptions status', {
userId: user._id,
})
}
}
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 }, 'finished subscribing user to newsletter')
} catch (err) {
throw OError.tag(err, 'error subscribing user to newsletter', {
userId: user._id,
})
}
}
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 },
'finished unsubscribing user from newsletter'
)
} catch (err) {
if (err.status === 404 || err.status === 405) {
// 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 },
'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,
})
}
}
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 },
'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 }
)
}
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('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 }, 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,
})
}
}
function getSubscriberPath(email) {
const emailHash = hashEmail(email)
return `/lists/${MAILCHIMP_LIST_ID}/members/${emailHash}`
}
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() {
return {
subscribed,
subscribe,
unsubscribe,
changeEmail,
}
async function subscribed(user) {
logger.debug(
{ user },
'Not checking user because no newsletter provider is configured'
)
return false
}
async function subscribe(user) {
logger.debug(
{ user },
'Not subscribing user to newsletter because no newsletter provider is configured'
)
}
async function unsubscribe(user) {
logger.debug(
{ user },
'Not unsubscribing user from newsletter because no newsletter provider is configured'
)
}
async function changeEmail(oldEmail, newEmail) {
logger.debug(
{ oldEmail, newEmail },
'Not changing email in newsletter for user because no newsletter provider is configured'
)
}
}
module.exports = provider
@@ -765,7 +765,7 @@ const ProjectController = {
)
User.findById(
userId,
'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace',
'email first_name last_name referal_id signUpDate featureSwitches features featuresEpoch refProviders alphaProgram betaProgram isAdmin ace labsProgram labsProgramGalileo',
(err, user) => {
// Handle case of deleted user
if (user == null) {
@@ -1197,6 +1197,8 @@ const ProjectController = {
refProviders: _.mapValues(user.refProviders, Boolean),
alphaProgram: user.alphaProgram,
betaProgram: user.betaProgram,
labsProgram: user.labsProgram,
labsProgramGalileo: user.labsProgramGalileo,
isAdmin: hasAdminAccess(user),
},
userSettings: {
@@ -290,8 +290,16 @@ const UserController = {
OError.tag(err, 'error unsubscribing to newsletter')
return next(err)
}
return res.json({
message: req.i18n.translate('thanks_settings_updated'),
// TODO: figure out why things go wrong if we import at the top
const Modules = require('../../infrastructure/Modules')
Modules.hooks.fire('newsletterUnsubscribed', user, err => {
if (err) {
OError.tag(err, 'error firing "newsletterUnsubscribed" hook')
return next(err)
}
return res.json({
message: req.i18n.translate('thanks_settings_updated'),
})
})
})
})
@@ -73,6 +73,7 @@ async function settingsPage(req, res) {
last_name: user.last_name,
alphaProgram: user.alphaProgram,
betaProgram: user.betaProgram,
labsProgram: user.labsProgram,
features: {
dropbox: user.features.dropbox,
github: user.features.github,
@@ -217,6 +217,16 @@ async function setDefaultEmailAddress(
'Failed to change email in newsletter subscription'
)
}
try {
// TODO: figure out why things go wrong if we import at the top
const Modules = require('../../infrastructure/Modules')
await Modules.promises.hooks.fire('userEmailChanged', user, email)
} catch (err) {
logger.error(
{ err, oldEmail, newEmail: email },
'Failed to fire "userEmailChanged" hook'
)
}
try {
await RecurlyWrapper.promises.updateAccountEmailAddress(user._id, email)
@@ -411,6 +411,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
Settings.analytics.ga.tokenV4,
cookieDomain: Settings.cookieDomain,
templateLinks: Settings.templateLinks,
labsEnabled: Settings.labs && Settings.labs.enable,
}
next()
})
@@ -21,7 +21,7 @@ function loadModules() {
require(settingsCheckModule)
}
for (const moduleName of Settings.moduleImportSequence) {
for (const moduleName of Settings.moduleImportSequence || []) {
const loadedModule = require(Path.join(
MODULE_BASE_PATH,
moduleName,
+2
View File
@@ -163,6 +163,8 @@ const UserSchema = new Schema({
},
alphaProgram: { type: Boolean, default: false }, // experimental features
betaProgram: { type: Boolean, default: false },
labsProgram: { type: Boolean, default: false },
labsProgramGalileo: { type: Boolean, default: false },
overleaf: {
id: { type: Number },
accessToken: { type: String },