diff --git a/packages/frontend-2/components/auth/RegisterPanel.vue b/packages/frontend-2/components/auth/RegisterPanel.vue index e0831dcc5..d82dd5a22 100644 --- a/packages/frontend-2/components/auth/RegisterPanel.vue +++ b/packages/frontend-2/components/auth/RegisterPanel.vue @@ -63,6 +63,10 @@ graphql(` } `) +const newsletterConsent = ref(false) + +provide('newsletterconsent', newsletterConsent) + const { result } = useQuery(loginServerInfoQuery) const { appId, challenge, inviteToken } = useLoginOrRegisterUtils() diff --git a/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue b/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue index 885324cbf..c1fef56d7 100644 --- a/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue +++ b/packages/frontend-2/components/auth/RegisterWithEmailBlock.vue @@ -43,6 +43,19 @@ :class="`mt-2 overflow-hidden ${pwdFocused ? 'h-8' : 'h-0'} transition-[height]`" /> Sign up +
+ + +
>('newsletterconsent') + const pwdFocused = ref(false) const finalLoginRoute = computed(() => { @@ -116,7 +131,8 @@ const onSubmit = handleSubmit(async (fullUser) => { await signUpWithEmail({ user, challenge: props.challenge, - inviteToken: inviteToken.value + inviteToken: inviteToken.value, + newsletter: newsletterConsent?.value }) } catch (e) { triggerNotification({ diff --git a/packages/frontend-2/components/auth/third-party/LoginBlock.vue b/packages/frontend-2/components/auth/third-party/LoginBlock.vue index d9500ac90..927f951d9 100644 --- a/packages/frontend-2/components/auth/third-party/LoginBlock.vue +++ b/packages/frontend-2/components/auth/third-party/LoginBlock.vue @@ -50,6 +50,8 @@ const { const mixpanel = useMixpanel() const { inviteToken } = useAuthManager() +const newsletterConsent = inject>('newsletterconsent') + const NuxtLink = resolveComponent('NuxtLink') const GoogleButton = resolveComponent('AuthThirdPartyLoginButtonGoogle') const MicrosoftButton = resolveComponent('AuthThirdPartyLoginButtonMicrosoft') @@ -68,6 +70,11 @@ const buildAuthUrl = (strat: StrategyType) => { url.searchParams.set('token', inviteToken.value) } + if (newsletterConsent?.value) { + url.searchParams.set('newsletter', 'true') + } + + console.log(url) return url.toString() } diff --git a/packages/frontend-2/lib/auth/composables/auth.ts b/packages/frontend-2/lib/auth/composables/auth.ts index 6ea70ea61..d19a9c530 100644 --- a/packages/frontend-2/lib/auth/composables/auth.ts +++ b/packages/frontend-2/lib/auth/composables/auth.ts @@ -239,14 +239,16 @@ export const useAuthManager = () => { } challenge: string inviteToken?: string + newsletter?: boolean }) => { - const { user, challenge, inviteToken } = params + const { user, challenge, inviteToken, newsletter } = params const { accessCode } = await registerAndGetAccessCode({ apiOrigin, challenge, user, - inviteToken + inviteToken, + newsletter }) // eslint-disable-next-line camelcase diff --git a/packages/frontend-2/lib/auth/services/auth.ts b/packages/frontend-2/lib/auth/services/auth.ts index 16db249c3..921a310e9 100644 --- a/packages/frontend-2/lib/auth/services/auth.ts +++ b/packages/frontend-2/lib/auth/services/auth.ts @@ -27,6 +27,7 @@ type RegisterParams = { apiOrigin: string challenge: string inviteToken?: string + newsletter?: boolean user: { email: string password: string @@ -85,7 +86,7 @@ export async function getAccessCode(params: LoginParams) { } export async function registerAndGetAccessCode(params: RegisterParams) { - const { apiOrigin, challenge, user, inviteToken } = params + const { apiOrigin, challenge, user, inviteToken, newsletter } = params if (!user.email || !user.password || !user.name) { throw new InvalidRegisterParametersError( "Can't register without a valid email, password and name!" @@ -98,6 +99,10 @@ export async function registerAndGetAccessCode(params: RegisterParams) { registerUrl.searchParams.append('token', inviteToken) } + if (newsletter) { + registerUrl.searchParams.append('newsletter', 'true') + } + const res = await fetch(registerUrl, { method: 'POST', headers: { diff --git a/packages/server/modules/auth/services/mailchimp.ts b/packages/server/modules/auth/services/mailchimp.ts new file mode 100644 index 000000000..9dda9fa96 --- /dev/null +++ b/packages/server/modules/auth/services/mailchimp.ts @@ -0,0 +1,52 @@ +/* eslint-disable camelcase */ +import mailchimp from '@mailchimp/mailchimp_marketing' +import { logger } from '@/logging/logging' +import { md5 } from '@/modules/shared/helpers/cryptoHelper' +import { + getMailchimpConfig, + getMailchimpStatus +} from '@/modules/shared/helpers/envHelper' +import { getUserById } from '@/modules/core/services/users' + +async function addToMailchimpAudience(userId: string) { + // Do not do anything (inc. logging) if we do not explicitely enable it + if (!getMailchimpStatus()) return + + // Note: fails here should not block registration at any cost + try { + const config = getMailchimpConfig() // Note: throws an error if not configured + + mailchimp.setConfig({ + apiKey: config.apiKey, + server: config.serverPrefix + }) + + const user = await getUserById({ userId }) + + if (!user) { + throw new Error( + 'Could not register user for newsletter - no db user record found.' + ) + } + + const [first, second] = user.name.split(' ') + const subscriberHash = md5(user.email.toLowerCase()) + + // NOTE: using setListMember (NOT addListMember) to prevent errors for previously + // registered members. + await mailchimp.lists.setListMember(config.listId, subscriberHash, { + status_if_new: 'subscribed', + email_address: user.email, + merge_fields: { + EMAIL: user.email, + FNAME: first, + LNAME: second, + FULLNAME: user.name // NOTE: this field needs to be set in the audience merge fields + } + }) + } catch (e) { + logger.warn(e, 'Failed to register user to newsletter.') + } +} + +export { addToMailchimpAudience } diff --git a/packages/server/modules/auth/strategies.js b/packages/server/modules/auth/strategies.js index e3e688531..47195b4c7 100644 --- a/packages/server/modules/auth/strategies.js +++ b/packages/server/modules/auth/strategies.js @@ -11,9 +11,10 @@ const { isSSLServer, getRedisUrl } = require('@/modules/shared/helpers/envHelper const { authLogger } = require('@/logging/logging') const { createRedisClient } = require('@/modules/shared/redis/redis') const { mixpanel, resolveMixpanelUserId } = require('@/modules/shared/utils/mixpanel') - +const { addToMailchimpAudience } = require('./services/mailchimp') /** * TODO: Get rid of session entirely, we don't use it for the app and it's not really necessary for the auth flow, so it only complicates things + * NOTE: it does seem used! */ module.exports = async (app) => { @@ -49,6 +50,11 @@ module.exports = async (app) => { req.session.token = token } + const newsletterConsent = req.query.newsletter || null + if (newsletterConsent) { + req.session.newsletterConsent = true + } + next() } @@ -64,6 +70,9 @@ module.exports = async (app) => { challenge: req.session.challenge }) + let newsletterConsent = false + if (req.session.newsletterConsent) newsletterConsent = true // NOTE: it's only set if it's true + if (req.session) req.session.destroy() // Resolve redirect URL @@ -83,6 +92,10 @@ module.exports = async (app) => { } } + if (newsletterConsent) { + await addToMailchimpAudience(req.user.id) + } + const redirectUrl = urlObj.toString() return res.redirect(redirectUrl) diff --git a/packages/server/modules/shared/helpers/envHelper.ts b/packages/server/modules/shared/helpers/envHelper.ts index a3c6982c0..727dfda5a 100644 --- a/packages/server/modules/shared/helpers/envHelper.ts +++ b/packages/server/modules/shared/helpers/envHelper.ts @@ -81,6 +81,26 @@ export function getOidcName() { return process.env.OIDC_NAME } +export function getMailchimpStatus() { + return [true, 'true'].includes(process.env.MAILCHIMP_ENABLED || false) +} + +export function getMailchimpConfig() { + if ( + !process.env.MAILCHIMP_API_KEY || + !process.env.MAILCHIMP_SERVER_PREFIX || + !process.env.MAILCHIMP_LIST_ID + ) { + throw new MisconfiguredEnvironmentError('Mailchimp is not configured') + } + + return { + apiKey: process.env.MAILCHIMP_API_KEY, + serverPrefix: process.env.MAILCHIMP_SERVER_PREFIX, + listId: process.env.MAILCHIMP_LIST_ID + } +} + /** * Get app base url / canonical url / origin * TODO: Go over all getBaseUrl() usages and move them to getXOrigin() instead diff --git a/packages/server/package.json b/packages/server/package.json index d5fc5f7f5..0015a2e7b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -36,9 +36,11 @@ "@aws-sdk/lib-storage": "^3.100.0", "@godaddy/terminus": "^4.9.0", "@graphql-tools/schema": "^9.0.4", + "@mailchimp/mailchimp_marketing": "^3.0.80", "@sentry/node": "^6.17.9", "@sentry/tracing": "^6.17.9", "@speckle/shared": "workspace:^", + "@types/mailchimp__mailchimp_marketing": "^3.0.9", "@types/pino-http": "^5.8.1", "@types/uuid": "^9.0.0", "apollo-server-express": "^3.10.2", diff --git a/utils/helm/speckle-server/templates/server/deployment.yml b/utils/helm/speckle-server/templates/server/deployment.yml index 20fde29f6..bd94b6e64 100644 --- a/utils/helm/speckle-server/templates/server/deployment.yml +++ b/utils/helm/speckle-server/templates/server/deployment.yml @@ -276,6 +276,27 @@ spec: - name: EMAIL_FROM value: "{{ .Values.server.email.from }}" {{- end }} + + # *** Newsletter *** + {{- if (default false (.Values.server.mailchimp).enabled) }} + - name: MAILCHIMP_ENABLED + value: {{ default false (.Values.server.mailchimp).enabled }} + - name: MAILCHIMP_API_KEY + valueFrom: + secretKeyRef: + name: {{ default .Values.secretName ((.Values.server.mailchimp).apikey).secretName }} + key: {{ default "mailchimp_apikey" ((.Values.server.mailchimp).apikey).secretKey }} + - name: MAILCHIMP_SERVER_PREFIX + valueFrom: + secretKeyRef: + name: {{ default .Values.secretName ((.Values.server.mailchimp).serverprefix).secretName }} + key: {{ default "mailchimp_serverprefix" ((.Values.server.mailchimp).serverprefix).secretKey }} + - name: MAILCHIMP_LIST_ID + valueFrom: + secretKeyRef: + name: {{ default .Values.secretName ((.Values.server.mailchimp).listid).secretName }} + key: {{ default "mailchimp_listid" ((.Values.server.mailchimp).listid).secretKey }} + {{- end }} # *** Tracking / Tracing *** - name: SENTRY_DSN diff --git a/yarn.lock b/yarn.lock index e1a57c2f3..a5d18fe64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9344,6 +9344,16 @@ __metadata: languageName: node linkType: hard +"@mailchimp/mailchimp_marketing@npm:^3.0.80": + version: 3.0.80 + resolution: "@mailchimp/mailchimp_marketing@npm:3.0.80" + dependencies: + dotenv: ^8.2.0 + superagent: 3.8.1 + checksum: a43f69334766bf02fccf4f6076fa38b7e6a53d9d99485547e1e11d23f9188349ac2831a329f15bb8e31be46cca5e22275a120d0d108b80212ed92ad12df709e4 + languageName: node + linkType: hard + "@mapbox/node-pre-gyp@npm:^1.0.0": version: 1.0.9 resolution: "@mapbox/node-pre-gyp@npm:1.0.9" @@ -11237,6 +11247,7 @@ __metadata: "@graphql-codegen/typescript-operations": ^2.5.2 "@graphql-codegen/typescript-resolvers": 2.7.2 "@graphql-tools/schema": ^9.0.4 + "@mailchimp/mailchimp_marketing": ^3.0.80 "@sentry/node": ^6.17.9 "@sentry/tracing": ^6.17.9 "@speckle/objectloader": "workspace:^" @@ -11251,6 +11262,7 @@ __metadata: "@types/ejs": ^3.1.1 "@types/express": ^4.17.13 "@types/lodash": ^4.14.180 + "@types/mailchimp__mailchimp_marketing": ^3.0.9 "@types/mjml": ^4.7.0 "@types/mocha": ^10.0.0 "@types/mock-require": ^2.0.1 @@ -15321,6 +15333,13 @@ __metadata: languageName: node linkType: hard +"@types/mailchimp__mailchimp_marketing@npm:^3.0.9": + version: 3.0.9 + resolution: "@types/mailchimp__mailchimp_marketing@npm:3.0.9" + checksum: 3bf413367a77a331fd87552096ce42f23baee3f34309c91766d26095bc686fa541aa1ce5d72061fd66e9e2b8bb3589542b4709e04043c1ca54e28e71e4480934 + languageName: node + linkType: hard + "@types/mdx@npm:^2.0.0": version: 2.0.3 resolution: "@types/mdx@npm:2.0.3" @@ -25534,7 +25553,7 @@ __metadata: languageName: node linkType: hard -"formidable@npm:^1.2.0": +"formidable@npm:^1.1.1, formidable@npm:^1.2.0": version: 1.2.6 resolution: "formidable@npm:1.2.6" checksum: 2b68ed07ba88302b9c63f8eda94f19a460cef6017bfda48348f09f41d2a36660c9353137991618e0e4c3db115b41e4b8f6fa63bc973b7a7c91dec66acdd02a56 @@ -40206,6 +40225,24 @@ __metadata: languageName: node linkType: hard +"superagent@npm:3.8.1": + version: 3.8.1 + resolution: "superagent@npm:3.8.1" + dependencies: + component-emitter: ^1.2.0 + cookiejar: ^2.1.0 + debug: ^3.1.0 + extend: ^3.0.0 + form-data: ^2.3.1 + formidable: ^1.1.1 + methods: ^1.1.1 + mime: ^1.4.1 + qs: ^6.5.1 + readable-stream: ^2.0.5 + checksum: 42895e220fb5aab303edeef7ec4d9c38fef31638d18254fe57329366e7a74624dffe20acd682a9a27df4f62fe4acb953b33d16be9611f184284815c2492d35cf + languageName: node + linkType: hard + "superagent@npm:^3.7.0, superagent@npm:^3.8.3": version: 3.8.3 resolution: "superagent@npm:3.8.3"