├── .firebaserc ├── functions ├── jest.config.js ├── tests │ ├── __mocks__ │ │ └── @mailchimp │ │ │ └── mailchimp_marketing.js │ ├── utils.js │ ├── addUserToList.test.js │ ├── removeUserFromList.test.js │ ├── memberEventsHandler.test.js │ ├── memberTagsHandler.test.js │ └── mergeFieldsHandler.test.js ├── .eslintrc.js ├── config.js ├── package.json ├── validation.js ├── logs.js └── index.js ├── .vscode └── settings.json ├── .gitignore ├── firebase.json ├── .github └── workflows │ ├── release.yml │ ├── validate.yml │ ├── test.yml │ └── scripts │ └── release.sh ├── PREINSTALL.md ├── CONTRIBUTING.md ├── CHANGELOG.md ├── POSTINSTALL.md ├── LICENSE ├── README.md └── extension.yaml /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "mailchimp-extension" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /functions/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ["**/tests/*.test.js"], 3 | testPathIgnorePatterns: ["node_modules/"], 4 | moduleFileExtensions: ["js", "jsx", "json", "node"], 5 | testEnvironment: "node", 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | }, 7 | "eslint.workingDirectories": ["./functions"] 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | functions/*.log* 10 | 11 | # Compiled binary addons (http://nodejs.org/api/addons.html) 12 | build/Release 13 | 14 | # Dependency directories 15 | functions/node_modules/ 16 | -------------------------------------------------------------------------------- /functions/tests/__mocks__/@mailchimp/mailchimp_marketing.js: -------------------------------------------------------------------------------- 1 | const lists = jest.fn(); 2 | 3 | lists.addListMember = jest.fn(); 4 | lists.createListMemberEvent = jest.fn(); 5 | lists.updateListMemberTags = jest.fn(); 6 | lists.setListMember = jest.fn(); 7 | lists.deleteListMember = jest.fn(); 8 | const setConfig = jest.fn(); 9 | module.exports = { lists, setConfig }; 10 | -------------------------------------------------------------------------------- /functions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | es2022: true, 5 | }, 6 | parserOptions: { 7 | ecmaVersion: 2023, 8 | }, 9 | extends: [ 10 | "eslint:recommended", 11 | "airbnb-base", 12 | ], 13 | rules: { 14 | "no-restricted-globals": ["error", "name", "length"], 15 | "prefer-arrow-callback": "error", 16 | quotes: ["error", "double", { allowTemplateLiterals: true }], 17 | }, 18 | overrides: [ 19 | { 20 | files: ["tests/*.js", "tests/**/*.js"], 21 | env: { 22 | jest: true, 23 | }, 24 | }, 25 | ], 26 | globals: {}, 27 | }; 28 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "public", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ] 9 | }, 10 | "firestore": { 11 | "rules": "firestore.rules", 12 | "indexes": "firestore.indexes.json" 13 | }, 14 | "functions": { 15 | "predeploy": [ 16 | "npm --prefix \"$RESOURCE_DIR\" run lint" 17 | ], 18 | "source": "functions" 19 | }, 20 | "emulators": { 21 | "functions": { 22 | "port": 6000 23 | }, 24 | "firestore": { 25 | "port": 8080 26 | }, 27 | "database": { 28 | "port": 9000 29 | }, 30 | "ui": { 31 | "enabled": true 32 | }, 33 | "hosting": { 34 | "port": 5000 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /functions/tests/utils.js: -------------------------------------------------------------------------------- 1 | // default configuration for testing 2 | const defaultConfig = { 3 | location: "location", 4 | mailchimpOAuthToken: "mailchimpOAuthToken-us9", 5 | mailchimpAudienceId: "mailchimpAudienceId", 6 | mailchimpContactStatus: "mailchimpContactStatus", 7 | mailchimpMemberTagsWatchPath: "_unused_", 8 | mailchimpMemberTags: "{}", 9 | mailchimpMergeFieldWatchPath: "_unused_", 10 | mailchimpMergeField: "{}", 11 | mailchimpMemberEventsWatchPath: "_unused_", 12 | mailchimpMemberEvents: "{}", 13 | mailchimpRetryAttempts: "0", 14 | }; 15 | 16 | const errorWithStatus = (status) => { 17 | const err = new Error(); 18 | err.status = status; 19 | return err; 20 | }; 21 | 22 | module.exports = { 23 | defaultConfig, 24 | errorWithStatus, 25 | }; 26 | -------------------------------------------------------------------------------- /functions/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | location: process.env.LOCATION, 3 | mailchimpOAuthToken: process.env.MAILCHIMP_API_KEY, 4 | mailchimpAudienceId: process.env.MAILCHIMP_AUDIENCE_ID, 5 | mailchimpContactStatus: process.env.MAILCHIMP_CONTACT_STATUS, 6 | mailchimpMemberTagsWatchPath: process.env.MAILCHIMP_MEMBER_TAGS_WATCH_PATH, 7 | mailchimpMemberTags: process.env.MAILCHIMP_MEMBER_TAGS_CONFIG, 8 | mailchimpMergeFieldWatchPath: process.env.MAILCHIMP_MERGE_FIELDS_WATCH_PATH, 9 | mailchimpMergeField: process.env.MAILCHIMP_MERGE_FIELDS_CONFIG, 10 | mailchimpMemberEventsWatchPath: process.env.MAILCHIMP_MEMBER_EVENTS_WATCH_PATH, 11 | mailchimpMemberEvents: process.env.MAILCHIMP_MEMBER_EVENTS_CONFIG, 12 | mailchimpRetryAttempts: process.env.MAILCHIMP_RETRY_ATTEMPTS, 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Copied from https://github.com/firebase/extensions/blob/next/.github/workflows/release.yml 16 | 17 | name: Release 18 | 19 | on: 20 | push: 21 | branches: 22 | - master 23 | 24 | jobs: 25 | release: 26 | name: "Create Releases" 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - name: Release Script 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | run: | 34 | ./.github/workflows/scripts/release.sh 35 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-mailchimp-sync-functions", 3 | "description": "Add new users to a Mailchimp list, and delete them from the list when they delete their account.", 4 | "main": "index.js", 5 | "scripts": { 6 | "gcp-build": "", 7 | "test": "jest", 8 | "test:watch": "jest --watch", 9 | "generate-readme": "firebase ext:info .. --markdown > ../README.md", 10 | "serve": "firebase emulators:start --project mailchimp-extension --only functions,firestore", 11 | "lint": "eslint --max-warnings=0 .", 12 | "lint:fix": "eslint --max-warnings=0 --fix ." 13 | }, 14 | "dependencies": { 15 | "@mailchimp/mailchimp_marketing": "^3.0.80", 16 | "firebase-admin": "^11.10.1", 17 | "firebase-functions": "^4.4.1", 18 | "jmespath": "^0.16.0", 19 | "jsonschema": "^1.4.1", 20 | "lodash": "^4.17.21" 21 | }, 22 | "devDependencies": { 23 | "eslint": "^8.46.0", 24 | "eslint-config-airbnb-base": "^15.0.0", 25 | "eslint-plugin-import": "^2.28.0", 26 | "firebase-functions-test": "^3.1.0", 27 | "jest": "^29.6.2", 28 | "rimraf": "^5.0.1" 29 | }, 30 | "author": "Lauren Long ", 31 | "license": "Apache-2.0", 32 | "private": true, 33 | "engines": { 34 | "node": "18" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Copied from https://github.com/firebase/extensions/blob/next/.github/workflows/validate.yml 16 | # 1. Changed node versions list to only 18. 17 | # 2. Updated paths to use functions directory. 18 | 19 | name: Validate 20 | 21 | on: 22 | pull_request: 23 | branches: 24 | - "**" 25 | 26 | jobs: 27 | formatting: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | - name: Setup node 32 | uses: actions/setup-node@v3 33 | with: 34 | node-version: 18 35 | cache: "npm" 36 | cache-dependency-path: "**/package-lock.json" 37 | - name: npm install 38 | run: cd functions && npm i 39 | - name: Prettier Lint Check 40 | run: cd functions && npm run lint -------------------------------------------------------------------------------- /PREINSTALL.md: -------------------------------------------------------------------------------- 1 | Use this extension to: 2 | 3 | - Add new users to an existing Mailchimp audience 4 | - Remove user from an existing Mailchimp audience 5 | - Associate member tags with a Mailchimp subscriber 6 | - Use merge fields to sync user data with a Mailchimp subscriber 7 | - Set member events to trigger Mailchimp actions and automations 8 | 9 | #### Additional setup 10 | 11 | Make sure that you've set up [Firebase Authentication](https://firebase.google.com/docs/auth) to manage your users. 12 | 13 | You must also have a Mailchimp account before installing this extension. 14 | 15 | #### Billing 16 | 17 | This extension uses the following Firebase services which may have associated charges: 18 | 19 | - Cloud Firestore 20 | - Cloud Functions 21 | - Firebase Authentication 22 | 23 | This extension also uses the following third-party services: 24 | 25 | - Mailchimp Billing ([pricing information](https://mailchimp.com/pricing)) 26 | 27 | You are responsible for any costs associated with your use of these services. 28 | 29 | #### Note from Firebase 30 | 31 | To install this extension, your Firebase project must be on the Blaze (pay-as-you-go) plan. You will only be charged for the resources you use. Most Firebase services offer a free tier for low-volume use. [Learn more about Firebase billing.](https://firebase.google.com/pricing) 32 | 33 | You will be billed a small amount (typically less than $0.10) when you install or reconfigure this extension. See the [Cloud Functions for Firebase billing FAQ](https://firebase.google.com/support/faq#expandable-15) for a detailed explanation. 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Copied and modified from https://github.com/firebase/extensions/blob/next/.github/workflows/test.yml. Changes: 16 | # 1. Changed node versions list to only 18. 17 | # 2. Updated paths to use functions directory. 18 | 19 | name: Testing 20 | 21 | on: 22 | push: 23 | branches: 24 | - "**" 25 | pull_request: 26 | branches: 27 | - "**" 28 | 29 | jobs: 30 | nodejs: 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | node: ["18"] 35 | name: node.js_${{ matrix.node }}_test 36 | steps: 37 | - uses: actions/checkout@v3 38 | - name: Setup node 39 | uses: actions/setup-node@v3 40 | with: 41 | node-version: ${{ matrix.node }} 42 | cache: "npm" 43 | cache-dependency-path: "**/package-lock.json" 44 | - name: npm install 45 | run: cd functions && npm i 46 | - name: npm test 47 | run: cd functions && npm run test 48 | -------------------------------------------------------------------------------- /functions/tests/addUserToList.test.js: -------------------------------------------------------------------------------- 1 | jest.mock("@mailchimp/mailchimp_marketing"); 2 | 3 | const functions = require("firebase-functions-test"); 4 | const mailchimp = require("@mailchimp/mailchimp_marketing"); 5 | const { defaultConfig } = require("./utils"); 6 | 7 | const testEnv = functions(); 8 | 9 | // configure config mocks (so we can inject config and try different scenarios) 10 | jest.doMock("../config", () => defaultConfig); 11 | 12 | const api = require("../index"); 13 | 14 | describe("addUserToList", () => { 15 | const configureApi = (config) => { 16 | api.processConfig(config); 17 | }; 18 | 19 | beforeEach(() => { 20 | jest.clearAllMocks(); 21 | mailchimp.lists.addListMember = jest.fn(); 22 | }); 23 | 24 | afterAll(() => { 25 | testEnv.cleanup(); 26 | }); 27 | 28 | it("should make no calls when email is not set", async () => { 29 | configureApi(defaultConfig); 30 | const wrapped = testEnv.wrap(api.addUserToList); 31 | 32 | const result = await wrapped({}); 33 | 34 | expect(result).toBe(undefined); 35 | expect(mailchimp.lists.addListMember).toHaveBeenCalledTimes(0); 36 | }); 37 | 38 | it("should post user when email is given", async () => { 39 | configureApi(defaultConfig); 40 | const wrapped = testEnv.wrap(api.addUserToList); 41 | 42 | const testUser = { 43 | uid: "122", 44 | email: "test@example.com", 45 | }; 46 | 47 | mailchimp.lists.addListMember.mockReturnValue({ 48 | id: "createdUserId", 49 | }); 50 | 51 | const result = await wrapped(testUser); 52 | 53 | expect(result).toBe(undefined); 54 | expect(mailchimp.lists.addListMember).toHaveBeenCalledTimes(1); 55 | expect(mailchimp.lists.addListMember).toHaveBeenCalledWith( 56 | "mailchimpAudienceId", 57 | { 58 | email_address: "test@example.com", 59 | status: "mailchimpContactStatus", 60 | }, 61 | ); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Adding unit tests 4 | 5 | Unit tests are able to be added to `functions/tests/` and then executed via `npm run test:watch` (in the `functions` directory). 6 | 7 | These are the fastest way to get feedback on your new code and functionality. 8 | 9 | ## Using the Firebase Emulator 10 | 11 | To use the firebase emulator, you'll need the following: 12 | 13 | 1. A separate Firebase Project repository. This can be a bare bones project, but needs the following features enabled (see below for an example `firebase.json`): 14 | 1. Firestore 15 | 1. Authentication 16 | 1. Functions 17 | 1. Database 18 | 1. Hosting 19 | 1. UI 20 | 1. This repository installed as a local extension to the Project, via the `firebase ext:install PATH_TO_THIS_REPOSITORY` 21 | 1. This will walk you through the set up, similar to the Firebase Console. 22 | 1. Make sure to specify a collection name 23 | 1. Start the emulators locally with `firebase emulators:start`. 24 | 1. You should then be able to add items to the collection you specified via the Firestore UI to test the triggers. 25 | 26 | ### Example firebase.json 27 | 28 | ```json 29 | { 30 | "firestore": { 31 | "rules": "firestore.rules", 32 | "indexes": "firestore.indexes.json" 33 | }, 34 | "functions": { 35 | "predeploy": [ 36 | "npm --prefix \"$RESOURCE_DIR\" run lint", 37 | "npm --prefix \"$RESOURCE_DIR\" run build" 38 | ], 39 | "source": "functions" 40 | }, 41 | "hosting": { 42 | "public": "public", 43 | "ignore": [ 44 | "firebase.json", 45 | "**/.*", 46 | "**/node_modules/**" 47 | ] 48 | }, 49 | "emulators": { 50 | "functions": { 51 | "port": 5001 52 | }, 53 | "firestore": { 54 | "port": 8080 55 | }, 56 | "database": { 57 | "port": 9000 58 | }, 59 | "hosting": { 60 | "port": 5000 61 | }, 62 | "ui": { 63 | "enabled": true 64 | }, 65 | "auth": { 66 | "port": 9099 67 | } 68 | } 69 | } 70 | ``` 71 | -------------------------------------------------------------------------------- /functions/tests/removeUserFromList.test.js: -------------------------------------------------------------------------------- 1 | jest.mock("@mailchimp/mailchimp_marketing"); 2 | 3 | const functions = require("firebase-functions-test"); 4 | const mailchimp = require("@mailchimp/mailchimp_marketing"); 5 | const { errorWithStatus, defaultConfig } = require("./utils"); 6 | 7 | const testEnv = functions(); 8 | 9 | // configure config mocks (so we can inject config and try different scenarios) 10 | jest.doMock("../config", () => defaultConfig); 11 | 12 | const api = require("../index"); 13 | 14 | describe("removeUserFromList", () => { 15 | const configureApi = (config) => { 16 | api.processConfig(config); 17 | }; 18 | 19 | beforeEach(() => { 20 | jest.clearAllMocks(); 21 | }); 22 | 23 | afterAll(() => { 24 | testEnv.cleanup(); 25 | }); 26 | 27 | it("should make no calls when email is not set", async () => { 28 | configureApi(defaultConfig); 29 | const wrapped = testEnv.wrap(api.removeUserFromList); 30 | 31 | const result = await wrapped({}); 32 | 33 | expect(result).toBe(undefined); 34 | expect(mailchimp.lists.deleteListMember).toHaveBeenCalledTimes(0); 35 | }); 36 | 37 | it("should delete user when email is given", async () => { 38 | configureApi(defaultConfig); 39 | const wrapped = testEnv.wrap(api.removeUserFromList); 40 | 41 | const testUser = { 42 | uid: "122", 43 | email: "test@example.com", 44 | }; 45 | 46 | const result = await wrapped(testUser); 47 | 48 | expect(result).toBe(undefined); 49 | expect(mailchimp.lists.deleteListMember).toHaveBeenCalledTimes(1); 50 | expect(mailchimp.lists.deleteListMember).toHaveBeenCalledWith( 51 | "mailchimpAudienceId", 52 | "55502f40dc8b7c769880b10874abc9d0", 53 | ); 54 | }); 55 | 56 | it.each` 57 | retryAttempts 58 | ${0} 59 | ${2} 60 | `("should retry '$retryAttempts' times on operation error", async ({ retryAttempts }) => { 61 | configureApi({ 62 | ...defaultConfig, 63 | mailchimpRetryAttempts: retryAttempts.toString(), 64 | }); 65 | const wrapped = testEnv.wrap(api.removeUserFromList); 66 | mailchimp.lists.deleteListMember.mockImplementation(() => { 67 | throw errorWithStatus(404); 68 | }); 69 | 70 | const testUser = { 71 | uid: "122", 72 | email: "test@example.com", 73 | }; 74 | 75 | const result = await wrapped(testUser); 76 | 77 | expect(result).toBe(undefined); 78 | expect(mailchimp.lists.deleteListMember).toHaveBeenCalledTimes(retryAttempts + 1); 79 | expect(mailchimp.lists.deleteListMember).toHaveBeenCalledWith( 80 | "mailchimpAudienceId", 81 | "55502f40dc8b7c769880b10874abc9d0", 82 | ); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /functions/validation.js: -------------------------------------------------------------------------------- 1 | const { Validator } = require("jsonschema"); 2 | 3 | const v = new Validator(); 4 | 5 | const multidimensionalSelectorSchema = { 6 | id: "/MultiDimensionalSelector", 7 | type: "object", 8 | properties: { 9 | documentPath: { type: "string" }, 10 | }, 11 | required: ["documentPath"], 12 | }; 13 | 14 | const tagConfigSchema = { 15 | id: "/TagConfig", 16 | type: "object", 17 | properties: { 18 | memberTags: { 19 | type: "array", 20 | items: { 21 | oneOf: [ 22 | { type: "string" }, 23 | { $ref: multidimensionalSelectorSchema.id }, 24 | ], 25 | }, 26 | }, 27 | subscriberEmail: { type: "string" }, 28 | }, 29 | required: ["memberTags", "subscriberEmail"], 30 | }; 31 | 32 | const eventsConfigSchema = { 33 | id: "/EventsConfig", 34 | type: "object", 35 | properties: { 36 | memberEvents: { 37 | type: "array", 38 | items: { 39 | oneOf: [ 40 | { type: "string" }, 41 | { $ref: multidimensionalSelectorSchema.id }, 42 | ], 43 | }, 44 | }, 45 | subscriberEmail: { type: "string" }, 46 | }, 47 | required: ["memberEvents", "subscriberEmail"], 48 | }; 49 | 50 | const mergeFieldsExtendedConfigSchema = { 51 | id: "/MergeFieldExtendedConfig", 52 | type: "object", 53 | properties: { 54 | mailchimpFieldName: { type: "string" }, 55 | typeConversion: { type: "string", enum: ["none", "timestampToDate", "stringToNumber"] }, 56 | when: { type: "string", enum: ["changed", "always"] }, 57 | }, 58 | required: ["mailchimpFieldName"], 59 | }; 60 | 61 | const mergeFieldsConfigSchema = { 62 | id: "/MergeFieldsConfig", 63 | type: "object", 64 | properties: { 65 | mergeFields: { 66 | type: "object", 67 | additionalProperties: { 68 | oneOf: [ 69 | { type: "string" }, 70 | { $ref: mergeFieldsExtendedConfigSchema.id }, 71 | ], 72 | }, 73 | }, 74 | statusField: { 75 | type: "object", 76 | properties: { 77 | documentPath: { type: "string" }, 78 | statusFormat: { type: "string", enum: ["boolean", "string"] }, 79 | }, 80 | required: ["documentPath"], 81 | }, 82 | subscriberEmail: { type: "string" }, 83 | }, 84 | required: ["mergeFields", "subscriberEmail"], 85 | }; 86 | 87 | v.addSchema( 88 | mergeFieldsExtendedConfigSchema, 89 | mergeFieldsExtendedConfigSchema.id, 90 | ); 91 | v.addSchema(multidimensionalSelectorSchema, multidimensionalSelectorSchema.id); 92 | 93 | exports.validateTagConfig = (tagConfig) => v.validate(tagConfig, tagConfigSchema); 94 | exports.validateEventsConfig = (eventsConfig) => v.validate(eventsConfig, eventsConfigSchema); 95 | exports.validateMergeFieldsConfig = (mergeFieldsConfig) => v.validate( 96 | mergeFieldsConfig, 97 | mergeFieldsConfigSchema, 98 | ); 99 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Version 0.5.4 2 | 3 | - Switch from mailchimp-api-v3 package to @mailchimp/mailchimp_marketing (PR #66) 4 | - Add a configurable retry to allow a higher chance of requests causing transient failures to succeed (PR #69) 5 | - Added linter config and updated packages. (PR #70) 6 | - Node.JS 18 runtime for functions (PR #73) 7 | - GitHub Actions Support to assist with verification and publishing (PR #77) 8 | - Allow type conversion for timestamp and numeric types in merge fields. Additionally, support complex values in merge fields by facilitating path-style merge field names (PR #75). 9 | 10 | ## Version 0.5.3 11 | 12 | - Addressed [breaking change in Google Cloud Functions](https://cloud.google.com/functions/docs/release-notes#April_11_2023) where the build command would run on function deployment (PR #65). 13 | 14 | ## Version 0.5.2 15 | 16 | - Reverted firebase-functions package update to use original handler methods. 17 | 18 | ## Version 0.5.1 19 | 20 | - Resolved bug where duplicate functions were trying to be created on install after firebase-functions package was updated. 21 | 22 | ## Version 0.5.0 23 | 24 | - Support updating Mailchimp when a user changes their email in Firebase (PR #46) 25 | - Support extended syntax for configurations of Member Tags and Member Events, to allow multidimensional arrays to be unwrapped (PR #53) 26 | - Added configuration validation via JSON Schema (PR #54) 27 | - Support extended configuration for Merge Fields, so that fields can be set to continuously sync even when not changed (PR #55) 28 | - Dependencies bump (PR #56) 29 | - Support updating mailchimp subscription status via merge field trigger (PR #57) 30 | - Integrate JMESPath library to allow standardized configuration to fetch from complex object structures. Replaces some work done in PR #53 (PR #58) 31 | 32 | ## Version 0.4.0 33 | 34 | - Runtime and dependencies bump (#45) 35 | - Added test coverage for all handlers (#47) 36 | 37 | ## Version 0.3.0 38 | 39 | - List Mailchimp as an external service in Firebase Extensions configuration (PR #23) 40 | - Support for JSON path for subscriber email config values (PR #37) 41 | - Changed initialization process to facilitate user testing (PR #34) 42 | - Use status_if_new with merge fields updates in case user has not yet been created in Mailchimp (PR #20) 43 | - Added unit test setup for easier verification of features (PR #34) 44 | - Add Warsaw cloud function location (europe-central2) (#16) 45 | 46 | ## Version 0.2.3 47 | 48 | - Update extension name 49 | 50 | ## Version 0.2.2 51 | 52 | - Update author name and source repo url. 53 | 54 | ## Version 0.2.1 55 | 56 | - Update preinstall markdown and extension description. 57 | 58 | ## Version 0.2.0 59 | 60 | - Add new Cloud Functions to support member tags, merge fields, and member events. 61 | 62 | ## Version 0.1.2 63 | 64 | - Add new Cloud Functions locations. For more information about locations and their pricing tiers, refer to the [location selection guide](https://firebase.google.com/docs/functions/locations). 65 | 66 | ## Version 0.1.1 67 | 68 | - Update Cloud Functions runtime to Node.js 10. 69 | 70 | ## Version 0.1.0 71 | 72 | - Initial release of the _Sync with Mailchimp_ extension. 73 | -------------------------------------------------------------------------------- /functions/logs.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2019 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const { logger } = require("firebase-functions"); 18 | const config = require("./config"); 19 | 20 | module.exports = { 21 | complete: () => { 22 | logger.log("Completed execution of extension"); 23 | }, 24 | userAlreadyInAudience: () => { 25 | logger.log("Attempted added user already in mailchimp audience"); 26 | }, 27 | errorAddUser: (err) => { 28 | logger.error("Error when adding user to Mailchimp audience", err); 29 | }, 30 | userNotInAudience: () => { 31 | logger.log( 32 | "Attempted removal failed, member deletion not allowed. Probably because member has already been removed from audience", 33 | ); 34 | }, 35 | errorRemoveUser: (err) => { 36 | logger.error("Error when removing user from Mailchimp audience", err); 37 | }, 38 | init: () => { 39 | logger.log("Initializing extension with configuration", config); 40 | }, 41 | initError: (err) => { 42 | logger.error("Error when initializing extension", err); 43 | }, 44 | mailchimpNotInitialized: () => { 45 | logger.error( 46 | "Mailchimp was not initialized correctly, check for errors in the logs", 47 | ); 48 | }, 49 | start: () => { 50 | logger.log("Started execution of extension with configuration"); 51 | }, 52 | userAdded: (userId, audienceId, mailchimpId, status) => { 53 | logger.log( 54 | `Added user: ${userId} with status '${status}' to Mailchimp audience: ${audienceId} with Mailchimp ID: ${mailchimpId}`, 55 | ); 56 | }, 57 | userAdding: (userId, audienceId) => { 58 | logger.log(`Adding user: ${userId} to Mailchimp audience: ${audienceId}`); 59 | }, 60 | userNoEmail: () => { 61 | logger.log("User does not have an email"); 62 | }, 63 | userRemoved: (userId, hashedEmail, audienceId) => { 64 | logger.log( 65 | `Removed user: ${userId} with hashed email: ${hashedEmail} from Mailchimp audience: ${audienceId}`, 66 | ); 67 | }, 68 | userRemoving: (userId, hashedEmail, audienceId) => { 69 | logger.log( 70 | `Removing user: ${userId} with hashed email: ${hashedEmail} from Mailchimp audience: ${audienceId}`, 71 | ); 72 | }, 73 | attemptFailed: (attempt, retries) => { 74 | if (attempt >= retries) { 75 | let content = `Attempt ${attempt} failed. Max retries (${retries}) reached, failing operation.`; 76 | if (retries === 0) { 77 | content += ` If this looks to be a transient error, please set the MAILCHIMP_RETRY_ATTEMPTS configuration value to non-zero value.`; 78 | } 79 | logger.warn(content); 80 | } else { 81 | logger.warn(`Attempt ${attempt} failed. Waiting to attempt retry of operation. Max retries: ${retries}.`); 82 | } 83 | }, 84 | subsequentAttemptRecovered: (attempt) => { 85 | logger.info(`Attempt ${attempt} succeeded, operation recovered.`); 86 | }, 87 | }; 88 | -------------------------------------------------------------------------------- /POSTINSTALL.md: -------------------------------------------------------------------------------- 1 | ### See it in action 2 | 3 | You can test out this extension right away! 4 | 5 | #### Add/Remove Users 6 | 7 | 1. Go to your [Authentication dashboard](https://console.firebase.google.com/project/${param:PROJECT_ID}/authentication/users) in the Firebase console. 8 | 9 | 1. Click **Add User** to add a test user. 10 | 11 | 1. In a few seconds, go to your Mailchimp audience page, you'll see the test user's email appear. 12 | 13 | #### Member Tags 14 | 1. Go to your [Firestore Database](https://console.firebase.google.com/project/${param:PROJECT_ID}/firestore) in the Firebase console. 15 | 16 | Assuming the following params: 17 | ``` 18 | Member Tags Watch Path: registrations 19 | Member Tags Watch Config: 20 | { 21 | "memberTags": ["jobTitle", "domainKnowledge"], 22 | "subscriberEmail": "emailAddress" 23 | } 24 | ``` 25 | 26 | 1. Click **Start collection** and provide the following name "registrations" 27 | 28 | 1. Click **Add document** and populate the document: 29 | ``` 30 | { 31 | emailAddress: "{MAILCHIMP_SUBSCRIBER_EMAIL_ADDRESS}", 32 | jobTitle: "Marketing Manager" 33 | } 34 | ``` 35 | 36 | 1. Confirm the user data has been updated in the "Tags" portion of your Mailchimp account: https://admin.mailchimp.com/lists/members/view?id={YOUR_MAILCHIMP_ACCOUNT_ID} 37 | 38 | #### Merge Fields 39 | 1. Go to your [Firestore Database](https://console.firebase.google.com/project/${param:PROJECT_ID}/firestore) in the Firebase console. 40 | 41 | Assuming the following params: 42 | ``` 43 | Merge Fields Watch Path: registrations 44 | Merge Fields Watch Config: 45 | { 46 | "mergeFields": { 47 | "firstName": "FNAME", 48 | "lastName": "LNAME", 49 | "phoneNumber": "PHONE" 50 | }, 51 | "subscriberEmail": "emailAddress" 52 | } 53 | ``` 54 | 55 | 1. Click **Start collection** and provide the following name "registrations" 56 | 57 | 1. Click **Add document** and populate the document: 58 | ``` 59 | { 60 | emailAddress: "{MAILCHIMP_SUBSCRIBER_EMAIL_ADDRESS}", 61 | firstName: "Janet", 62 | lastName: "Jones", 63 | phoneNumber: "000-111-2222" 64 | } 65 | ``` 66 | 67 | 1. Confirm the user data has been updated in the "Profile Information" portion of your Mailchimp account: https://admin.mailchimp.com/lists/members/view?id={YOUR_MAILCHIMP_ACCOUNT_ID} 68 | 69 | #### Member Events 70 | 1. Go to your [Firestore Database](https://console.firebase.google.com/project/${param:PROJECT_ID}/firestore) in the Firebase console. 71 | 72 | Assuming the following params: 73 | ``` 74 | Member Events Watch Path: registrations 75 | Member Events Watch Config: 76 | { 77 | "memberEvents": ["activity"], 78 | "subscriberEmail": "emailAddress" 79 | } 80 | ``` 81 | 82 | 1. Click **Start collection** and provide the following name "registrations" 83 | 84 | 1. Click **Add document** and populate the document: 85 | ``` 86 | { 87 | emailAddress: "{MAILCHIMP_SUBSCRIBER_EMAIL_ADDRESS}", 88 | activity: ['training_registration', 'welcome_email', 'reminder_email'] 89 | } 90 | ``` 91 | 92 | 1. Click **Add document** and populate the document: 93 | 94 | 1. Confirm the event has been registered in the "Activity Feed" portion of your Mailchimp account: https://admin.mailchimp.com/lists/members/view?id={YOUR_MAILCHIMP_ACCOUNT_ID} 95 | 96 | ### Using the extension 97 | 98 | Whenever a new user is added your app, this extension adds the user's email address to your specified Mailchimp audience. 99 | 100 | Also, if the user deletes their user account for your app, this extension removes the user from the Mailchimp audience. 101 | 102 | ### Monitoring 103 | 104 | As a best practice, you can [monitor the activity](https://firebase.google.com/docs/extensions/manage-installed-extensions#monitor) of your installed extension, including checks on its health, usage, and logs. 105 | -------------------------------------------------------------------------------- /.github/workflows/scripts/release.sh: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Copied and modified from https://github.com/firebase/extensions/blob/next/.github/workflows/scripts/release.sh. Changes: 16 | # 1. Read the extension name from the extension.yaml file instead of from the directory name. 17 | 18 | #!/bin/bash 19 | set -e 20 | set -o pipefail 21 | 22 | # Uncomment for testing purposes: 23 | 24 | #GITHUB_TOKEN=YOUR_TOKEN_HERE 25 | #GITHUB_REPOSITORY=invertase/extensions-release-testing 26 | 27 | # ------------------- 28 | # Functions 29 | # ------------------- 30 | json_escape() { 31 | printf '%s' "$1" | python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 32 | } 33 | 34 | # Creates a new GitHub release 35 | # ARGS: 36 | # 1: Name of the release (becomes the release title on GitHub) 37 | # 2: Markdown body of the release 38 | # 3: Release Git tag 39 | create_github_release() { 40 | local response='' 41 | local release_name=$1 42 | local release_body=$2 43 | local release_tag=$3 44 | 45 | local body='{ 46 | "tag_name": "%s", 47 | "target_commitish": "master", 48 | "name": "%s", 49 | "body": %s, 50 | "draft": false, 51 | "prerelease": false 52 | }' 53 | 54 | # shellcheck disable=SC2059 55 | body=$(printf "$body" "$release_tag" "$release_name" "$release_body") 56 | response=$(curl --request POST \ 57 | --url https://api.github.com/repos/${GITHUB_REPOSITORY}/releases \ 58 | --header "Authorization: Bearer $GITHUB_TOKEN" \ 59 | --header 'Content-Type: application/json' \ 60 | --data "$body" \ 61 | -s) 62 | 63 | created=$(echo "$response" | python -c "import sys, json; data = json.load(sys.stdin); print(data.get('id', sys.stdin))") 64 | if [ "$created" != "$response" ]; then 65 | printf "release created successfully!\n" 66 | else 67 | printf "release failed to create; " 68 | printf "\n%s\n" "$body" 69 | printf "\n%s\n" "$response" 70 | exit 1 71 | fi 72 | } 73 | 74 | # Updates an existing GitHub release 75 | # ARGS: 76 | # 1: Name of the release (becomes the release title on GitHub) 77 | # 2: Markdown body of the release 78 | # 3: Release Git tag 79 | # 4: ID of the existing release 80 | update_github_release() { 81 | local response='' 82 | local release_name=$1 83 | local release_body=$2 84 | local release_tag=$3 85 | local release_id=$4 86 | 87 | local body='{ 88 | "tag_name": "%s", 89 | "target_commitish": "master", 90 | "name": "%s", 91 | "body": %s, 92 | "draft": false, 93 | "prerelease": false 94 | }' 95 | 96 | # shellcheck disable=SC2059 97 | body=$(printf "$body" "$release_tag" "$release_name" "$release_body") 98 | response=$(curl --request PATCH \ 99 | --url "https://api.github.com/repos/$GITHUB_REPOSITORY/releases/$release_id" \ 100 | --header "Authorization: Bearer $GITHUB_TOKEN" \ 101 | --header 'Content-Type: application/json' \ 102 | --data "$body" \ 103 | -s) 104 | 105 | updated=$(echo "$response" | python -c "import sys, json; data = json.load(sys.stdin); print(data.get('id', sys.stdin))") 106 | if [ "$updated" != "$response" ]; then 107 | printf "release updated successfully!\n" 108 | else 109 | printf "release failed to update; " 110 | printf "\n%s\n" "$body" 111 | printf "\n%s\n" "$response" 112 | exit 1 113 | fi 114 | } 115 | 116 | # Creates or updates a GitHub release 117 | # ARGS: 118 | # 1: Extension name 119 | # 2: Extension version 120 | # 3: Markdown body to use for the release 121 | create_or_update_github_release() { 122 | local response='' 123 | local release_id='' 124 | local extension_name=$1 125 | local extension_version=$2 126 | local release_body=$3 127 | local release_tag="$extension_name-v$extension_version" 128 | local release_name="$extension_name v$extension_version" 129 | 130 | response=$(curl --request GET \ 131 | --url "https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" \ 132 | --header "Authorization: Bearer $GITHUB_TOKEN" \ 133 | --header 'Content-Type: application/json' \ 134 | --data "$body" \ 135 | -s) 136 | 137 | release_id=$(echo "$response" | python -c "import sys, json; data = json.load(sys.stdin); print(data.get('id', 'Not Found'))") 138 | if [ "$release_id" != "Not Found" ]; then 139 | existing_release_body=$(echo "$response" | python -c "import sys, json; data = json.load(sys.stdin); print(data.get('body', ''))") 140 | existing_release_body=$(json_escape "$existing_release_body") 141 | # Only update it if the release body is different (this can happen if a change log is manually updated) 142 | printf "Existing release (%s) found for %s - " "$release_id" "$release_tag" 143 | if [ "$existing_release_body" != "$release_body" ]; then 144 | printf "updating it with updated release body ... " 145 | update_github_release "$release_name" "$release_body" "$release_tag" "$release_id" 146 | else 147 | printf "skipping it as release body is already up to date.\n" 148 | fi 149 | else 150 | response_message=$(echo "$response" | python -c "import sys, json; data = json.load(sys.stdin); print(data.get('message'))") 151 | if [ "$response_message" != "Not Found" ]; then 152 | echo "Failed to query release '$release_name' -> GitHub API request failed with response: $response_message" 153 | echo "$response" 154 | exit 1 155 | else 156 | printf "Creating new release '%s' ... " "$release_tag" 157 | create_github_release "$release_name" "$release_body" "$release_tag" 158 | fi 159 | fi 160 | } 161 | 162 | # ------------------- 163 | # Main Script 164 | # ------------------- 165 | 166 | # Ensure that the GITHUB_TOKEN env variable is defined 167 | if [[ -z "$GITHUB_TOKEN" ]]; then 168 | echo "Missing required GITHUB_TOKEN env variable. Set this on the workflow action or on your local environment." 169 | exit 1 170 | fi 171 | 172 | # Ensure that the GITHUB_REPOSITORY env variable is defined 173 | if [[ -z "$GITHUB_REPOSITORY" ]]; then 174 | echo "Missing required GITHUB_REPOSITORY env variable. Set this on the workflow action or on your local environment." 175 | exit 1 176 | fi 177 | 178 | # Find all extensions based on whether a extension.yaml file exists in the directory 179 | for i in $(find . -type f -name 'extension.yaml' -exec dirname {} \; | sort -u); do 180 | # Pluck extension latest name from yaml file 181 | extension_name=$(awk '/^name: /' "$i/extension.yaml" | sed "s/name: //") 182 | # Pluck extension latest version from yaml file 183 | extension_version=$(awk '/^version: /' "$i/extension.yaml" | sed "s/version: //") 184 | 185 | changelog_contents="No changelog found for this version." 186 | 187 | # Ensure changelog exists 188 | if [ -f "$i/CHANGELOG.md" ]; then 189 | # Pluck out change log contents for the latest extension version 190 | changelog_contents=$(awk -v ver="$extension_version" '/^## Version / { if (p) { exit }; if ($3 == ver) { p=1; next} } p && NF' "$i/CHANGELOG.md") 191 | else 192 | echo "WARNING: A changelog could not be found at $i/CHANGELOG.md - a default entry will be used instead." 193 | fi 194 | 195 | # JSON escape the markdown content for the release body 196 | changelog_contents=$(json_escape "$changelog_contents") 197 | 198 | # Creates a new release if it does not exist 199 | # OR 200 | # Updates an existing release with updated content (allows updating CHANGELOG.md which will update relevant release body) 201 | create_or_update_github_release "$extension_name" "$extension_version" "$changelog_contents" 202 | done 203 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /functions/tests/memberEventsHandler.test.js: -------------------------------------------------------------------------------- 1 | jest.mock("@mailchimp/mailchimp_marketing"); 2 | 3 | const functions = require("firebase-functions-test"); 4 | const mailchimp = require("@mailchimp/mailchimp_marketing"); 5 | const { errorWithStatus, defaultConfig } = require("./utils"); 6 | 7 | const testEnv = functions(); 8 | 9 | // configure config mocks (so we can inject config and try different scenarios) 10 | jest.doMock("../config", () => defaultConfig); 11 | 12 | const api = require("../index"); 13 | 14 | describe("memberEventsHandler", () => { 15 | const configureApi = (config) => { 16 | api.processConfig(config); 17 | }; 18 | 19 | beforeAll(() => { }); 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | mailchimp.lists.createListMemberEvent = jest.fn(); 24 | }); 25 | 26 | afterAll(() => { 27 | testEnv.cleanup(); 28 | }); 29 | 30 | it("should make no calls with empty config", async () => { 31 | configureApi(defaultConfig); 32 | const wrapped = testEnv.wrap(api.memberEventsHandler); 33 | 34 | const testUser = { 35 | uid: "122", 36 | displayName: "lee", 37 | }; 38 | 39 | const result = await wrapped({ 40 | after: { 41 | data: () => testUser, 42 | }, 43 | }); 44 | 45 | expect(result).toBe(undefined); 46 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(0); 47 | }); 48 | 49 | it("should make no calls with config missing memberEvents", async () => { 50 | configureApi({ 51 | ...defaultConfig, 52 | mailchimpMemberEvents: JSON.stringify({ 53 | subscriberEmail: "emailAddress", 54 | }), 55 | }); 56 | const wrapped = testEnv.wrap(api.memberEventsHandler); 57 | 58 | const testUser = { 59 | uid: "122", 60 | displayName: "lee", 61 | }; 62 | 63 | const result = await wrapped({ 64 | after: { 65 | data: () => testUser, 66 | }, 67 | }); 68 | 69 | expect(result).toBe(undefined); 70 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(0); 71 | }); 72 | 73 | it("should make no calls with config specifying invalid memberEvents", async () => { 74 | configureApi({ 75 | ...defaultConfig, 76 | mailchimpMemberEvents: JSON.stringify({ 77 | memberEvents: [{ field1: "test" }], 78 | subscriberEmail: "emailAddress", 79 | }), 80 | }); 81 | const wrapped = testEnv.wrap(api.memberEventsHandler); 82 | 83 | const testUser = { 84 | uid: "122", 85 | displayName: "lee", 86 | emailAddress: "email", 87 | }; 88 | 89 | const result = await wrapped({ 90 | after: { 91 | data: () => testUser, 92 | }, 93 | }); 94 | 95 | expect(result).toBe(undefined); 96 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(0); 97 | }); 98 | 99 | it.each` 100 | retryAttempts 101 | ${0} 102 | ${2} 103 | `("should retry '$retryAttempts' times on operation error", async ({ retryAttempts }) => { 104 | configureApi({ 105 | ...defaultConfig, 106 | mailchimpRetryAttempts: retryAttempts.toString(), 107 | mailchimpMemberEvents: JSON.stringify({ 108 | memberEvents: ["events"], 109 | subscriberEmail: "emailAddress", 110 | }), 111 | }); 112 | const wrapped = testEnv.wrap(api.memberEventsHandler); 113 | 114 | mailchimp.lists.createListMemberEvent.mockImplementation(() => { 115 | throw errorWithStatus(404); 116 | }); 117 | 118 | const beforeUser = { 119 | uid: "122", 120 | displayName: "lee", 121 | emailAddress: "test@example.com", 122 | }; 123 | 124 | const afterUser = { 125 | ...beforeUser, 126 | events: "my string event", 127 | }; 128 | 129 | const result = await wrapped({ 130 | before: { 131 | data: () => beforeUser, 132 | }, 133 | after: { 134 | data: () => afterUser, 135 | }, 136 | }); 137 | 138 | expect(result).toBe(undefined); 139 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(retryAttempts + 1); 140 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 141 | "mailchimpAudienceId", 142 | "55502f40dc8b7c769880b10874abc9d0", 143 | { 144 | name: "my string event", 145 | }, 146 | ); 147 | }, 10000); 148 | 149 | it("should add string events when none were existing", async () => { 150 | configureApi({ 151 | ...defaultConfig, 152 | mailchimpMemberEvents: JSON.stringify({ 153 | memberEvents: ["events"], 154 | subscriberEmail: "emailAddress", 155 | }), 156 | }); 157 | const wrapped = testEnv.wrap(api.memberEventsHandler); 158 | 159 | const beforeUser = { 160 | uid: "122", 161 | displayName: "lee", 162 | emailAddress: "test@example.com", 163 | }; 164 | 165 | const afterUser = { 166 | ...beforeUser, 167 | events: "my string event", 168 | }; 169 | 170 | const result = await wrapped({ 171 | before: { 172 | data: () => beforeUser, 173 | }, 174 | after: { 175 | data: () => afterUser, 176 | }, 177 | }); 178 | 179 | expect(result).toBe(undefined); 180 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(1); 181 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 182 | "mailchimpAudienceId", 183 | "55502f40dc8b7c769880b10874abc9d0", 184 | { 185 | name: "my string event", 186 | }, 187 | ); 188 | }); 189 | 190 | it("should add array events when none were existing", async () => { 191 | configureApi({ 192 | ...defaultConfig, 193 | mailchimpMemberEvents: JSON.stringify({ 194 | memberEvents: ["events"], 195 | subscriberEmail: "emailAddress", 196 | }), 197 | }); 198 | const wrapped = testEnv.wrap(api.memberEventsHandler); 199 | 200 | const beforeUser = { 201 | uid: "122", 202 | displayName: "lee", 203 | emailAddress: "test@example.com", 204 | }; 205 | 206 | const afterUser = { 207 | ...beforeUser, 208 | events: ["my string event"], 209 | }; 210 | 211 | const result = await wrapped({ 212 | before: { 213 | data: () => beforeUser, 214 | }, 215 | after: { 216 | data: () => afterUser, 217 | }, 218 | }); 219 | 220 | expect(result).toBe(undefined); 221 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(1); 222 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 223 | "mailchimpAudienceId", 224 | "55502f40dc8b7c769880b10874abc9d0", 225 | { 226 | name: "my string event", 227 | }, 228 | ); 229 | }); 230 | 231 | it("should add string events from multiple fields when none were existing", async () => { 232 | configureApi({ 233 | ...defaultConfig, 234 | mailchimpMemberEvents: JSON.stringify({ 235 | memberEvents: ["events1", "events2"], 236 | subscriberEmail: "emailAddress", 237 | }), 238 | }); 239 | const wrapped = testEnv.wrap(api.memberEventsHandler); 240 | 241 | const beforeUser = { 242 | uid: "122", 243 | displayName: "lee", 244 | emailAddress: "test@example.com", 245 | }; 246 | 247 | const afterUser = { 248 | ...beforeUser, 249 | events1: "my string event 1", 250 | events2: "my string event 2", 251 | }; 252 | 253 | const result = await wrapped({ 254 | before: { 255 | data: () => beforeUser, 256 | }, 257 | after: { 258 | data: () => afterUser, 259 | }, 260 | }); 261 | 262 | expect(result).toBe(undefined); 263 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(2); 264 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 265 | "mailchimpAudienceId", 266 | "55502f40dc8b7c769880b10874abc9d0", 267 | { 268 | name: "my string event 1", 269 | }, 270 | ); 271 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 272 | "mailchimpAudienceId", 273 | "55502f40dc8b7c769880b10874abc9d0", 274 | { 275 | name: "my string event 2", 276 | }, 277 | ); 278 | }); 279 | 280 | it("should add array of events from multiple fields when none were existing", async () => { 281 | configureApi({ 282 | ...defaultConfig, 283 | mailchimpMemberEvents: JSON.stringify({ 284 | memberEvents: ["events1", "events2"], 285 | subscriberEmail: "emailAddress", 286 | }), 287 | }); 288 | const wrapped = testEnv.wrap(api.memberEventsHandler); 289 | 290 | const beforeUser = { 291 | uid: "122", 292 | displayName: "lee", 293 | emailAddress: "test@example.com", 294 | }; 295 | 296 | const afterUser = { 297 | ...beforeUser, 298 | events1: ["my string event 1"], 299 | events2: ["my string event 2"], 300 | }; 301 | 302 | const result = await wrapped({ 303 | before: { 304 | data: () => beforeUser, 305 | }, 306 | after: { 307 | data: () => afterUser, 308 | }, 309 | }); 310 | 311 | expect(result).toBe(undefined); 312 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(2); 313 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 314 | "mailchimpAudienceId", 315 | "55502f40dc8b7c769880b10874abc9d0", 316 | { 317 | name: "my string event 1", 318 | }, 319 | ); 320 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 321 | "mailchimpAudienceId", 322 | "55502f40dc8b7c769880b10874abc9d0", 323 | { 324 | name: "my string event 2", 325 | }, 326 | ); 327 | }); 328 | 329 | it("should ignore previously sent string field events", async () => { 330 | configureApi({ 331 | ...defaultConfig, 332 | mailchimpMemberEvents: JSON.stringify({ 333 | memberEvents: ["events1", "events2"], 334 | subscriberEmail: "emailAddress", 335 | }), 336 | }); 337 | const wrapped = testEnv.wrap(api.memberEventsHandler); 338 | 339 | const beforeUser = { 340 | uid: "122", 341 | displayName: "lee", 342 | emailAddress: "test@example.com", 343 | events1: "my string event 1", 344 | events2: "my string event 2", 345 | }; 346 | 347 | const afterUser = { 348 | ...beforeUser, 349 | events1: "my string event 3", 350 | }; 351 | 352 | const result = await wrapped({ 353 | before: { 354 | data: () => beforeUser, 355 | }, 356 | after: { 357 | data: () => afterUser, 358 | }, 359 | }); 360 | 361 | expect(result).toBe(undefined); 362 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(1); 363 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 364 | "mailchimpAudienceId", 365 | "55502f40dc8b7c769880b10874abc9d0", 366 | { 367 | name: "my string event 3", 368 | }, 369 | ); 370 | }); 371 | 372 | it("should ignore previously sent string field events", async () => { 373 | configureApi({ 374 | ...defaultConfig, 375 | mailchimpMemberEvents: JSON.stringify({ 376 | memberEvents: ["events1", "events2"], 377 | subscriberEmail: "emailAddress", 378 | }), 379 | }); 380 | const wrapped = testEnv.wrap(api.memberEventsHandler); 381 | 382 | const beforeUser = { 383 | uid: "122", 384 | displayName: "lee", 385 | emailAddress: "test@example.com", 386 | events1: ["my string event 1"], 387 | events2: ["my string event 2"], 388 | }; 389 | 390 | const afterUser = { 391 | ...beforeUser, 392 | events1: ["my string event 1", "my string event 3"], 393 | events2: ["my string event 4"], 394 | }; 395 | 396 | const result = await wrapped({ 397 | before: { 398 | data: () => beforeUser, 399 | }, 400 | after: { 401 | data: () => afterUser, 402 | }, 403 | }); 404 | 405 | expect(result).toBe(undefined); 406 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(2); 407 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 408 | "mailchimpAudienceId", 409 | "55502f40dc8b7c769880b10874abc9d0", 410 | { 411 | name: "my string event 3", 412 | }, 413 | ); 414 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 415 | "mailchimpAudienceId", 416 | "55502f40dc8b7c769880b10874abc9d0", 417 | { 418 | name: "my string event 4", 419 | }, 420 | ); 421 | }); 422 | 423 | it("should use verbose config to determine events", async () => { 424 | configureApi({ 425 | ...defaultConfig, 426 | mailchimpMemberEvents: JSON.stringify({ 427 | memberEvents: [ 428 | "events1", 429 | { documentPath: "events2" }, 430 | { documentPath: "events3[*].eventKey" }, 431 | ], 432 | subscriberEmail: "emailAddress", 433 | }), 434 | }); 435 | const wrapped = testEnv.wrap(api.memberEventsHandler); 436 | 437 | const beforeUser = { 438 | uid: "122", 439 | displayName: "lee", 440 | emailAddress: "test@example.com", 441 | events1: ["my string event 1"], 442 | events2: ["my string event 2"], 443 | events3: [ 444 | { eventKey: "my string event 3" }, 445 | { eventKey: "my string event 4" }, 446 | ], 447 | }; 448 | 449 | const afterUser = { 450 | ...beforeUser, 451 | events1: ["my string event 1", "my string event 5"], 452 | events2: ["my string event 6"], 453 | events3: [ 454 | { eventKey: "my string event 3" }, 455 | { eventKey: "my string event 7" }, 456 | ], 457 | }; 458 | 459 | const result = await wrapped({ 460 | before: { 461 | data: () => beforeUser, 462 | }, 463 | after: { 464 | data: () => afterUser, 465 | }, 466 | }); 467 | 468 | expect(result).toBe(undefined); 469 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledTimes(3); 470 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 471 | "mailchimpAudienceId", 472 | "55502f40dc8b7c769880b10874abc9d0", 473 | { 474 | name: "my string event 5", 475 | }, 476 | ); 477 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 478 | "mailchimpAudienceId", 479 | "55502f40dc8b7c769880b10874abc9d0", 480 | { 481 | name: "my string event 6", 482 | }, 483 | ); 484 | expect(mailchimp.lists.createListMemberEvent).toHaveBeenCalledWith( 485 | "mailchimpAudienceId", 486 | "55502f40dc8b7c769880b10874abc9d0", 487 | { 488 | name: "my string event 7", 489 | }, 490 | ); 491 | }); 492 | }); 493 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Manage Marketing with Mailchimp 2 | 3 | **Author**: Mailchimp (**[https://mailchimp.com](https://mailchimp.com)**) 4 | 5 | **Description**: Syncs user data with a Mailchimp audience for sending personalized email marketing campaigns. 6 | 7 | 8 | 9 | **Details**: Use this extension to: 10 | 11 | - Add new users to an existing Mailchimp audience 12 | - Remove user from an existing Mailchimp audience 13 | - Associate member tags with a Mailchimp subscriber 14 | - Use merge fields to sync user data with a Mailchimp subscriber 15 | - Set member events to trigger Mailchimp actions and automations 16 | 17 | #### Additional setup 18 | 19 | Make sure that you've set up [Firebase Authentication](https://firebase.google.com/docs/auth) to manage your users. 20 | 21 | You must also have a Mailchimp account before installing this extension. 22 | 23 | #### Billing 24 | 25 | This extension uses the following Firebase services which may have associated charges: 26 | 27 | - Cloud Firestore 28 | - Cloud Functions 29 | - Firebase Authentication 30 | 31 | This extension also uses the following third-party services: 32 | 33 | - Mailchimp Billing ([pricing information](https://mailchimp.com/pricing)) 34 | 35 | You are responsible for any costs associated with your use of these services. 36 | 37 | #### Note from Firebase 38 | 39 | To install this extension, your Firebase project must be on the Blaze (pay-as-you-go) plan. You will only be charged for the resources you use. Most Firebase services offer a free tier for low-volume use. [Learn more about Firebase billing.](https://firebase.google.com/pricing) 40 | 41 | You will be billed a small amount (typically less than $0.10) when you install or reconfigure this extension. See the [Cloud Functions for Firebase billing FAQ](https://firebase.google.com/support/faq#expandable-15) for a detailed explanation. 42 | 43 | 44 | 45 | 46 | **Configuration Parameters:** 47 | 48 | * Cloud Functions location: Where do you want to deploy the functions created for this extension? 49 | 50 | * Mailchimp OAuth Token: To obtain a Mailchimp OAuth Token, click [here](https://firebase.mailchimp.com/index.html). 51 | 52 | * Audience ID: What is the Mailchimp Audience ID to which you want to subscribe new users? To find your Audience ID: visit https://admin.mailchimp.com/lists, click on the desired audience or create a new audience, then select **Settings**. Look for **Audience ID** (for example, `27735fc60a`). 53 | 54 | * Mailchimp Retry Attempts: The number of attempts to retry operation against Mailchimp. Race conditions can occur between user creation events and user update events, and this allows the extension to retry operations that failed transiently. Currently this is limited to 404 responses for removeUserFromList, memberTagsHandler, mergeFieldsHandler and memberEventsHandler calls. 55 | 56 | * Contact status: When the extension adds a new user to the Mailchimp audience, what is their initial status? This value can be `subscribed` or `pending`. `subscribed` means the user can receive campaigns; `pending` means the user still needs to opt-in to receive campaigns. 57 | 58 | * Firebase Member Tags Watch Path: The Firestore collection to watch for member tag changes 59 | 60 | * Firebase Member Tags Config: Provide a configuration mapping in JSON format indicating which Firestore event(s) to listen for and associate as Mailchimp member tags. 61 | 62 | Required Fields: 63 | 1) `memberTags` - The Firestore document fields(s) to retrieve data from and classify as subscriber tags in Mailchimp. Acceptable data types include: 64 | 65 | - `Array` - The extension will lookup the values in the provided fields and update the subscriber's member tags with the respective data values. The format of each string can be any valid [JMES Path query](https://jmespath.org/). e.g. ["primaryTags", "additionalTags", "tags.primary"] 66 | 67 | - `Array` - An extended object configuration is supported with the following fields: 68 | - `documentPath` - (required) The path to the field in the document containing tag/s, as a string. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. "primaryTags", "tags.primary". 69 | 70 | 2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp 71 | 72 | Configuration Example: 73 | ```json 74 | { 75 | "memberTags": ["domainKnowledge", "jobTitle"], 76 | "subscriberEmail": "emailAddress" 77 | } 78 | ``` 79 | 80 | Or via equivalent extended syntax: 81 | ```json 82 | { 83 | "memberTags": [{ "documentPath": "domainKnowledge" }, { "documentPath": "jobTitle" }], 84 | "subscriberEmail": "emailAddress" 85 | } 86 | ``` 87 | Based on the sample configuration, if the following Firestore document is provided: 88 | ```json 89 | { 90 | "firstName": "..", 91 | "lastName": "..", 92 | "phoneNumber": "..", 93 | "courseName": "..", 94 | "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field 95 | "jobTitle": "..", // The config property 'memberTags' maps to this document field 96 | "domainKnowledge": "..", // The config property 'memberTags' maps to this document field 97 | "activity": [] 98 | } 99 | ``` 100 | Any data associated with the mapped fields (i.e. `domainKnowledge` and `jobTitle`) will be considered Member Tags and the Mailchimp user's profile will be updated accordingly. 101 | For complex documents such as: 102 | ```json 103 | { 104 | "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field 105 | "meta": { 106 | "tags": [{ 107 | "label": "Red", 108 | "value": "red" 109 | },{ 110 | "label": "Team 1", 111 | "value": "team1" 112 | }] 113 | } 114 | } 115 | ``` 116 | A configuration of the following will allow for the tag values of "red", "team1" to be sent to Mailchimp: 117 | ```json 118 | { 119 | "memberTags": [{ "documentPath": "meta.tags[*].value" }], 120 | "subscriberEmail": "emailAddress" 121 | } 122 | ``` 123 | NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. 124 | 125 | * Firebase Merge Fields Watch Path: The Firestore collection to watch for merge field changes 126 | 127 | * Firebase Merge Fields Config: Provide a configuration mapping in JSON format indicating which Firestore event(s) to listen for and associate as Mailchimp merge fields. 128 | 129 | Required Fields: 130 | 1) `mergeFields` - JSON mapping representing the Firestore document fields to associate with Mailchimp Merge Fields. The key format can be any valid [JMES Path query](https://jmespath.org/) as a string. The value must be the name of a Mailchimp Merge Field as a string, or an object with the following properties: 131 | 132 | - `mailchimpFieldName` - (required) The name of the Mailchimp Merge Field to map to, e.g. "FNAME". Paths are allowed, e.g. "ADDRESS.addr1" will map to an "ADDRESS" object. 133 | 134 | - `typeConversion` - (optional) Whether to apply a type conversion to the value found at documentPath. Valid options: 135 | 136 | - `none`: no conversion is applied. 137 | 138 | - `timestampToDate`: Converts from a [Firebase Timestamp](https://firebase.google.com/docs/reference/android/com/google/firebase/Timestamp) to YYYY-MM-DD format (UTC). 139 | 140 | - `stringToNumber`: Converts to a number. 141 | 142 | - `when` - (optional) When to send the value of the field to Mailchimp. Options are "always" (which will send the value of this field on _any_ change to the document, not just this field) or "changed". Default is "changed". 143 | 144 | 2) `statusField` - An optional configuration setting for syncing the users mailchimp status. Properties are: 145 | 146 | - `documentPath` - (required) The path to the field in the document containing the users status, as a string. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. "status", "meta.status". 147 | 148 | - `statusFormat` - (optional) Indicates the format that the status field is. The options are: 149 | - `"string"` - The default, this will sync the value from the status field as is, with no modification. 150 | - `"boolean"` - This will check if the value is truthy (e.g. true, 1, "subscribed"), and if so will resolve the status to "subscribed", otherwise it will resolve to "unsubscribed". 151 | 152 | 3) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp 153 | 154 | Configuration Example: 155 | ```json 156 | { 157 | "mergeFields": { 158 | "firstName": "FNAME", 159 | "lastName": "LNAME", 160 | "phoneNumber": "PHONE" 161 | }, 162 | "subscriberEmail": "emailAddress" 163 | } 164 | ``` 165 | Or via equivalent extended syntax: 166 | ```json 167 | { 168 | "mergeFields": { 169 | "firstName": { "mailchimpFieldName": "FNAME" }, 170 | "lastName":{ "mailchimpFieldName": "LNAME" }, 171 | "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } 172 | }, 173 | "subscriberEmail": "emailAddress" 174 | } 175 | ``` 176 | 177 | Based on the sample configuration, if the following Firestore document is provided: 178 | ```json 179 | { 180 | "firstName": "..", // The config property FNAME maps to this document field 181 | "lastName": "..", // The config property LNAME maps to this document field 182 | "phoneNumber": "..", // The config property PHONE maps to this document field 183 | "emailAddress": "..", // The config property "subscriberEmail" maps to this document field 184 | "jobTitle": "..", 185 | "domainKnowledge": "..", 186 | "activity": [] 187 | } 188 | ``` 189 | 190 | Any data associated with the mapped fields (i.e. firstName, lastName, phoneNumber) will be considered Merge Fields and the Mailchimp user's profile will be updated accordingly. 191 | If there is a requirement to always send the firstName and lastName values, the `"when": "always"` configuration option can be set on those fields, like so: 192 | ```json 193 | { 194 | "mergeFields": { 195 | "firstName": { "mailchimpFieldName": "FNAME", "when": "always" }, 196 | "lastName":{ "mailchimpFieldName": "LNAME", "when": "always" }, 197 | "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } 198 | }, 199 | "subscriberEmail": "emailAddress" 200 | } 201 | ``` 202 | This can be handy if Firebase needs to remain the source or truth or if the extension has been installed after data is already in the collection and there is a data migration period. 203 | If the users status is also captured in the Firestore document, the status can be updated in Mailchimp by using the following configuration: 204 | ```json 205 | { 206 | "statusField": { 207 | "documentPath": "meta.status", 208 | "statusFormat": "string", 209 | }, 210 | "subscriberEmail": "emailAddress" 211 | } 212 | ``` 213 | This can be as well, or instead of, the `mergeFields` configuration property being set. 214 | NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. 215 | 216 | * Firebase Member Events Watch Path: The Firestore collection to watch for member event changes 217 | 218 | * Firebase Member Events Config: Provide a configuration mapping in JSON format indicating which Firestore event(s) to listen for and associate as Mailchimp merge events. 219 | 220 | Required Fields: 221 | 1) `memberEvents` - The Firestore document fields(s) to retrieve data from and classify as member events in Mailchimp. Acceptable data types include: 222 | 223 | - `Array` - The extension will lookup the values (mailchimp event names) in the provided fields and post those events to Mailchimp on the subscriber's activity feed. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. ["events", "meta.events"] 224 | 225 | - `Array` - An extended object configuration is supported with the following fields: 226 | - `documentPath` - (required) The path to the field in the document containing events. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. "events", "meta.events". 227 | 228 | 2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp 229 | 230 | Configuration Example: 231 | ```json 232 | { 233 | "memberEvents": [ 234 | "activity" 235 | ], 236 | "subscriberEmail": "emailAddress" 237 | } 238 | ``` 239 | Or via equivalent extended syntax: 240 | ```json 241 | { 242 | "memberEvents": [{ "documentPath": "activity" }], 243 | "subscriberEmail": "emailAddress" 244 | } 245 | ``` 246 | Based on the sample configuration, if the following Firestore document is provided: 247 | ```json 248 | { 249 | "firstName": "..", 250 | "lastName": "..", 251 | "phoneNumber": "..", 252 | "courseName": "..", 253 | "jobTitle": "..", 254 | "domainKnowledge": "..", 255 | "emailAddress": "..", // The config property "subscriberEmail" maps to this document field 256 | "activity": ["send_welcome_email"] // The config property "memberTags" maps to this document field 257 | } 258 | ``` 259 | Any data associated with the mapped fields (i.e. `activity`) will be considered events and the Mailchimp user's profile will be updated accordingly. 260 | For complex documents such as: 261 | ```json 262 | { 263 | "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field 264 | "meta": { 265 | "events": [{ 266 | "title": "Registered", 267 | "date": "2021-10-08T00:00:00Z" 268 | },{ 269 | "title": "Invited Friend", 270 | "date": "2021-10-09T00:00:00Z" 271 | }] 272 | } 273 | } 274 | ``` 275 | A configuration of the following will allow for the events of "Registered", "Invited Friend" to be sent to Mailchimp: 276 | ```json 277 | { 278 | "memberEvents": [{ "documentPath": "meta.events[*].title" }], 279 | "subscriberEmail": "emailAddress" 280 | } 281 | ``` 282 | NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. 283 | 284 | 285 | 286 | **Cloud Functions:** 287 | 288 | * **addUserToList:** Listens for new user accounts (as managed by Firebase Authentication), then automatically adds the new user to your specified MailChimp audience. 289 | 290 | * **removeUserFromList:** Listens for existing user accounts to be deleted (as managed by Firebase Authentication), then automatically removes them from your specified MailChimp audience. 291 | 292 | * **memberTagsHandler:** Member Tags provide the ability to associate "metadata" or "labels" with a Mailchimp subscriber. The memberTagsHandler function listens for Firestore write events based on specified config path, then automatically classifies the document data as Mailchimp subscriber tags. 293 | 294 | * **mergeFieldsHandler:** Merge fields provide the ability to create new properties that can be associated with Mailchimp subscriber. The mergeFieldsHandler function listens for Firestore write events based on specified config path, then automatically populates the Mailchimp subscriber's respective merge fields. 295 | 296 | * **memberEventsHandler:** Member events are Mailchimp specific activity events that can be created and associated with a predefined action. The memberEventsHandler function Listens for Firestore write events based on specified config path, then automatically uses the document data to create a Mailchimp event on the subscriber's profile which can subsequently trigger automation workflows. 297 | -------------------------------------------------------------------------------- /functions/tests/memberTagsHandler.test.js: -------------------------------------------------------------------------------- 1 | jest.mock("@mailchimp/mailchimp_marketing"); 2 | 3 | const functions = require("firebase-functions-test"); 4 | const mailchimp = require("@mailchimp/mailchimp_marketing"); 5 | const { errorWithStatus, defaultConfig } = require("./utils"); 6 | 7 | const testEnv = functions(); 8 | 9 | // configure config mocks (so we can inject config and try different scenarios) 10 | jest.doMock("../config", () => defaultConfig); 11 | 12 | const api = require("../index"); 13 | 14 | describe("memberTagsHandler", () => { 15 | const configureApi = (config) => { 16 | api.processConfig(config); 17 | }; 18 | 19 | beforeEach(() => { 20 | jest.clearAllMocks(); 21 | mailchimp.lists.updateListMemberTags = jest.fn(); 22 | }); 23 | 24 | afterAll(() => { 25 | testEnv.cleanup(); 26 | }); 27 | 28 | it("should make no calls with empty config", async () => { 29 | configureApi(defaultConfig); 30 | const wrapped = testEnv.wrap(api.memberTagsHandler); 31 | 32 | const testUser = { 33 | uid: "122", 34 | displayName: "lee", 35 | }; 36 | 37 | const result = await wrapped({ 38 | after: { 39 | data: () => testUser, 40 | }, 41 | }); 42 | 43 | expect(result).toBe(undefined); 44 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(0); 45 | }); 46 | 47 | it("should make no calls with config missing memberEvents", async () => { 48 | configureApi({ 49 | ...defaultConfig, 50 | mailchimpMemberTags: JSON.stringify({ 51 | subscriberEmail: "emailAddress", 52 | }), 53 | }); 54 | const wrapped = testEnv.wrap(api.memberTagsHandler); 55 | 56 | const testUser = { 57 | displayName: "lee", 58 | emailAddress: "email", 59 | tag_data_1: "tagValue1", 60 | tag_data_2: "tagValue2", 61 | }; 62 | 63 | const result = await wrapped({ 64 | after: { 65 | data: () => testUser, 66 | }, 67 | }); 68 | 69 | expect(result).toBe(undefined); 70 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(0); 71 | }); 72 | 73 | it("should make no calls with config specifying invalid memberTags", async () => { 74 | configureApi({ 75 | ...defaultConfig, 76 | mailchimpMemberTags: JSON.stringify({ 77 | memberTags: [{ field1: "test" }], 78 | subscriberEmail: "emailAddress", 79 | }), 80 | }); 81 | const wrapped = testEnv.wrap(api.memberTagsHandler); 82 | 83 | const testUser = { 84 | displayName: "lee", 85 | emailAddress: "email", 86 | tag_data_1: "tagValue1", 87 | tag_data_2: "tagValue2", 88 | }; 89 | 90 | const result = await wrapped({ 91 | after: { 92 | data: () => testUser, 93 | }, 94 | }); 95 | 96 | expect(result).toBe(undefined); 97 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(0); 98 | }); 99 | 100 | it("should make no calls when subscriberEmail field not found in document", async () => { 101 | configureApi({ 102 | ...defaultConfig, 103 | mailchimpMemberTags: JSON.stringify({ 104 | memberTags: ["tag_data_1"], 105 | subscriberEmail: "email", 106 | }), 107 | }); 108 | const wrapped = testEnv.wrap(api.memberTagsHandler); 109 | 110 | const testUser = { 111 | displayName: "lee", 112 | tag_data_1: "tagValue1", 113 | tag_data_2: "tagValue2", 114 | }; 115 | 116 | const result = await wrapped({ 117 | after: { 118 | data: () => testUser, 119 | }, 120 | }); 121 | 122 | expect(result).toBe(undefined); 123 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(0); 124 | }); 125 | 126 | it("should set tags for new user", async () => { 127 | configureApi({ 128 | ...defaultConfig, 129 | mailchimpMemberTags: JSON.stringify({ 130 | memberTags: ["tag_data_1", "tag_data_2"], 131 | subscriberEmail: "emailAddress", 132 | }), 133 | }); 134 | const wrapped = testEnv.wrap(api.memberTagsHandler); 135 | 136 | const testUser = { 137 | uid: "122", 138 | displayName: "lee", 139 | emailAddress: "test@example.com", 140 | tag_data_1: "tagValue1", 141 | tag_data_2: "tagValue2", 142 | }; 143 | 144 | const result = await wrapped({ 145 | after: { 146 | data: () => testUser, 147 | }, 148 | }); 149 | 150 | expect(result).toBe(undefined); 151 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(1); 152 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 153 | "mailchimpAudienceId", 154 | "55502f40dc8b7c769880b10874abc9d0", 155 | { 156 | tags: [ 157 | { name: "tagValue1", status: "active" }, 158 | { name: "tagValue2", status: "active" }, 159 | ], 160 | }, 161 | ); 162 | }); 163 | 164 | it.each` 165 | retryAttempts 166 | ${0} 167 | ${2} 168 | `("should retry '$retryAttempts' times on operation error", async ({ retryAttempts }) => { 169 | configureApi({ 170 | ...defaultConfig, 171 | mailchimpRetryAttempts: retryAttempts.toString(), 172 | mailchimpMemberTags: JSON.stringify({ 173 | memberTags: ["tag_data_1", "tag_data_2"], 174 | subscriberEmail: "emailAddress", 175 | }), 176 | }); 177 | const wrapped = testEnv.wrap(api.memberTagsHandler); 178 | 179 | mailchimp.lists.updateListMemberTags.mockImplementation(() => { 180 | throw errorWithStatus(404); 181 | }); 182 | 183 | const testUser = { 184 | uid: "122", 185 | displayName: "lee", 186 | emailAddress: "test@example.com", 187 | tag_data_1: "tagValue1", 188 | tag_data_2: "tagValue2", 189 | }; 190 | 191 | const result = await wrapped({ 192 | after: { 193 | data: () => testUser, 194 | }, 195 | }); 196 | 197 | expect(result).toBe(undefined); 198 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(retryAttempts + 1); 199 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 200 | "mailchimpAudienceId", 201 | "55502f40dc8b7c769880b10874abc9d0", 202 | { 203 | tags: [ 204 | { name: "tagValue1", status: "active" }, 205 | { name: "tagValue2", status: "active" }, 206 | ], 207 | }, 208 | ); 209 | }, 10000); 210 | 211 | it("should set tags for new user with nested subscriber email", async () => { 212 | configureApi({ 213 | ...defaultConfig, 214 | mailchimpMemberTags: JSON.stringify({ 215 | memberTags: ["tag_data_1", "tag_data_2"], 216 | subscriberEmail: "contactInfo.emailAddress", 217 | }), 218 | }); 219 | const wrapped = testEnv.wrap(api.memberTagsHandler); 220 | 221 | const testUser = { 222 | uid: "122", 223 | displayName: "lee", 224 | contactInfo: { 225 | emailAddress: "test@example.com", 226 | }, 227 | tag_data_1: "tagValue1", 228 | tag_data_2: "tagValue2", 229 | }; 230 | 231 | const result = await wrapped({ 232 | after: { 233 | data: () => testUser, 234 | }, 235 | }); 236 | 237 | expect(result).toBe(undefined); 238 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(1); 239 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 240 | "mailchimpAudienceId", 241 | "55502f40dc8b7c769880b10874abc9d0", 242 | { 243 | tags: [ 244 | { name: "tagValue1", status: "active" }, 245 | { name: "tagValue2", status: "active" }, 246 | ], 247 | }, 248 | ); 249 | }); 250 | 251 | it("should set tags from nested config for new user", async () => { 252 | configureApi({ 253 | ...defaultConfig, 254 | mailchimpMemberTags: JSON.stringify({ 255 | memberTags: ["tag_data.field_1", "tag_data.field_2"], 256 | subscriberEmail: "emailAddress", 257 | }), 258 | }); 259 | const wrapped = testEnv.wrap(api.memberTagsHandler); 260 | 261 | const testUser = { 262 | uid: "122", 263 | displayName: "lee", 264 | emailAddress: "test@example.com", 265 | tag_data: { 266 | field_1: "tagValue1", 267 | field_2: "tagValue2", 268 | }, 269 | }; 270 | 271 | const result = await wrapped({ 272 | after: { 273 | data: () => testUser, 274 | }, 275 | }); 276 | 277 | expect(result).toBe(undefined); 278 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(1); 279 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 280 | "mailchimpAudienceId", 281 | "55502f40dc8b7c769880b10874abc9d0", 282 | { 283 | tags: [ 284 | { name: "tagValue1", status: "active" }, 285 | { name: "tagValue2", status: "active" }, 286 | ], 287 | }, 288 | ); 289 | }); 290 | 291 | it("should set tags from multidimensional nested config for new user", async () => { 292 | configureApi({ 293 | ...defaultConfig, 294 | mailchimpMemberTags: JSON.stringify({ 295 | memberTags: [ 296 | "tag_data.field_1", 297 | { documentPath: "tag_data.field_2[*].value" }, 298 | ], 299 | subscriberEmail: "emailAddress", 300 | }), 301 | }); 302 | const wrapped = testEnv.wrap(api.memberTagsHandler); 303 | 304 | const testUser = { 305 | uid: "122", 306 | displayName: "lee", 307 | emailAddress: "test@example.com", 308 | tag_data: { 309 | field_1: "tagValue1", 310 | field_2: [ 311 | { label: "label_1", value: "value_1" }, 312 | { label: "label_2", value: "value_2" }, 313 | { label: "label_3", value: "value_3" }, 314 | ], 315 | }, 316 | }; 317 | 318 | const result = await wrapped({ 319 | after: { 320 | data: () => testUser, 321 | }, 322 | }); 323 | 324 | expect(result).toBe(undefined); 325 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(1); 326 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 327 | "mailchimpAudienceId", 328 | "55502f40dc8b7c769880b10874abc9d0", 329 | { 330 | tags: [ 331 | { name: "tagValue1", status: "active" }, 332 | { name: "value_1", status: "active" }, 333 | { name: "value_2", status: "active" }, 334 | { name: "value_3", status: "active" }, 335 | ], 336 | }, 337 | ); 338 | }); 339 | 340 | it("should update tags for changed user", async () => { 341 | configureApi({ 342 | ...defaultConfig, 343 | mailchimpMemberTags: JSON.stringify({ 344 | memberTags: ["tag_data_1", "tag_data_2"], 345 | subscriberEmail: "emailAddress", 346 | }), 347 | }); 348 | const wrapped = testEnv.wrap(api.memberTagsHandler); 349 | 350 | const existingUser = { 351 | uid: "122", 352 | displayName: "lee", 353 | emailAddress: "test@example.com", 354 | tag_data_1: "tagValue1", 355 | tag_data_2: "tagValue2", 356 | }; 357 | 358 | const updatedUser = { 359 | uid: "122", 360 | displayName: "lee", 361 | emailAddress: "test@example.com", 362 | tag_data_1: "tagValue3", 363 | tag_data_2: "tagValue4", 364 | }; 365 | 366 | const result = await wrapped({ 367 | before: { 368 | data: () => existingUser, 369 | }, 370 | after: { 371 | data: () => updatedUser, 372 | }, 373 | }); 374 | 375 | expect(result).toBe(undefined); 376 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(1); 377 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 378 | "mailchimpAudienceId", 379 | "55502f40dc8b7c769880b10874abc9d0", 380 | { 381 | tags: [ 382 | { name: "tagValue1", status: "inactive" }, 383 | { name: "tagValue2", status: "inactive" }, 384 | { name: "tagValue3", status: "active" }, 385 | { name: "tagValue4", status: "active" }, 386 | ], 387 | }, 388 | ); 389 | }); 390 | 391 | it("should use old email for hash if email field changed", async () => { 392 | configureApi({ 393 | ...defaultConfig, 394 | mailchimpMemberTags: JSON.stringify({ 395 | memberTags: ["tag_data_1", "tag_data_2"], 396 | subscriberEmail: "emailAddress", 397 | }), 398 | }); 399 | const wrapped = testEnv.wrap(api.memberTagsHandler); 400 | 401 | const existingUser = { 402 | uid: "122", 403 | displayName: "lee", 404 | emailAddress: "test@example.com", 405 | tag_data_1: "tagValue1", 406 | tag_data_2: "tagValue2", 407 | }; 408 | 409 | const updatedUser = { 410 | uid: "122", 411 | displayName: "lee", 412 | emailAddress: "test2@example.com", 413 | tag_data_1: "tagValue3", 414 | tag_data_2: "tagValue4", 415 | }; 416 | 417 | const result = await wrapped({ 418 | before: { 419 | data: () => existingUser, 420 | }, 421 | after: { 422 | data: () => updatedUser, 423 | }, 424 | }); 425 | 426 | expect(result).toBe(undefined); 427 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(1); 428 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 429 | "mailchimpAudienceId", 430 | "55502f40dc8b7c769880b10874abc9d0", 431 | { 432 | tags: [ 433 | { name: "tagValue1", status: "inactive" }, 434 | { name: "tagValue2", status: "inactive" }, 435 | { name: "tagValue3", status: "active" }, 436 | { name: "tagValue4", status: "active" }, 437 | ], 438 | }, 439 | ); 440 | }); 441 | 442 | it("should update multiple tag fields", async () => { 443 | configureApi({ 444 | ...defaultConfig, 445 | mailchimpMemberTags: `{ "memberTags": ["tag_field_1", "tag_field_2", "tag_field_3"], "subscriberEmail": "email"}`, 446 | }); 447 | const wrapped = testEnv.wrap(api.memberTagsHandler); 448 | 449 | const existingUser = { 450 | uid: "122", 451 | displayName: "lee", 452 | email: "test@example.com", 453 | }; 454 | 455 | const updatedUser = { 456 | uid: "122", 457 | displayName: "lee", 458 | email: "test@example.com", 459 | tag_field_1: "data_1", 460 | tag_field_2: ["data_2", "data_3"], 461 | tag_field_3: "data_4", 462 | }; 463 | 464 | const result = await wrapped({ 465 | before: { 466 | data: () => existingUser, 467 | }, 468 | after: { 469 | data: () => updatedUser, 470 | }, 471 | }); 472 | 473 | expect(result).toBe(undefined); 474 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledTimes(1); 475 | expect(mailchimp.lists.updateListMemberTags).toHaveBeenCalledWith( 476 | "mailchimpAudienceId", 477 | "55502f40dc8b7c769880b10874abc9d0", 478 | { 479 | tags: [ 480 | { name: "data_1", status: "active" }, 481 | { name: "data_2", status: "active" }, 482 | { name: "data_3", status: "active" }, 483 | { name: "data_4", status: "active" }, 484 | ], 485 | }, 486 | ); 487 | }); 488 | }); 489 | -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const assert = require("assert"); 3 | const _ = require("lodash"); 4 | const { auth, firestore, logger } = require("firebase-functions"); 5 | const admin = require("firebase-admin"); 6 | const mailchimp = require("@mailchimp/mailchimp_marketing"); 7 | const jmespath = require("jmespath"); 8 | const validation = require("./validation"); 9 | 10 | let config = require("./config"); 11 | const logs = require("./logs"); 12 | 13 | const CONFIG_PARAMS = Object.freeze({ 14 | mailchimpMemberTags: validation.validateTagConfig, 15 | mailchimpMergeField: validation.validateMergeFieldsConfig, 16 | mailchimpMemberEvents: validation.validateEventsConfig, 17 | }); 18 | 19 | admin.initializeApp(); 20 | logs.init(); 21 | 22 | function processConfig(configInput) { 23 | // extension.yaml receives serialized JSON inputs representing configuration settings 24 | // for merge fields, tags, and custom events. the following code deserialized the JSON 25 | // inputs and builds a configuration object with each custom setting path 26 | // (tags, merge fields, custom events) at the root. 27 | config = Object.entries(configInput).reduce((acc, [key, value]) => { 28 | const logError = (message) => { 29 | logger.log(message, key, value); 30 | return acc; 31 | }; 32 | if (configInput[key] && Object.keys(CONFIG_PARAMS).includes(key)) { 33 | const parsedConfig = JSON.parse(configInput[key]); 34 | if (!configInput[`${key}WatchPath`]) { 35 | // Firebase functions must listen to a document path 36 | // As such, a specific id (users/marie) or wildcard path (users/{userId}) must be specified 37 | // https://firebase.google.com/docs/firestore/extend-with-functions#wildcards-parameters 38 | return logError(`${key}WatchPath config property is undefined. Please ensure a proper watch path param has been provided.`); 39 | } 40 | if (configInput[`${key}WatchPath`] === "N/A") { 41 | // The Firebase platform requires a watch path to be provided conforming to a 42 | // regular expression string/string. However, given this Mailchimp extension 43 | // represents a suite of features, it's possible a user will not utilize all of them 44 | // As such, when a watch path of "N/A" is provided as input, it serves as an indicator 45 | // to skip this feature and treat the function as NO-OP. 46 | return logError(`${key}WatchPath property is N/A. Setting ${configInput[key]} cloud function as NO-OP.`); 47 | } 48 | 49 | if (_.isEmpty(parsedConfig)) { 50 | return logError(`${key} configuration not provided.`); 51 | } 52 | 53 | const validator = CONFIG_PARAMS[key]; 54 | const validationResult = validator(parsedConfig); 55 | if (!validationResult.valid) { 56 | return logError(`${key} syntax is invalid: \n${validationResult.errors.map((e) => e.message).join(",\n")}`); 57 | } 58 | 59 | // persist the deserialized JSON 60 | acc[key] = parsedConfig; 61 | } else { 62 | // persist the string value as-is (location, oAuth Token, AudienceId, Contact Status, etc.) 63 | acc[key] = value; 64 | } 65 | return acc; 66 | }, {}); 67 | } 68 | 69 | try { 70 | // Configure mailchimp api client 71 | // The datacenter id is appended to the API key in the form key-dc; 72 | // if your API key is 0123456789abcdef0123456789abcde-us6, then the data center subdomain is us6 73 | // See https://mailchimp.com/developer/marketing/guides/quick-start/ 74 | // See https://github.com/mailchimp/mailchimp-marketing-node/ 75 | // See https://mailchimp.com/developer/marketing/guides/access-user-data-oauth-2/ 76 | const apiKey = config.mailchimpOAuthToken; 77 | 78 | const apiKeyParts = apiKey.split("-"); 79 | 80 | if (apiKeyParts.length === 2) { 81 | const server = apiKeyParts.pop(); 82 | mailchimp.setConfig({ 83 | apiKey, 84 | server, 85 | }); 86 | } else { 87 | throw new Error("Unable to set Mailchimp configuration"); 88 | } 89 | 90 | processConfig(config); 91 | } catch (err) { 92 | logs.initError(err); 93 | } 94 | 95 | /** 96 | * MD5 hashes the email address, for use as the mailchimp identifier 97 | * @param {string} email 98 | * @returns {string} The MD5 Hash 99 | */ 100 | function subscriberHasher(email) { return crypto.createHash("md5").update(email.toLowerCase()).digest("hex"); } 101 | 102 | /** 103 | * Extracts the subscriber email from a document, based on a string path. 104 | * Uses lodash's "get" function. 105 | * @param {any} prevDoc 106 | * @param {any} newDoc 107 | * @param {string} emailPath 108 | * @returns {string} the subscribers email 109 | */ 110 | function getSubscriberEmail(prevDoc, newDoc, emailPath) { 111 | return _.get(prevDoc, emailPath, false) || _.get(newDoc, emailPath); 112 | } 113 | 114 | /** 115 | * Uses JMESPath to retrieve a value from a document. 116 | * @param {any} doc 117 | * @param {string | { documentPath: string }} documentPathOrConfig 118 | * @param {string} defaultValue 119 | * @returns 120 | */ 121 | function resolveValueFromDocumentPath(doc, documentPathOrConfig, defaultValue = undefined) { 122 | const documentSelector = _.isObject(documentPathOrConfig) 123 | ? documentPathOrConfig.documentPath : documentPathOrConfig; 124 | return jmespath.search(doc, documentSelector) ?? defaultValue; 125 | } 126 | 127 | /** 128 | * Determines a period to wait, based on an exponential backoff function. 129 | * @param {number} attempt 130 | * @returns {Promise} 131 | */ 132 | async function wait(attempt) { 133 | const random = Math.random() + 1; 134 | const factor = 2; 135 | const minTimeout = 500; 136 | const maxTimeout = 2000; 137 | const time = Math.min(random * minTimeout * factor ** attempt, maxTimeout); 138 | // eslint-disable-next-line no-promise-executor-return 139 | return new Promise((resolve) => setTimeout(resolve, time)); 140 | } 141 | 142 | /** 143 | * Converts a Firestore Timestamp type to YYYY-MM-DD format 144 | * @param {import('firebase-admin').firestore.Timestamp} timestamp 145 | * @returns {string} The date in string format. 146 | */ 147 | function convertTimestampToMailchimpDate(timestamp) { 148 | assert(timestamp instanceof admin.firestore.Timestamp, `Value ${timestamp} is not a Timestamp`); 149 | const timestampDate = timestamp.toDate(); 150 | const padNumber = (number) => _.padStart(number, 2, "0"); 151 | return `${timestampDate.getUTCFullYear()}-${padNumber(timestampDate.getUTCMonth() + 1)}-${padNumber(timestampDate.getUTCDate())}`; 152 | } 153 | 154 | /** 155 | * Attempts the provided function 156 | * @template T 157 | * @param {() => Promise} fn The function to try with retries 158 | * @param {(err: any) => boolean} errorFilter Return true to retry this error (optional). 159 | * Default is to retry all errors. 160 | * @returns {Promise} The response of the function or the first error thrown. 161 | */ 162 | async function retry(fn, errorFilter) { 163 | let attempt = 0; let 164 | firstError = null; 165 | const retries = Math.max(0, parseInt(config.mailchimpRetryAttempts || "0", 10)); 166 | do { 167 | try { 168 | // eslint-disable-next-line no-await-in-loop 169 | const result = await fn(); 170 | if (attempt !== 0) { 171 | logs.subsequentAttemptRecovered(attempt, retries); 172 | } 173 | return result; 174 | } catch (err) { 175 | if (errorFilter && !errorFilter(err)) { 176 | throw err; 177 | } 178 | 179 | if (!firstError) firstError = err; 180 | logs.attemptFailed(attempt, retries); 181 | attempt += 1; 182 | if (attempt <= retries) { 183 | // eslint-disable-next-line no-await-in-loop 184 | await wait(attempt); 185 | } 186 | } 187 | } while (attempt <= retries); 188 | 189 | throw firstError; 190 | } 191 | 192 | function errorFilterFor404(err) { 193 | return err?.status === 404; 194 | } 195 | 196 | exports.addUserToList = auth.user().onCreate( 197 | async (user) => { 198 | logs.start(); 199 | 200 | if (!mailchimp) { 201 | logs.mailchimpNotInitialized(); 202 | return; 203 | } 204 | 205 | const { email, uid } = user; 206 | if (!email) { 207 | logs.userNoEmail(); 208 | return; 209 | } 210 | 211 | try { 212 | logs.userAdding(uid, config.mailchimpAudienceId); 213 | // this call is not retried, as a 404 here indicates 214 | // the audience ID is incorrect which will not change. 215 | const results = await mailchimp.lists.addListMember(config.mailchimpAudienceId, { 216 | email_address: email, 217 | status: config.mailchimpContactStatus, 218 | }); 219 | 220 | logs.userAdded( 221 | uid, 222 | config.mailchimpAudienceId, 223 | results.id, 224 | config.mailchimpContactStatus, 225 | ); 226 | logs.complete(); 227 | } catch (err) { 228 | if (err.title === "Member Exists") { 229 | logs.userAlreadyInAudience(); 230 | } else { 231 | logs.errorAddUser(err); 232 | } 233 | } 234 | }, 235 | ); 236 | 237 | exports.removeUserFromList = auth.user().onDelete( 238 | async (user) => { 239 | logs.start(); 240 | 241 | if (!mailchimp) { 242 | logs.mailchimpNotInitialized(); 243 | return; 244 | } 245 | 246 | const { email, uid } = user; 247 | if (!email) { 248 | logs.userNoEmail(); 249 | return; 250 | } 251 | 252 | try { 253 | const hashed = subscriberHasher(email); 254 | 255 | logs.userRemoving(uid, hashed, config.mailchimpAudienceId); 256 | await retry(() => mailchimp.lists.deleteListMember( 257 | config.mailchimpAudienceId, 258 | hashed, 259 | ), errorFilterFor404); 260 | logs.userRemoved(uid, hashed, config.mailchimpAudienceId); 261 | logs.complete(); 262 | } catch (err) { 263 | if (err.title === "Method Not Allowed") { 264 | logs.userNotInAudience(); 265 | } else { 266 | logs.errorRemoveUser(err); 267 | } 268 | } 269 | }, 270 | ); 271 | 272 | exports.memberTagsHandler = firestore.document(config.mailchimpMemberTagsWatchPath) 273 | .onWrite(async (event) => { 274 | // If an empty JSON configuration was provided then consider function as NO-OP 275 | if (_.isEmpty(config.mailchimpMemberTags)) return; 276 | 277 | try { 278 | // Get the configuration settings for mailchimp tags as is defined in extension.yml 279 | const tagsConfig = config.mailchimpMemberTags; 280 | 281 | // Validate proper configuration settings were provided 282 | if (!mailchimp) { 283 | logs.mailchimpNotInitialized(); 284 | return; 285 | } 286 | if (!tagsConfig.memberTags) { 287 | logger.log(`A property named 'memberTags' is required`); 288 | return; 289 | } 290 | if (!Array.isArray(tagsConfig.memberTags)) { 291 | logger.log("\"memberTags\" must be an array"); 292 | return; 293 | } 294 | 295 | // Get snapshot of document before & after write event 296 | const prevDoc = event && event.before && event.before.data(); 297 | const newDoc = event && event.after && event.after.data(); 298 | 299 | // Retrieves subscriber tags before/after write event 300 | const getTagsFromEventSnapshot = (snapshot) => tagsConfig 301 | .memberTags 302 | .reduce((acc, tagConfig) => { 303 | const tags = resolveValueFromDocumentPath(snapshot, tagConfig); 304 | if (Array.isArray(tags) && tags && tags.length) { 305 | return [...acc, ...tags]; 306 | } if (tags) { 307 | return acc.concat(tags); 308 | } 309 | return acc; 310 | }, []); 311 | 312 | // Determine all the tags prior to write event 313 | const prevTags = prevDoc ? getTagsFromEventSnapshot(prevDoc) : []; 314 | // Determine all the tags after write event 315 | const newTags = newDoc ? getTagsFromEventSnapshot(newDoc) : []; 316 | 317 | // Compute the delta between existing/new tags 318 | const tagsToRemove = prevTags.filter((tag) => !newTags.includes(tag)).map((tag) => ({ name: tag, status: "inactive" })); 319 | const tagsToAdd = newTags.filter((tag) => !prevTags.includes(tag)).map((tag) => ({ name: tag, status: "active" })); 320 | const tags = [...tagsToRemove, ...tagsToAdd]; 321 | 322 | // Compute the mailchimp subscriber email hash 323 | const subscriberHash = subscriberHasher( 324 | getSubscriberEmail(prevDoc, newDoc, tagsConfig.subscriberEmail), 325 | ); 326 | 327 | // Invoke mailchimp API with updated tags 328 | if (tags && tags.length) { 329 | await retry(() => mailchimp.lists.updateListMemberTags( 330 | config.mailchimpAudienceId, 331 | subscriberHash, 332 | { tags }, 333 | ), errorFilterFor404); 334 | } 335 | } catch (e) { 336 | logger.log(e); 337 | } 338 | }); 339 | 340 | exports.mergeFieldsHandler = firestore.document(config.mailchimpMergeFieldWatchPath) 341 | .onWrite(async (event) => { 342 | // If an empty JSON configuration was provided then consider function as NO-OP 343 | if (_.isEmpty(config.mailchimpMergeField)) return; 344 | 345 | try { 346 | // Get the configuration settings for mailchimp merge fields as is defined in extension.yml 347 | const mergeFieldsConfig = config.mailchimpMergeField; 348 | 349 | // Validate proper configuration settings were provided 350 | if (!mailchimp) { 351 | logs.mailchimpNotInitialized(); 352 | return; 353 | } 354 | if (!mergeFieldsConfig.mergeFields || _.isEmpty(mergeFieldsConfig.mergeFields)) { 355 | logger.log(`A property named 'mergeFields' is required`); 356 | return; 357 | } 358 | if (!_.isObject(mergeFieldsConfig.mergeFields)) { 359 | logger.log("Merge Fields config must be an object"); 360 | return; 361 | } 362 | 363 | // Get snapshot of document before & after write event 364 | const prevDoc = event && event.before && event.before.data(); 365 | const newDoc = event && event.after && event.after.data(); 366 | 367 | // Determine all the merge prior to write event 368 | const mergeFieldsToUpdate = Object.entries(mergeFieldsConfig.mergeFields) 369 | .reduce((acc, [documentPath, mergeFieldConfig]) => { 370 | const mergeFieldName = _.isObject(mergeFieldConfig) 371 | ? mergeFieldConfig.mailchimpFieldName : mergeFieldConfig; 372 | 373 | const prevMergeFieldValue = jmespath.search(prevDoc, documentPath); 374 | // lookup the same field value in new snapshot 375 | const newMergeFieldValue = jmespath.search(newDoc, documentPath) ?? ""; 376 | 377 | // if delta exists or the field should always be sent, then update accumulator collection 378 | if (prevMergeFieldValue !== newMergeFieldValue || (_.isObject(mergeFieldConfig) && mergeFieldConfig.when && mergeFieldConfig.when === "always")) { 379 | const conversionToApply = _.isObject(mergeFieldConfig) && mergeFieldConfig.typeConversion ? mergeFieldConfig.typeConversion : "none"; 380 | let finalValue = newMergeFieldValue; 381 | switch (conversionToApply) { 382 | case "timestampToDate": 383 | finalValue = convertTimestampToMailchimpDate(newMergeFieldValue); 384 | break; 385 | case "stringToNumber": 386 | finalValue = Number(newMergeFieldValue); 387 | assert(!isNaN(finalValue), `${newMergeFieldValue} could not be converted to a number.`); 388 | break; 389 | default: 390 | break; 391 | } 392 | _.set(acc, mergeFieldName, finalValue); 393 | } 394 | return acc; 395 | }, {}); 396 | 397 | // Compute the mailchimp subscriber email hash 398 | const subscriberHash = subscriberHasher( 399 | getSubscriberEmail(prevDoc, newDoc, mergeFieldsConfig.subscriberEmail), 400 | ); 401 | 402 | const params = { 403 | status_if_new: config.mailchimpContactStatus, 404 | email_address: _.get(newDoc, mergeFieldsConfig.subscriberEmail), 405 | }; 406 | 407 | if (!_.isEmpty(mergeFieldsToUpdate)) { 408 | params.merge_fields = mergeFieldsToUpdate; 409 | } 410 | 411 | // sync status if opted in 412 | if (_.isObject(mergeFieldsConfig.statusField)) { 413 | const { documentPath, statusFormat } = mergeFieldsConfig.statusField; 414 | let prevStatusValue = jmespath.search(prevDoc, documentPath) ?? ""; 415 | // lookup the same field value in new snapshot 416 | let newStatusValue = jmespath.search(newDoc, documentPath) ?? ""; 417 | 418 | if (statusFormat && statusFormat === "boolean" 419 | ) { 420 | prevStatusValue = prevStatusValue ? "subscribed" : "unsubscribed"; 421 | newStatusValue = newStatusValue ? "subscribed" : "unsubscribed"; 422 | } 423 | 424 | if (prevStatusValue !== newStatusValue) { 425 | params.status = newStatusValue; 426 | params.status_if_new = newStatusValue; 427 | } 428 | } 429 | 430 | // Invoke mailchimp API with updated data 431 | if (params.merge_fields || params.status) { 432 | await retry(() => mailchimp.lists.setListMember( 433 | config.mailchimpAudienceId, 434 | subscriberHash, 435 | params, 436 | ), errorFilterFor404); 437 | } 438 | } catch (e) { 439 | logger.log(e); 440 | } 441 | }); 442 | 443 | exports.memberEventsHandler = firestore.document(config.mailchimpMemberEventsWatchPath) 444 | .onWrite(async (event) => { 445 | // If an empty JSON configuration was provided then consider function as NO-OP 446 | if (_.isEmpty(config.mailchimpMemberEvents)) return; 447 | 448 | try { 449 | // Get the configuration settings for mailchimp custom events as is defined in extension.yml 450 | const eventsConfig = config.mailchimpMemberEvents; 451 | 452 | // Validate proper configuration settings were provided 453 | if (!mailchimp) { 454 | logs.mailchimpNotInitialized(); 455 | return; 456 | } 457 | if (!eventsConfig.memberEvents) { 458 | logger.log(`A property named 'memberEvents' is required`); 459 | return; 460 | } 461 | if (!Array.isArray(eventsConfig.memberEvents)) { 462 | logger.log(`'memberEvents' property must be an array`); 463 | return; 464 | } 465 | 466 | // Get snapshot of document before & after write event 467 | const prevDoc = event && event.before && event.before.data(); 468 | const newDoc = event && event.after && event.after.data(); 469 | 470 | // Retrieves subscriber tags before/after write event 471 | const getMemberEventsFromSnapshot = (snapshot) => eventsConfig 472 | .memberEvents 473 | .reduce((acc, memberEventConfiguration) => { 474 | const events = resolveValueFromDocumentPath(snapshot, memberEventConfiguration); 475 | if (Array.isArray(events) && events && events.length) { 476 | return [...acc, ...events]; 477 | } 478 | if (events) { 479 | return acc.concat(events); 480 | } 481 | return acc; 482 | }, []); 483 | 484 | // Get all member events prior to write event 485 | const prevEvents = prevDoc ? getMemberEventsFromSnapshot(prevDoc) : []; 486 | // Get all member events after write event 487 | const newEvents = newDoc ? getMemberEventsFromSnapshot(newDoc) : []; 488 | // Find the intersection of both collections 489 | const memberEvents = newEvents.filter((e) => !prevEvents.includes(e)); 490 | 491 | // Compute the mailchimp subscriber email hash 492 | const subscriberHash = subscriberHasher( 493 | getSubscriberEmail(prevDoc, newDoc, eventsConfig.subscriberEmail), 494 | ); 495 | 496 | // Invoke mailchimp API with new events 497 | if (memberEvents && memberEvents.length) { 498 | const requests = memberEvents.reduce((acc, name) => { 499 | acc.push( 500 | retry(() => mailchimp.lists.createListMemberEvent( 501 | config.mailchimpAudienceId, 502 | subscriberHash, 503 | { name }, 504 | ), errorFilterFor404), 505 | ); 506 | return acc; 507 | }, []); 508 | await Promise.all(requests); 509 | } 510 | } catch (e) { 511 | logger.log(e); 512 | } 513 | }); 514 | 515 | exports.processConfig = processConfig; 516 | -------------------------------------------------------------------------------- /extension.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: mailchimp-firebase-sync 16 | version: 0.5.4 17 | specVersion: v1beta 18 | 19 | displayName: Manage Marketing with Mailchimp 20 | description: Syncs user data with a Mailchimp audience for sending personalized email marketing campaigns. 21 | 22 | license: Apache-2.0 23 | 24 | sourceUrl: https://github.com/mailchimp/Firebase/tree/master/ 25 | releaseNotesUrl: https://github.com/mailchimp/Firebase/tree/master/CHANGELOG.md 26 | 27 | author: 28 | authorName: Mailchimp 29 | url: https://mailchimp.com 30 | 31 | contributors: 32 | - authorName: Lauren Long 33 | url: https://github.com/laurenzlong 34 | - authorName: Chris Bianca 35 | email: chris@csfrequency.com 36 | url: https://github.com/chrisbianca 37 | - authorName: Invertase 38 | email: oss@invertase.io 39 | url: https://github.com/invertase 40 | - authorName: Amr Desouky 41 | email: desoukya@gmail.com 42 | url: https://github.com/desoukya 43 | - authorName: Bart Breen 44 | email: bart.breen@twobulls.com 45 | url: https://github.com/barticus 46 | 47 | billingRequired: true 48 | 49 | externalServices: 50 | - name: Mailchimp 51 | pricingUri: https://mailchimp.com/pricing 52 | 53 | resources: 54 | - name: addUserToList 55 | type: firebaseextensions.v1beta.function 56 | description: 57 | Listens for new user accounts (as managed by Firebase Authentication), 58 | then automatically adds the new user to your specified MailChimp audience. 59 | properties: 60 | location: ${LOCATION} 61 | runtime: nodejs18 62 | eventTrigger: 63 | eventType: providers/firebase.auth/eventTypes/user.create 64 | resource: projects/${PROJECT_ID} 65 | 66 | - name: removeUserFromList 67 | type: firebaseextensions.v1beta.function 68 | description: 69 | Listens for existing user accounts to be deleted (as managed by Firebase 70 | Authentication), then automatically removes them from your specified 71 | MailChimp audience. 72 | properties: 73 | location: ${LOCATION} 74 | runtime: nodejs18 75 | eventTrigger: 76 | eventType: providers/firebase.auth/eventTypes/user.delete 77 | resource: projects/${PROJECT_ID} 78 | 79 | - name: memberTagsHandler 80 | type: firebaseextensions.v1beta.function 81 | description: 82 | Member Tags provide the ability to associate "metadata" or "labels" with a Mailchimp subscriber. 83 | The memberTagsHandler function listens for Firestore write events based on specified config path, 84 | then automatically classifies the document data as Mailchimp subscriber tags. 85 | properties: 86 | location: ${LOCATION} 87 | runtime: nodejs18 88 | eventTrigger: 89 | eventType: providers/cloud.firestore/eventTypes/document.write 90 | resource: projects/${param:PROJECT_ID}/databases/(default)/documents/${param:MAILCHIMP_MEMBER_TAGS_WATCH_PATH}/{documentId} 91 | 92 | - name: mergeFieldsHandler 93 | type: firebaseextensions.v1beta.function 94 | description: 95 | Merge fields provide the ability to create new properties that can be associated with Mailchimp subscriber. 96 | The mergeFieldsHandler function listens for Firestore write events based on specified config path, 97 | then automatically populates the Mailchimp subscriber's respective merge fields. 98 | properties: 99 | location: ${LOCATION} 100 | runtime: nodejs18 101 | eventTrigger: 102 | eventType: providers/cloud.firestore/eventTypes/document.write 103 | resource: projects/${param:PROJECT_ID}/databases/(default)/documents/${param:MAILCHIMP_MERGE_FIELDS_WATCH_PATH}/{documentId} 104 | 105 | - name: memberEventsHandler 106 | type: firebaseextensions.v1beta.function 107 | description: 108 | Member events are Mailchimp specific activity events that can be created and associated with a predefined action. 109 | The memberEventsHandler function Listens for Firestore write events based on specified config path, 110 | then automatically uses the document data to create a Mailchimp event on the subscriber's profile 111 | which can subsequently trigger automation workflows. 112 | properties: 113 | location: ${LOCATION} 114 | runtime: nodejs18 115 | eventTrigger: 116 | eventType: providers/cloud.firestore/eventTypes/document.write 117 | resource: projects/${param:PROJECT_ID}/databases/(default)/documents/${param:MAILCHIMP_MEMBER_EVENTS_WATCH_PATH}/{documentId} 118 | 119 | params: 120 | - param: LOCATION 121 | label: Cloud Functions location 122 | description: >- 123 | Where do you want to deploy the functions created for this extension? 124 | type: select 125 | options: 126 | - label: Iowa (us-central1) 127 | value: us-central1 128 | - label: South Carolina (us-east1) 129 | value: us-east1 130 | - label: Northern Virginia (us-east4) 131 | value: us-east4 132 | - label: Los Angeles (us-west2) 133 | value: us-west2 134 | - label: Salt Lake City (us-west3) 135 | value: us-west3 136 | - label: Las Vegas (us-west4) 137 | value: us-west4 138 | - label: Warsaw (europe-central2) 139 | value: europe-central2 140 | - label: Belgium (europe-west1) 141 | value: europe-west1 142 | - label: London (europe-west2) 143 | value: europe-west2 144 | - label: Frankfurt (europe-west3) 145 | value: europe-west3 146 | - label: Zurich (europe-west6) 147 | value: europe-west6 148 | - label: Hong Kong (asia-east2) 149 | value: asia-east2 150 | - label: Tokyo (asia-northeast1) 151 | value: asia-northeast1 152 | - label: Osaka (asia-northeast2) 153 | value: asia-northeast2 154 | - label: Seoul (asia-northeast3) 155 | value: asia-northeast3 156 | - label: Mumbai (asia-south1) 157 | value: asia-south1 158 | - label: Jakarta (asia-southeast2) 159 | value: asia-southeast2 160 | - label: Montreal (northamerica-northeast1) 161 | value: northamerica-northeast1 162 | - label: Sao Paulo (southamerica-east1) 163 | value: southamerica-east1 164 | - label: Sydney (australia-southeast1) 165 | value: australia-southeast1 166 | default: us-central1 167 | required: true 168 | immutable: true 169 | 170 | - param: MAILCHIMP_API_KEY 171 | label: Mailchimp OAuth Token 172 | description: >- 173 | To obtain a Mailchimp OAuth Token, click 174 | [here](https://firebase.mailchimp.com/index.html). 175 | type: string 176 | example: a1b2c3d4e5f6g7 177 | required: true 178 | 179 | - param: MAILCHIMP_AUDIENCE_ID 180 | label: Audience ID 181 | description: >- 182 | What is the Mailchimp Audience ID to which you want to subscribe new 183 | users? To find your Audience ID: visit https://admin.mailchimp.com/lists, 184 | click on the desired audience or create a new audience, then select 185 | **Settings**. Look for **Audience ID** (for example, `27735fc60a`). 186 | type: string 187 | example: 1ab2345c67 188 | required: true 189 | 190 | - param: MAILCHIMP_RETRY_ATTEMPTS 191 | label: Mailchimp Retry Attempts 192 | description: >- 193 | The number of attempts to retry operation against Mailchimp. 194 | Race conditions can occur between user creation events and user update events, and this allows the extension to retry operations that failed transiently. 195 | Currently this is limited to 404 responses for removeUserFromList, memberTagsHandler, mergeFieldsHandler and memberEventsHandler calls. 196 | type: string 197 | validationRegex: "^[0-9]$" 198 | validationErrorMessage: The number of attempts must be an integer value between 0 and 9. 199 | default: "2" 200 | required: true 201 | 202 | - param: MAILCHIMP_CONTACT_STATUS 203 | label: Contact status 204 | description: >- 205 | When the extension adds a new user to the Mailchimp audience, what is 206 | their initial status? This value can be `subscribed` or `pending`. 207 | `subscribed` means the user can receive campaigns; `pending` means the 208 | user still needs to opt-in to receive campaigns. 209 | type: select 210 | options: 211 | - label: Subscribed 212 | value: subscribed 213 | - label: Pending 214 | value: pending 215 | default: subscribed 216 | required: true 217 | 218 | - param: MAILCHIMP_MEMBER_TAGS_WATCH_PATH 219 | label: Firebase Member Tags Watch Path 220 | description: The Firestore collection to watch for member tag changes 221 | type: string 222 | example: registrations 223 | default: _unused_ 224 | required: true 225 | 226 | - param: MAILCHIMP_MEMBER_TAGS_CONFIG 227 | type: string 228 | label: Firebase Member Tags Config 229 | description: >- 230 | Provide a configuration mapping in JSON format indicating which Firestore event(s) to listen for and associate as Mailchimp member tags. 231 | 232 | 233 | Required Fields: 234 | 235 | 1) `memberTags` - The Firestore document fields(s) to retrieve data from and classify as subscriber tags in Mailchimp. Acceptable data types include: 236 | 237 | - `Array` - The extension will lookup the values in the provided fields and update the subscriber's member tags with the respective data values. The format of each string can be any valid [JMES Path query](https://jmespath.org/). e.g. ["primaryTags", "additionalTags", "tags.primary"] 238 | 239 | - `Array` - An extended object configuration is supported with the following fields: 240 | - `documentPath` - (required) The path to the field in the document containing tag/s, as a string. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. "primaryTags", "tags.primary". 241 | 242 | 2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp 243 | 244 | 245 | Configuration Example: 246 | 247 | ```json 248 | 249 | { 250 | "memberTags": ["domainKnowledge", "jobTitle"], 251 | "subscriberEmail": "emailAddress" 252 | } 253 | 254 | ``` 255 | 256 | 257 | Or via equivalent extended syntax: 258 | 259 | ```json 260 | 261 | { 262 | "memberTags": [{ "documentPath": "domainKnowledge" }, { "documentPath": "jobTitle" }], 263 | "subscriberEmail": "emailAddress" 264 | } 265 | 266 | ``` 267 | 268 | Based on the sample configuration, if the following Firestore document is provided: 269 | 270 | ```json 271 | 272 | { 273 | "firstName": "..", 274 | "lastName": "..", 275 | "phoneNumber": "..", 276 | "courseName": "..", 277 | "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field 278 | "jobTitle": "..", // The config property 'memberTags' maps to this document field 279 | "domainKnowledge": "..", // The config property 'memberTags' maps to this document field 280 | "activity": [] 281 | } 282 | 283 | ``` 284 | 285 | Any data associated with the mapped fields (i.e. `domainKnowledge` and `jobTitle`) will be considered Member Tags and the Mailchimp user's profile will be updated accordingly. 286 | 287 | For complex documents such as: 288 | 289 | ```json 290 | 291 | { 292 | "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field 293 | "meta": { 294 | "tags": [{ 295 | "label": "Red", 296 | "value": "red" 297 | },{ 298 | "label": "Team 1", 299 | "value": "team1" 300 | }] 301 | } 302 | } 303 | 304 | ``` 305 | 306 | A configuration of the following will allow for the tag values of "red", "team1" to be sent to Mailchimp: 307 | 308 | ```json 309 | 310 | { 311 | "memberTags": [{ "documentPath": "meta.tags[*].value" }], 312 | "subscriberEmail": "emailAddress" 313 | } 314 | 315 | ``` 316 | 317 | NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. 318 | 319 | required: true 320 | default: "{}" 321 | 322 | - param: MAILCHIMP_MERGE_FIELDS_WATCH_PATH 323 | label: Firebase Merge Fields Watch Path 324 | description: The Firestore collection to watch for merge field changes 325 | type: string 326 | example: registrations 327 | default: _unused_ 328 | required: true 329 | 330 | - param: MAILCHIMP_MERGE_FIELDS_CONFIG 331 | type: string 332 | label: Firebase Merge Fields Config 333 | description: >- 334 | Provide a configuration mapping in JSON format indicating which Firestore event(s) to listen for and associate as Mailchimp merge fields. 335 | 336 | 337 | Required Fields: 338 | 339 | 1) `mergeFields` - JSON mapping representing the Firestore document fields to associate with Mailchimp Merge Fields. The key format can be any valid [JMES Path query](https://jmespath.org/) as a string. The value must be the name of a Mailchimp Merge Field as a string, or an object with the following properties: 340 | 341 | - `mailchimpFieldName` - (required) The name of the Mailchimp Merge Field to map to, e.g. "FNAME". Paths are allowed, e.g. "ADDRESS.addr1" will map to an "ADDRESS" object. 342 | 343 | - `typeConversion` - (optional) Whether to apply a type conversion to the value found at documentPath. Valid options: 344 | 345 | - `none`: no conversion is applied. 346 | 347 | - `timestampToDate`: Converts from a [Firebase Timestamp](https://firebase.google.com/docs/reference/android/com/google/firebase/Timestamp) to YYYY-MM-DD format (UTC). 348 | 349 | - `stringToNumber`: Converts to a number. 350 | 351 | - `when` - (optional) When to send the value of the field to Mailchimp. Options are "always" (which will send the value of this field on _any_ change to the document, not just this field) or "changed". Default is "changed". 352 | 353 | 2) `statusField` - An optional configuration setting for syncing the users mailchimp status. Properties are: 354 | 355 | - `documentPath` - (required) The path to the field in the document containing the users status, as a string. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. "status", "meta.status". 356 | 357 | - `statusFormat` - (optional) Indicates the format that the status field is. The options are: 358 | - `"string"` - The default, this will sync the value from the status field as is, with no modification. 359 | - `"boolean"` - This will check if the value is truthy (e.g. true, 1, "subscribed"), and if so will resolve the status to "subscribed", otherwise it will resolve to "unsubscribed". 360 | 361 | 3) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp 362 | 363 | 364 | Configuration Example: 365 | 366 | ```json 367 | 368 | { 369 | "mergeFields": { 370 | "firstName": "FNAME", 371 | "lastName": "LNAME", 372 | "phoneNumber": "PHONE" 373 | }, 374 | "subscriberEmail": "emailAddress" 375 | } 376 | 377 | ``` 378 | 379 | Or via equivalent extended syntax: 380 | 381 | ```json 382 | 383 | { 384 | "mergeFields": { 385 | "firstName": { "mailchimpFieldName": "FNAME" }, 386 | "lastName":{ "mailchimpFieldName": "LNAME" }, 387 | "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } 388 | }, 389 | "subscriberEmail": "emailAddress" 390 | } 391 | 392 | ``` 393 | 394 | 395 | Based on the sample configuration, if the following Firestore document is provided: 396 | 397 | ```json 398 | 399 | { 400 | "firstName": "..", // The config property FNAME maps to this document field 401 | "lastName": "..", // The config property LNAME maps to this document field 402 | "phoneNumber": "..", // The config property PHONE maps to this document field 403 | "emailAddress": "..", // The config property "subscriberEmail" maps to this document field 404 | "jobTitle": "..", 405 | "domainKnowledge": "..", 406 | "activity": [] 407 | } 408 | 409 | ``` 410 | 411 | 412 | Any data associated with the mapped fields (i.e. firstName, lastName, phoneNumber) will be considered Merge Fields and the Mailchimp user's profile will be updated accordingly. 413 | 414 | If there is a requirement to always send the firstName and lastName values, the `"when": "always"` configuration option can be set on those fields, like so: 415 | 416 | ```json 417 | 418 | { 419 | "mergeFields": { 420 | "firstName": { "mailchimpFieldName": "FNAME", "when": "always" }, 421 | "lastName":{ "mailchimpFieldName": "LNAME", "when": "always" }, 422 | "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } 423 | }, 424 | "subscriberEmail": "emailAddress" 425 | } 426 | 427 | ``` 428 | 429 | This can be handy if Firebase needs to remain the source or truth or if the extension has been installed after data is already in the collection and there is a data migration period. 430 | 431 | If the users status is also captured in the Firestore document, the status can be updated in Mailchimp by using the following configuration: 432 | 433 | ```json 434 | 435 | { 436 | "statusField": { 437 | "documentPath": "meta.status", 438 | "statusFormat": "string", 439 | }, 440 | "subscriberEmail": "emailAddress" 441 | } 442 | 443 | ``` 444 | 445 | This can be as well, or instead of, the `mergeFields` configuration property being set. 446 | 447 | NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. 448 | 449 | required: true 450 | default: "{}" 451 | 452 | - param: MAILCHIMP_MEMBER_EVENTS_WATCH_PATH 453 | label: Firebase Member Events Watch Path 454 | description: The Firestore collection to watch for member event changes 455 | type: string 456 | example: registrations 457 | default: _unused_ 458 | required: true 459 | 460 | - param: MAILCHIMP_MEMBER_EVENTS_CONFIG 461 | type: string 462 | label: Firebase Member Events Config 463 | description: >- 464 | Provide a configuration mapping in JSON format indicating which Firestore event(s) to listen for and associate as Mailchimp merge events. 465 | 466 | 467 | Required Fields: 468 | 469 | 1) `memberEvents` - The Firestore document fields(s) to retrieve data from and classify as member events in Mailchimp. Acceptable data types include: 470 | 471 | - `Array` - The extension will lookup the values (mailchimp event names) in the provided fields and post those events to Mailchimp on the subscriber's activity feed. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. ["events", "meta.events"] 472 | 473 | - `Array` - An extended object configuration is supported with the following fields: 474 | - `documentPath` - (required) The path to the field in the document containing events. The format can be any valid [JMES Path query](https://jmespath.org/). e.g. "events", "meta.events". 475 | 476 | 2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp 477 | 478 | 479 | Configuration Example: 480 | 481 | ```json 482 | 483 | { 484 | "memberEvents": [ 485 | "activity" 486 | ], 487 | "subscriberEmail": "emailAddress" 488 | } 489 | 490 | ``` 491 | 492 | Or via equivalent extended syntax: 493 | 494 | ```json 495 | 496 | { 497 | "memberEvents": [{ "documentPath": "activity" }], 498 | "subscriberEmail": "emailAddress" 499 | } 500 | 501 | ``` 502 | 503 | Based on the sample configuration, if the following Firestore document is provided: 504 | 505 | ```json 506 | 507 | { 508 | "firstName": "..", 509 | "lastName": "..", 510 | "phoneNumber": "..", 511 | "courseName": "..", 512 | "jobTitle": "..", 513 | "domainKnowledge": "..", 514 | "emailAddress": "..", // The config property "subscriberEmail" maps to this document field 515 | "activity": ["send_welcome_email"] // The config property "memberTags" maps to this document field 516 | } 517 | 518 | ``` 519 | 520 | Any data associated with the mapped fields (i.e. `activity`) will be considered events and the Mailchimp user's profile will be updated accordingly. 521 | 522 | For complex documents such as: 523 | 524 | ```json 525 | 526 | { 527 | "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field 528 | "meta": { 529 | "events": [{ 530 | "title": "Registered", 531 | "date": "2021-10-08T00:00:00Z" 532 | },{ 533 | "title": "Invited Friend", 534 | "date": "2021-10-09T00:00:00Z" 535 | }] 536 | } 537 | } 538 | 539 | ``` 540 | 541 | A configuration of the following will allow for the events of "Registered", "Invited Friend" to be sent to Mailchimp: 542 | 543 | ```json 544 | 545 | { 546 | "memberEvents": [{ "documentPath": "meta.events[*].title" }], 547 | "subscriberEmail": "emailAddress" 548 | } 549 | 550 | ``` 551 | 552 | NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. 553 | 554 | required: true 555 | default: "{}" 556 | -------------------------------------------------------------------------------- /functions/tests/mergeFieldsHandler.test.js: -------------------------------------------------------------------------------- 1 | jest.mock("@mailchimp/mailchimp_marketing"); 2 | 3 | const functions = require("firebase-functions-test"); 4 | const admin = require("firebase-admin"); 5 | const mailchimp = require("@mailchimp/mailchimp_marketing"); 6 | const { errorWithStatus, defaultConfig } = require("./utils"); 7 | 8 | const testEnv = functions(); 9 | 10 | // configure config mocks (so we can inject config and try different scenarios) 11 | jest.doMock("../config", () => defaultConfig); 12 | const api = require("../index"); 13 | 14 | describe("mergeFieldsHandler", () => { 15 | const configureApi = (config) => { 16 | api.processConfig(config); 17 | }; 18 | 19 | beforeEach(() => { 20 | jest.clearAllMocks(); 21 | mailchimp.lists.setListMember = jest.fn(); 22 | }); 23 | 24 | afterAll(() => { 25 | testEnv.cleanup(); 26 | }); 27 | 28 | it("should make no calls with empty config", async () => { 29 | configureApi(defaultConfig); 30 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 31 | 32 | const testUser = { 33 | uid: "122", 34 | displayName: "lee", 35 | }; 36 | 37 | const result = await wrapped({ 38 | after: { 39 | data: () => testUser, 40 | }, 41 | }); 42 | 43 | expect(result).toBe(undefined); 44 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(0); 45 | }); 46 | 47 | it("should make no calls with missing mergeFields", async () => { 48 | configureApi({ 49 | ...defaultConfig, 50 | mailchimpMergeField: JSON.stringify({ 51 | subscriberEmail: "emailAddress", 52 | }), 53 | }); 54 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 55 | 56 | const testUser = { 57 | uid: "122", 58 | displayName: "lee", 59 | firstName: "new first name", 60 | lastName: "new last name", 61 | phoneNumber: "new phone number", 62 | emailAddress: "test@example.com", 63 | }; 64 | 65 | const result = await wrapped({ 66 | after: { 67 | data: () => testUser, 68 | }, 69 | }); 70 | 71 | expect(result).toBe(undefined); 72 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(0); 73 | }); 74 | 75 | it("should make no calls with invalid mergeFields", async () => { 76 | configureApi({ 77 | ...defaultConfig, 78 | mailchimpMergeField: JSON.stringify({ 79 | mergeFields: { 80 | firstName: { field1: "value" }, 81 | }, 82 | subscriberEmail: "emailAddress", 83 | }), 84 | }); 85 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 86 | 87 | const testUser = { 88 | uid: "122", 89 | displayName: "lee", 90 | firstName: "new first name", 91 | lastName: "new last name", 92 | phoneNumber: "new phone number", 93 | emailAddress: "test@example.com", 94 | }; 95 | 96 | const result = await wrapped({ 97 | after: { 98 | data: () => testUser, 99 | }, 100 | }); 101 | 102 | expect(result).toBe(undefined); 103 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(0); 104 | }); 105 | 106 | it("should make no calls with invalid statusField", async () => { 107 | configureApi({ 108 | ...defaultConfig, 109 | mailchimpMergeField: JSON.stringify({ 110 | mergeFields: { 111 | firstName: "FNAME", 112 | lastName: "LNAME", 113 | phoneNumber: "PHONE", 114 | }, 115 | statusField: { 116 | field1: "value", 117 | }, 118 | subscriberEmail: "emailAddress", 119 | }), 120 | }); 121 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 122 | 123 | const testUser = { 124 | uid: "122", 125 | displayName: "lee", 126 | firstName: "new first name", 127 | lastName: "new last name", 128 | phoneNumber: "new phone number", 129 | emailAddress: "test@example.com", 130 | }; 131 | 132 | const result = await wrapped({ 133 | after: { 134 | data: () => testUser, 135 | }, 136 | }); 137 | 138 | expect(result).toBe(undefined); 139 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(0); 140 | }); 141 | 142 | it("should make no calls when subscriberEmail field not found in document", async () => { 143 | configureApi({ 144 | ...defaultConfig, 145 | mailchimpMergeField: JSON.stringify({ 146 | mergeFields: { 147 | firstName: "FNAME", 148 | lastName: "LNAME", 149 | phoneNumber: "PHONE", 150 | }, 151 | subscriberEmail: "emailAddress", 152 | }), 153 | }); 154 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 155 | 156 | const testUser = { 157 | uid: "122", 158 | displayName: "lee", 159 | firstName: "new first name", 160 | lastName: "new last name", 161 | phoneNumber: "new phone number", 162 | }; 163 | 164 | const result = await wrapped({ 165 | after: { 166 | data: () => testUser, 167 | }, 168 | }); 169 | 170 | expect(result).toBe(undefined); 171 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(0); 172 | }); 173 | 174 | it("should set data for user", async () => { 175 | configureApi({ 176 | ...defaultConfig, 177 | mailchimpMergeField: JSON.stringify({ 178 | mergeFields: { 179 | firstName: "FNAME", 180 | lastName: "LNAME", 181 | phoneNumber: "PHONE", 182 | }, 183 | subscriberEmail: "emailAddress", 184 | }), 185 | }); 186 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 187 | 188 | const testUser = { 189 | uid: "122", 190 | displayName: "lee", 191 | firstName: "new first name", 192 | lastName: "new last name", 193 | phoneNumber: "new phone number", 194 | emailAddress: "test@example.com", 195 | }; 196 | 197 | const result = await wrapped({ 198 | after: { 199 | data: () => testUser, 200 | }, 201 | }); 202 | 203 | expect(result).toBe(undefined); 204 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 205 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 206 | "mailchimpAudienceId", 207 | "55502f40dc8b7c769880b10874abc9d0", 208 | { 209 | email_address: "test@example.com", 210 | merge_fields: { 211 | FNAME: "new first name", 212 | LNAME: "new last name", 213 | PHONE: "new phone number", 214 | }, 215 | status_if_new: "mailchimpContactStatus", 216 | }, 217 | ); 218 | }); 219 | 220 | it.each` 221 | retryAttempts 222 | ${0} 223 | ${2} 224 | `("should retry '$retryAttempts' times on operation error", async ({ retryAttempts }) => { 225 | configureApi({ 226 | ...defaultConfig, 227 | mailchimpRetryAttempts: retryAttempts.toString(), 228 | mailchimpMergeField: JSON.stringify({ 229 | mergeFields: { 230 | firstName: "FNAME", 231 | lastName: "LNAME", 232 | phoneNumber: "PHONE", 233 | }, 234 | subscriberEmail: "emailAddress", 235 | }), 236 | }); 237 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 238 | mailchimp.lists.setListMember.mockImplementation(() => { 239 | throw errorWithStatus(404); 240 | }); 241 | 242 | const testUser = { 243 | uid: "122", 244 | displayName: "lee", 245 | firstName: "new first name", 246 | lastName: "new last name", 247 | phoneNumber: "new phone number", 248 | emailAddress: "test@example.com", 249 | }; 250 | 251 | const result = await wrapped({ 252 | after: { 253 | data: () => testUser, 254 | }, 255 | }); 256 | 257 | expect(result).toBe(undefined); 258 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(retryAttempts + 1); 259 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 260 | "mailchimpAudienceId", 261 | "55502f40dc8b7c769880b10874abc9d0", 262 | { 263 | email_address: "test@example.com", 264 | merge_fields: { 265 | FNAME: "new first name", 266 | LNAME: "new last name", 267 | PHONE: "new phone number", 268 | }, 269 | status_if_new: "mailchimpContactStatus", 270 | }, 271 | ); 272 | }); 273 | 274 | it("should set data for user when new value only", async () => { 275 | configureApi({ 276 | ...defaultConfig, 277 | mailchimpMergeField: JSON.stringify({ 278 | mergeFields: { 279 | firstName: "FNAME", 280 | lastName: "LNAME", 281 | phoneNumber: "PHONE", 282 | }, 283 | subscriberEmail: "emailAddress", 284 | }), 285 | }); 286 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 287 | 288 | const beforeUser = { 289 | uid: "122", 290 | displayName: "lee", 291 | firstName: "old first name", 292 | lastName: "old last name", 293 | emailAddress: "test@example.com", 294 | }; 295 | const afterUser = { 296 | uid: "122", 297 | displayName: "lee", 298 | firstName: "new first name", 299 | lastName: "new last name", 300 | phoneNumber: "new phone number", 301 | emailAddress: "test@example.com", 302 | }; 303 | 304 | const result = await wrapped({ 305 | before: { 306 | data: () => beforeUser, 307 | }, 308 | after: { 309 | data: () => afterUser, 310 | }, 311 | }); 312 | 313 | expect(result).toBe(undefined); 314 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 315 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 316 | "mailchimpAudienceId", 317 | "55502f40dc8b7c769880b10874abc9d0", 318 | { 319 | email_address: "test@example.com", 320 | merge_fields: { 321 | FNAME: "new first name", 322 | LNAME: "new last name", 323 | PHONE: "new phone number", 324 | }, 325 | status_if_new: "mailchimpContactStatus", 326 | }, 327 | ); 328 | }); 329 | 330 | it("should set data for user when old value only", async () => { 331 | configureApi({ 332 | ...defaultConfig, 333 | mailchimpMergeField: JSON.stringify({ 334 | mergeFields: { 335 | firstName: "FNAME", 336 | lastName: "LNAME", 337 | phoneNumber: "PHONE", 338 | }, 339 | subscriberEmail: "emailAddress", 340 | }), 341 | }); 342 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 343 | 344 | const beforeUser = { 345 | uid: "122", 346 | displayName: "lee", 347 | firstName: "old first name", 348 | lastName: "old last name", 349 | phoneNumber: "old phone number", 350 | emailAddress: "test@example.com", 351 | }; 352 | const afterUser = { 353 | uid: "122", 354 | displayName: "lee", 355 | firstName: "new first name", 356 | lastName: "new last name", 357 | emailAddress: "test@example.com", 358 | }; 359 | 360 | const result = await wrapped({ 361 | before: { 362 | data: () => beforeUser, 363 | }, 364 | after: { 365 | data: () => afterUser, 366 | }, 367 | }); 368 | 369 | expect(result).toBe(undefined); 370 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 371 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 372 | "mailchimpAudienceId", 373 | "55502f40dc8b7c769880b10874abc9d0", 374 | { 375 | email_address: "test@example.com", 376 | merge_fields: { 377 | FNAME: "new first name", 378 | LNAME: "new last name", 379 | PHONE: "", 380 | }, 381 | status_if_new: "mailchimpContactStatus", 382 | }, 383 | ); 384 | }); 385 | 386 | it("should set data for user when changed boolean only", async () => { 387 | configureApi({ 388 | ...defaultConfig, 389 | mailchimpMergeField: JSON.stringify({ 390 | mergeFields: { 391 | firstName: "FNAME", 392 | lastName: "LNAME", 393 | hasThing: "HAS_THING", 394 | }, 395 | subscriberEmail: "emailAddress", 396 | }), 397 | }); 398 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 399 | 400 | const beforeUser = { 401 | uid: "122", 402 | displayName: "lee", 403 | firstName: "old first name", 404 | lastName: "old last name", 405 | hasThing: true, 406 | emailAddress: "test@example.com", 407 | }; 408 | const afterUser = { 409 | uid: "122", 410 | displayName: "lee", 411 | firstName: "new first name", 412 | lastName: "new last name", 413 | hasThing: false, 414 | emailAddress: "test@example.com", 415 | }; 416 | 417 | const result = await wrapped({ 418 | before: { 419 | data: () => beforeUser, 420 | }, 421 | after: { 422 | data: () => afterUser, 423 | }, 424 | }); 425 | 426 | expect(result).toBe(undefined); 427 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 428 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 429 | "mailchimpAudienceId", 430 | "55502f40dc8b7c769880b10874abc9d0", 431 | { 432 | email_address: "test@example.com", 433 | merge_fields: { 434 | FNAME: "new first name", 435 | LNAME: "new last name", 436 | HAS_THING: false, 437 | }, 438 | status_if_new: "mailchimpContactStatus", 439 | }, 440 | ); 441 | }); 442 | 443 | it("should set data for user with nested subscriber email", async () => { 444 | configureApi({ 445 | ...defaultConfig, 446 | mailchimpMergeField: JSON.stringify({ 447 | mergeFields: { 448 | firstName: "FNAME", 449 | lastName: "LNAME", 450 | phoneNumber: "PHONE", 451 | }, 452 | subscriberEmail: "contactInfo.emailAddress", 453 | }), 454 | }); 455 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 456 | 457 | const testUser = { 458 | uid: "122", 459 | displayName: "lee", 460 | firstName: "new first name", 461 | lastName: "new last name", 462 | phoneNumber: "new phone number", 463 | contactInfo: { 464 | emailAddress: "test@example.com", 465 | }, 466 | }; 467 | 468 | const result = await wrapped({ 469 | after: { 470 | data: () => testUser, 471 | }, 472 | }); 473 | 474 | expect(result).toBe(undefined); 475 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 476 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 477 | "mailchimpAudienceId", 478 | "55502f40dc8b7c769880b10874abc9d0", 479 | { 480 | email_address: "test@example.com", 481 | merge_fields: { 482 | FNAME: "new first name", 483 | LNAME: "new last name", 484 | PHONE: "new phone number", 485 | }, 486 | status_if_new: "mailchimpContactStatus", 487 | }, 488 | ); 489 | }); 490 | 491 | it("should set data from nested config for user", async () => { 492 | configureApi({ 493 | ...defaultConfig, 494 | mailchimpMergeField: JSON.stringify({ 495 | mergeFields: { 496 | "userData.firstName": "FNAME", 497 | "userData.lastName": "LNAME", 498 | "userData.phoneNumber": "PHONE", 499 | }, 500 | subscriberEmail: "emailAddress", 501 | }), 502 | }); 503 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 504 | 505 | const testUser = { 506 | uid: "122", 507 | displayName: "lee", 508 | emailAddress: "test@example.com", 509 | userData: { 510 | firstName: "new first name", 511 | lastName: "new last name", 512 | phoneNumber: "new phone number", 513 | }, 514 | }; 515 | 516 | const result = await wrapped({ 517 | after: { 518 | data: () => testUser, 519 | }, 520 | }); 521 | 522 | expect(result).toBe(undefined); 523 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 524 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 525 | "mailchimpAudienceId", 526 | "55502f40dc8b7c769880b10874abc9d0", 527 | { 528 | email_address: "test@example.com", 529 | merge_fields: { 530 | FNAME: "new first name", 531 | LNAME: "new last name", 532 | PHONE: "new phone number", 533 | }, 534 | status_if_new: "mailchimpContactStatus", 535 | }, 536 | ); 537 | }); 538 | 539 | it("should set data with complex field config for user", async () => { 540 | configureApi({ 541 | ...defaultConfig, 542 | mailchimpMergeField: JSON.stringify({ 543 | mergeFields: { 544 | firstName: { 545 | mailchimpFieldName: "FNAME", 546 | }, 547 | lastName: "LNAME", 548 | phoneNumber: "PHONE", 549 | }, 550 | subscriberEmail: "emailAddress", 551 | }), 552 | }); 553 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 554 | 555 | const testUser = { 556 | uid: "122", 557 | displayName: "lee", 558 | firstName: "new first name", 559 | lastName: "new last name", 560 | phoneNumber: "new phone number", 561 | emailAddress: "test@example.com", 562 | }; 563 | 564 | const result = await wrapped({ 565 | after: { 566 | data: () => testUser, 567 | }, 568 | }); 569 | 570 | expect(result).toBe(undefined); 571 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 572 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 573 | "mailchimpAudienceId", 574 | "55502f40dc8b7c769880b10874abc9d0", 575 | { 576 | email_address: "test@example.com", 577 | merge_fields: { 578 | FNAME: "new first name", 579 | LNAME: "new last name", 580 | PHONE: "new phone number", 581 | }, 582 | status_if_new: "mailchimpContactStatus", 583 | }, 584 | ); 585 | }); 586 | 587 | it("should update data selectively for user", async () => { 588 | configureApi({ 589 | ...defaultConfig, 590 | mailchimpMergeField: JSON.stringify({ 591 | mergeFields: { 592 | firstName: "FNAME", 593 | lastName: "LNAME", 594 | phoneNumber: "PHONE", 595 | }, 596 | subscriberEmail: "emailAddress", 597 | }), 598 | }); 599 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 600 | 601 | const beforeUser = { 602 | uid: "122", 603 | displayName: "lee", 604 | firstName: "old first name", 605 | lastName: "old last name", 606 | phoneNumber: "new phone number", 607 | emailAddress: "test@example.com", 608 | }; 609 | const afterUser = { 610 | uid: "122", 611 | displayName: "lee", 612 | firstName: "new first name", 613 | lastName: "new last name", 614 | phoneNumber: "new phone number", 615 | emailAddress: "test@example.com", 616 | }; 617 | 618 | const result = await wrapped({ 619 | before: { 620 | data: () => beforeUser, 621 | }, 622 | after: { 623 | data: () => afterUser, 624 | }, 625 | }); 626 | 627 | expect(result).toBe(undefined); 628 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 629 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 630 | "mailchimpAudienceId", 631 | "55502f40dc8b7c769880b10874abc9d0", 632 | { 633 | email_address: "test@example.com", 634 | merge_fields: { 635 | FNAME: "new first name", 636 | LNAME: "new last name", 637 | }, 638 | status_if_new: "mailchimpContactStatus", 639 | }, 640 | ); 641 | }); 642 | 643 | it("should use JMESPath query", async () => { 644 | configureApi({ 645 | ...defaultConfig, 646 | mailchimpMergeField: JSON.stringify({ 647 | mergeFields: { 648 | firstName: "FNAME", 649 | lastName: "LNAME", 650 | "data[?field=='phoneNumber'].value | [0]": "PHONE", 651 | "history[0].key": "LATEST_CHANGE", 652 | }, 653 | subscriberEmail: "emailAddress", 654 | }), 655 | }); 656 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 657 | 658 | const beforeUser = { 659 | uid: "122", 660 | displayName: "lee", 661 | firstName: "first name", 662 | lastName: "last name", 663 | data: [ 664 | { 665 | field: "phoneNumber", 666 | value: "old phone number", 667 | }, 668 | { 669 | field: "country", 670 | value: "Australia", 671 | }, 672 | ], 673 | history: [ 674 | { 675 | key: "firstName", 676 | to: "Some other first name", 677 | }, 678 | ], 679 | emailAddress: "test@example.com", 680 | }; 681 | const afterUser = { 682 | uid: "122", 683 | displayName: "lee", 684 | firstName: "first name", 685 | lastName: "last name", 686 | data: [ 687 | { 688 | field: "phoneNumber", 689 | value: "new phone number", 690 | }, 691 | { 692 | field: "country", 693 | value: "New Zealand", 694 | }, 695 | ], 696 | history: [ 697 | { 698 | key: "lastName", 699 | to: "Some other name", 700 | }, 701 | { 702 | key: "firstName", 703 | to: "Some other last name", 704 | }, 705 | ], 706 | emailAddress: "test@example.com", 707 | }; 708 | 709 | const result = await wrapped({ 710 | before: { 711 | data: () => beforeUser, 712 | }, 713 | after: { 714 | data: () => afterUser, 715 | }, 716 | }); 717 | 718 | expect(result).toBe(undefined); 719 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 720 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 721 | "mailchimpAudienceId", 722 | "55502f40dc8b7c769880b10874abc9d0", 723 | { 724 | email_address: "test@example.com", 725 | merge_fields: { 726 | PHONE: "new phone number", 727 | LATEST_CHANGE: "lastName", 728 | }, 729 | status_if_new: "mailchimpContactStatus", 730 | }, 731 | ); 732 | }); 733 | 734 | it("should always push data for fields with when=always configuration for user", async () => { 735 | configureApi({ 736 | ...defaultConfig, 737 | mailchimpMergeField: JSON.stringify({ 738 | mergeFields: { 739 | firstName: { 740 | mailchimpFieldName: "FNAME", 741 | when: "always", 742 | }, 743 | lastName: "LNAME", 744 | phoneNumber: { 745 | mailchimpFieldName: "PHONE", 746 | when: "changed", 747 | }, 748 | }, 749 | subscriberEmail: "emailAddress", 750 | }), 751 | }); 752 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 753 | 754 | const beforeUser = { 755 | uid: "122", 756 | displayName: "lee", 757 | firstName: "first name", 758 | lastName: "old last name", 759 | phoneNumber: "existing phone number", 760 | emailAddress: "test@example.com", 761 | }; 762 | const afterUser = { 763 | uid: "122", 764 | displayName: "lee", 765 | firstName: "first name", 766 | lastName: "new last name", 767 | phoneNumber: "existing phone number", 768 | emailAddress: "test@example.com", 769 | }; 770 | 771 | const result = await wrapped({ 772 | before: { 773 | data: () => beforeUser, 774 | }, 775 | after: { 776 | data: () => afterUser, 777 | }, 778 | }); 779 | 780 | expect(result).toBe(undefined); 781 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 782 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 783 | "mailchimpAudienceId", 784 | "55502f40dc8b7c769880b10874abc9d0", 785 | { 786 | email_address: "test@example.com", 787 | merge_fields: { 788 | FNAME: "first name", 789 | LNAME: "new last name", 790 | }, 791 | status_if_new: "mailchimpContactStatus", 792 | }, 793 | ); 794 | }); 795 | 796 | it("should update email address for user", async () => { 797 | configureApi({ 798 | ...defaultConfig, 799 | mailchimpMergeField: JSON.stringify({ 800 | mergeFields: { 801 | emailAddress: "EMAIL", 802 | firstName: "FNAME", 803 | lastName: "LNAME", 804 | phoneNumber: "PHONE", 805 | }, 806 | subscriberEmail: "emailAddress", 807 | }), 808 | }); 809 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 810 | 811 | const beforeUser = { 812 | uid: "122", 813 | displayName: "lee", 814 | firstName: "old first name", 815 | lastName: "old last name", 816 | phoneNumber: "new phone number", 817 | emailAddress: "test@example.com", 818 | }; 819 | const afterUser = { 820 | uid: "122", 821 | displayName: "lee", 822 | firstName: "new first name", 823 | lastName: "new last name", 824 | phoneNumber: "new phone number", 825 | emailAddress: "test2@example.com", 826 | }; 827 | 828 | const result = await wrapped({ 829 | before: { 830 | data: () => beforeUser, 831 | }, 832 | after: { 833 | data: () => afterUser, 834 | }, 835 | }); 836 | 837 | expect(result).toBe(undefined); 838 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 839 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 840 | "mailchimpAudienceId", 841 | "55502f40dc8b7c769880b10874abc9d0", 842 | { 843 | email_address: "test2@example.com", 844 | merge_fields: { 845 | EMAIL: "test2@example.com", 846 | FNAME: "new first name", 847 | LNAME: "new last name", 848 | }, 849 | status_if_new: "mailchimpContactStatus", 850 | }, 851 | ); 852 | }); 853 | 854 | it("should update the status of the user, with no transformation", async () => { 855 | configureApi({ 856 | ...defaultConfig, 857 | mailchimpMergeField: JSON.stringify({ 858 | mergeFields: { 859 | firstName: "FNAME", 860 | lastName: "LNAME", 861 | }, 862 | statusField: { 863 | documentPath: "statusField", 864 | }, 865 | subscriberEmail: "emailAddress", 866 | }), 867 | }); 868 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 869 | 870 | const beforeUser = { 871 | uid: "122", 872 | firstName: "first name", 873 | lastName: "last name", 874 | statusField: "transactional", 875 | emailAddress: "test@example.com", 876 | }; 877 | const afterUser = { 878 | uid: "122", 879 | firstName: "first name", 880 | lastName: "last name", 881 | statusField: "pending", 882 | emailAddress: "test@example.com", 883 | }; 884 | 885 | const result = await wrapped({ 886 | before: { 887 | data: () => beforeUser, 888 | }, 889 | after: { 890 | data: () => afterUser, 891 | }, 892 | }); 893 | 894 | expect(result).toBe(undefined); 895 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 896 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 897 | "mailchimpAudienceId", 898 | "55502f40dc8b7c769880b10874abc9d0", 899 | { 900 | email_address: "test@example.com", 901 | status: "pending", 902 | status_if_new: "pending", 903 | }, 904 | ); 905 | }); 906 | 907 | it("should update the status of the user, with boolean transformation to subscribed", async () => { 908 | configureApi({ 909 | ...defaultConfig, 910 | mailchimpMergeField: JSON.stringify({ 911 | mergeFields: { 912 | firstName: "FNAME", 913 | lastName: "LNAME", 914 | }, 915 | statusField: { 916 | documentPath: "subscribed", 917 | statusFormat: "boolean", 918 | }, 919 | subscriberEmail: "emailAddress", 920 | }), 921 | }); 922 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 923 | 924 | const beforeUser = { 925 | uid: "122", 926 | firstName: "first name", 927 | lastName: "last name", 928 | subscribed: false, 929 | emailAddress: "test@example.com", 930 | }; 931 | const afterUser = { 932 | uid: "122", 933 | firstName: "first name", 934 | lastName: "last name", 935 | subscribed: true, 936 | emailAddress: "test@example.com", 937 | }; 938 | 939 | const result = await wrapped({ 940 | before: { 941 | data: () => beforeUser, 942 | }, 943 | after: { 944 | data: () => afterUser, 945 | }, 946 | }); 947 | 948 | expect(result).toBe(undefined); 949 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 950 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 951 | "mailchimpAudienceId", 952 | "55502f40dc8b7c769880b10874abc9d0", 953 | { 954 | email_address: "test@example.com", 955 | status: "subscribed", 956 | status_if_new: "subscribed", 957 | }, 958 | ); 959 | }); 960 | 961 | it("should update the status of the user, with boolean transformation to unsubscribed", async () => { 962 | configureApi({ 963 | ...defaultConfig, 964 | mailchimpMergeField: JSON.stringify({ 965 | mergeFields: { 966 | firstName: "FNAME", 967 | lastName: "LNAME", 968 | }, 969 | statusField: { 970 | documentPath: "subscribed", 971 | statusFormat: "boolean", 972 | }, 973 | subscriberEmail: "emailAddress", 974 | }), 975 | }); 976 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 977 | 978 | const beforeUser = { 979 | uid: "122", 980 | firstName: "first name", 981 | lastName: "last name", 982 | subscribed: true, 983 | emailAddress: "test@example.com", 984 | }; 985 | const afterUser = { 986 | uid: "122", 987 | firstName: "first name", 988 | lastName: "last name", 989 | subscribed: false, 990 | emailAddress: "test@example.com", 991 | }; 992 | 993 | const result = await wrapped({ 994 | before: { 995 | data: () => beforeUser, 996 | }, 997 | after: { 998 | data: () => afterUser, 999 | }, 1000 | }); 1001 | 1002 | expect(result).toBe(undefined); 1003 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 1004 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 1005 | "mailchimpAudienceId", 1006 | "55502f40dc8b7c769880b10874abc9d0", 1007 | { 1008 | email_address: "test@example.com", 1009 | status: "unsubscribed", 1010 | status_if_new: "unsubscribed", 1011 | }, 1012 | ); 1013 | }); 1014 | 1015 | it("should set data for user with hyphenated field", async () => { 1016 | configureApi({ 1017 | ...defaultConfig, 1018 | mailchimpMergeField: JSON.stringify({ 1019 | mergeFields: { 1020 | firstName: "FNAME", 1021 | "\"last-name\"": "LNAME", 1022 | phoneNumber: "PHONE", 1023 | }, 1024 | subscriberEmail: "emailAddress", 1025 | }), 1026 | }); 1027 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 1028 | 1029 | const testUser = { 1030 | uid: "122", 1031 | displayName: "lee", 1032 | firstName: "new first name", 1033 | "last-name": "new last name", 1034 | phoneNumber: "new phone number", 1035 | emailAddress: "test@example.com", 1036 | }; 1037 | 1038 | const result = await wrapped({ 1039 | after: { 1040 | data: () => testUser, 1041 | }, 1042 | }); 1043 | 1044 | expect(result).toBe(undefined); 1045 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 1046 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 1047 | "mailchimpAudienceId", 1048 | "55502f40dc8b7c769880b10874abc9d0", 1049 | { 1050 | email_address: "test@example.com", 1051 | merge_fields: { 1052 | FNAME: "new first name", 1053 | LNAME: "new last name", 1054 | PHONE: "new phone number", 1055 | }, 1056 | status_if_new: "mailchimpContactStatus", 1057 | }, 1058 | ); 1059 | }); 1060 | 1061 | it("should set address data for user when using nested field names", async () => { 1062 | configureApi({ 1063 | ...defaultConfig, 1064 | mailchimpMergeField: JSON.stringify({ 1065 | mergeFields: { 1066 | firstName: "FNAME", 1067 | lastName: "LNAME", 1068 | phoneNumber: "PHONE", 1069 | addressLine1: "ADDRESS.addr1", 1070 | addressLine2: "ADDRESS.addr2", 1071 | addressCity: "ADDRESS.city", 1072 | addressState: "ADDRESS.state", 1073 | addressZip: "ADDRESS.zip", 1074 | addressCountry: "ADDRESS.country", 1075 | }, 1076 | subscriberEmail: "emailAddress", 1077 | }), 1078 | }); 1079 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 1080 | 1081 | const testUser = { 1082 | uid: "122", 1083 | displayName: "lee", 1084 | firstName: "first name", 1085 | lastName: "last name", 1086 | phoneNumber: "phone number", 1087 | emailAddress: "test@example.com", 1088 | addressLine1: "Line 1", 1089 | addressLine2: "Line 2", 1090 | addressCity: "City", 1091 | addressState: "State", 1092 | addressZip: "Zip", 1093 | addressCountry: "Country", 1094 | }; 1095 | 1096 | const result = await wrapped({ 1097 | after: { 1098 | data: () => testUser, 1099 | }, 1100 | }); 1101 | 1102 | expect(result).toBe(undefined); 1103 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 1104 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 1105 | "mailchimpAudienceId", 1106 | "55502f40dc8b7c769880b10874abc9d0", 1107 | { 1108 | email_address: "test@example.com", 1109 | merge_fields: { 1110 | FNAME: "first name", 1111 | LNAME: "last name", 1112 | PHONE: "phone number", 1113 | ADDRESS: { 1114 | addr1: "Line 1", 1115 | addr2: "Line 2", 1116 | city: "City", 1117 | country: "Country", 1118 | state: "State", 1119 | zip: "Zip", 1120 | }, 1121 | }, 1122 | status_if_new: "mailchimpContactStatus", 1123 | }, 1124 | ); 1125 | }); 1126 | 1127 | it("should convert timestamp to date", async () => { 1128 | configureApi({ 1129 | ...defaultConfig, 1130 | mailchimpMergeField: JSON.stringify({ 1131 | mergeFields: { 1132 | firstName: "FNAME", 1133 | lastName: "LNAME", 1134 | phoneNumber: "PHONE", 1135 | createdDate: { 1136 | mailchimpFieldName: "CREATED_AT", 1137 | typeConversion: "timestampToDate", 1138 | }, 1139 | }, 1140 | subscriberEmail: "emailAddress", 1141 | }), 1142 | }); 1143 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 1144 | 1145 | const testUser = { 1146 | uid: "122", 1147 | displayName: "lee", 1148 | firstName: "first name", 1149 | lastName: "last name", 1150 | phoneNumber: "phone number", 1151 | emailAddress: "test@example.com", 1152 | createdDate: new admin.firestore.Timestamp(1692572400, 233000000), 1153 | }; 1154 | 1155 | const result = await wrapped({ 1156 | after: { 1157 | data: () => testUser, 1158 | }, 1159 | }); 1160 | 1161 | expect(result).toBe(undefined); 1162 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 1163 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 1164 | "mailchimpAudienceId", 1165 | "55502f40dc8b7c769880b10874abc9d0", 1166 | { 1167 | email_address: "test@example.com", 1168 | merge_fields: { 1169 | FNAME: "first name", 1170 | LNAME: "last name", 1171 | PHONE: "phone number", 1172 | CREATED_AT: "2023-08-20", 1173 | }, 1174 | status_if_new: "mailchimpContactStatus", 1175 | }, 1176 | ); 1177 | }); 1178 | 1179 | it("should fail timestamp conversion if type is incorrect", async () => { 1180 | configureApi({ 1181 | ...defaultConfig, 1182 | mailchimpMergeField: JSON.stringify({ 1183 | mergeFields: { 1184 | firstName: "FNAME", 1185 | lastName: "LNAME", 1186 | phoneNumber: "PHONE", 1187 | createdDate: { 1188 | mailchimpFieldName: "CREATED_AT", 1189 | typeConversion: "timestampToDate", 1190 | }, 1191 | }, 1192 | subscriberEmail: "emailAddress", 1193 | }), 1194 | }); 1195 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 1196 | 1197 | const testUser = { 1198 | uid: "122", 1199 | displayName: "lee", 1200 | firstName: "first name", 1201 | lastName: "last name", 1202 | phoneNumber: "phone number", 1203 | emailAddress: "test@example.com", 1204 | createdDate: "1692572400", 1205 | }; 1206 | 1207 | const result = await wrapped({ 1208 | after: { 1209 | data: () => testUser, 1210 | }, 1211 | }); 1212 | 1213 | expect(result).toBe(undefined); 1214 | expect(mailchimp.lists.setListMember).not.toHaveBeenCalled(); 1215 | }); 1216 | 1217 | it("should convert number string to number", async () => { 1218 | configureApi({ 1219 | ...defaultConfig, 1220 | mailchimpMergeField: JSON.stringify({ 1221 | mergeFields: { 1222 | firstName: "FNAME", 1223 | lastName: "LNAME", 1224 | phoneNumber: "PHONE", 1225 | eventCount: { 1226 | mailchimpFieldName: "EVENT_COUNT", 1227 | typeConversion: "stringToNumber", 1228 | }, 1229 | }, 1230 | subscriberEmail: "emailAddress", 1231 | }), 1232 | }); 1233 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 1234 | 1235 | const testUser = { 1236 | uid: "122", 1237 | displayName: "lee", 1238 | firstName: "first name", 1239 | lastName: "last name", 1240 | phoneNumber: "phone number", 1241 | emailAddress: "test@example.com", 1242 | eventCount: "3.45", 1243 | }; 1244 | 1245 | const result = await wrapped({ 1246 | after: { 1247 | data: () => testUser, 1248 | }, 1249 | }); 1250 | 1251 | expect(result).toBe(undefined); 1252 | expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); 1253 | expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( 1254 | "mailchimpAudienceId", 1255 | "55502f40dc8b7c769880b10874abc9d0", 1256 | { 1257 | email_address: "test@example.com", 1258 | merge_fields: { 1259 | FNAME: "first name", 1260 | LNAME: "last name", 1261 | PHONE: "phone number", 1262 | EVENT_COUNT: 3.45, 1263 | }, 1264 | status_if_new: "mailchimpContactStatus", 1265 | }, 1266 | ); 1267 | }); 1268 | 1269 | it("should fail string to number conversion", async () => { 1270 | configureApi({ 1271 | ...defaultConfig, 1272 | mailchimpMergeField: JSON.stringify({ 1273 | mergeFields: { 1274 | firstName: "FNAME", 1275 | lastName: "LNAME", 1276 | phoneNumber: "PHONE", 1277 | eventCount: { 1278 | mailchimpFieldName: "EVENT_COUNT", 1279 | typeConversion: "stringToNumber", 1280 | }, 1281 | }, 1282 | subscriberEmail: "emailAddress", 1283 | }), 1284 | }); 1285 | const wrapped = testEnv.wrap(api.mergeFieldsHandler); 1286 | 1287 | const testUser = { 1288 | uid: "122", 1289 | displayName: "lee", 1290 | firstName: "first name", 1291 | lastName: "last name", 1292 | phoneNumber: "phone number", 1293 | emailAddress: "test@example.com", 1294 | eventCount: "test", 1295 | }; 1296 | 1297 | const result = await wrapped({ 1298 | after: { 1299 | data: () => testUser, 1300 | }, 1301 | }); 1302 | 1303 | expect(result).toBe(undefined); 1304 | expect(mailchimp.lists.setListMember).not.toHaveBeenCalled(); 1305 | }); 1306 | }); 1307 | --------------------------------------------------------------------------------