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-21 11:02:37 +01:00 committed by Copybot
parent 8388d808a5
commit 73e8fd115b
18 changed files with 388 additions and 252 deletions

View file

@ -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'
)
}
}

View file

@ -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

View file

@ -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: {

View file

@ -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'),
})
})
})
})

View file

@ -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,

View file

@ -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)

View file

@ -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()
})

View file

@ -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,

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 },

View file

@ -409,6 +409,11 @@
"or": "",
"other_logs_and_files": "",
"other_output_files": "",
"overleaf_labs": "",
"labs_program_benefits": "",
"labs_program_already_participating": "",
"labs_program_not_participating": "",
"manage_labs_program_membership": "",
"owner": "",
"page_current": "",
"pagination_navigation": "",

View file

@ -0,0 +1,28 @@
import { useTranslation, Trans } from 'react-i18next'
import { useUserContext } from '../../../shared/context/user-context'
function LabsProgramSection() {
const { t } = useTranslation()
const { labsProgram } = useUserContext()
return (
<>
<h3>{t('overleaf_labs')}</h3>
{labsProgram ? null : (
<p className="small">
<Trans i18nKey="labs_program_benefits">
<span />
</Trans>
</p>
)}
<p className="small">
{labsProgram
? t('labs_program_already_participating')
: t('labs_program_not_participating')}
</p>
<a href="/labs/participate">{t('manage_labs_program_membership')}</a>
</>
)
}
export default LabsProgramSection

View file

@ -6,6 +6,7 @@ import AccountInfoSection from './account-info-section'
import PasswordSection from './password-section'
import LinkingSection from './linking-section'
import BetaProgramSection from './beta-program-section'
import LabsProgramSection from './labs-program-section'
import SessionsSection from './sessions-section'
import NewsletterSection from './newsletter-section'
import LeaveSection from './leave-section'
@ -38,7 +39,9 @@ function SettingsPageRoot() {
function SettingsPageContent() {
const { t } = useTranslation()
const { isOverleaf } = getMeta('ol-ExposedSettings') as ExposedSettings
const { isOverleaf, labsEnabled } = getMeta(
'ol-ExposedSettings'
) as ExposedSettings
return (
<UserProvider>
@ -67,6 +70,12 @@ function SettingsPageContent() {
<hr />
</>
) : null}
{labsEnabled ? (
<>
<LabsProgramSection />
<hr />
</>
) : null}
<SessionsSection />
{isOverleaf ? (
<>

View file

@ -15,6 +15,7 @@ UserContext.Provider.propTypes = {
last_name: PropTypes.string,
alphaProgram: PropTypes.boolean,
betaProgram: PropTypes.boolean,
labsProgram: PropTypes.boolean,
features: PropTypes.shape({
dropbox: PropTypes.boolean,
github: PropTypes.boolean,

View file

@ -46,7 +46,6 @@
content: 'β';
}
}
.alpha-badge {
background-color: @ol-green;
border-radius: @border-radius-base;
@ -56,6 +55,13 @@
}
}
.labs-badge {
background-color: @orange;
border-radius: @border-radius-base;
padding: 2px;
color: white;
}
.split-test-badge-tooltip .tooltip-inner {
white-space: pre-wrap;
}

View file

@ -1876,5 +1876,20 @@
"reverse_x_sort_order" : "Reverse __x__ sort order",
"create_first_project": "Create First Project",
"you_dont_have_any_repositories": "You dont have any repositories",
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters"
"tag_name_cannot_exceed_characters": "Tag name cannot exceed __maxLength__ characters",
"overleaf_labs": "Overleaf Labs",
"labs_program_already_participating": "You are enrolled in Labs",
"labs_program_not_participating": "You are not enrolled in Labs",
"labs_program_benefits": "__appName__ is always looking for new ways to help users work more quickly and effectively. By joining Overleaf Labs, you can participate in experiments that explore innovative ideas in the space of collaborative writing and publishing.",
"you_can_opt_in_and_out_of_overleaf_labs_at_any_time_on_this_page": "You can <0>opt in and out</0> of Overleaf Labs at any time on this page",
"you_can_opt_in_and_out_of_galileo_at_any_time_on_this_page": "You can <0>opt in and out</0> of Galileo at any time on this page",
"you_can_opt_in_to_individual_experiments": "You will be asked to opt in and out of individual experiments; each experiment may have unique partners, requirements, terms and conditions, etc. that must be opted in to for that specific experiment",
"labs_program_badge_description": "While using __appName__, you will see Labs features marked with this badge:",
"note_experiments_under_development": "<0>Please note</0> that experiments in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a paid plan</0>",
"galileo_program_description": "Galileo is an AI that helps you write your documents",
"galileo_is_part_of_overleaf_labs": "Galileo is an experiment in <0>Overleaf Labs</0>",
"thank_you_for_being_part_of_our_labs_program": "Thank you for being part of our Labs program, where you can have <0>early access to experimental features</0> and help us explore innovative ideas that help you work more quickly and effectively",
"manage_labs_program_membership": "Manage Labs Program Membership",
"current_experiments": "Current Experiments",
"overleaf_labs": "Overleaf Labs"
}

View file

@ -0,0 +1,26 @@
/* eslint-disable no-unused-vars */
const Helpers = require('./lib/helpers')
exports.tags = ['saas']
const indexes = [
{
key: { labsProgram: 1 },
name: 'labsProgram_1',
},
{
key: { labsProgramGalileo: 1 },
name: 'labsProgramGalileo_1',
},
]
exports.migrate = async client => {
const { db } = client
await Helpers.addIndexesToCollection(db.users, indexes)
}
exports.rollback = async client => {
const { db } = client
await Helpers.dropIndexesFromCollection(db.users, indexes)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -36,4 +36,5 @@ export type ExposedSettings = {
textExtensions: string[]
validRootDocExtensions: string[]
templateLinks: TemplateLink[]
labsEnabled: boolean
}