From cce38df46fff6423a4bfa8f39159787da97bbed0 Mon Sep 17 00:00:00 2001 From: Bart Breen Date: Thu, 24 Aug 2023 15:43:43 +1000 Subject: [PATCH] #74 Allow type conversion for timestamp and numeric types. Also allow merge field values to be objects, to support Address types. --- README.md | 517 ++++++++++----------- extension.yaml | 85 +++- functions/index.js | 28 +- functions/tests/mergeFieldsHandler.test.js | 247 ++++++++++ functions/validation.js | 1 + 5 files changed, 594 insertions(+), 284 deletions(-) diff --git a/README.md b/README.md index bf8bc05..af4a7f2 100644 --- a/README.md +++ b/README.md @@ -1,301 +1,296 @@ -# Mailchimp Firebase Sync +# Manage Marketing with Mailchimp + +**Author**: Mailchimp (**[https://mailchimp.com](https://mailchimp.com)**) + +**Description**: Syncs user data with a Mailchimp audience for sending personalized email marketing campaigns. -**Author**: Mailchimp (**[https://mailchimp.com/](https://mailchimp.com/)**) -**Description**: This extension uses Firebase Authentication to manage (add/remove) users and Firestore to create member tags, merge fields, and member events with Mailchimp. **Details**: Use this extension to: -- Add new users to an existing [Mailchimp](https://mailchimp.com) audience. -- Remove user from an existing Mailchimp audience -- Associate member tags with a Mailchimp subscriber -- Use merge fields to sync user data with a Mailchimp subscriber -- Set member events to trigger Mailchimp actions and automations + - Add new users to an existing Mailchimp audience + - Remove user from an existing Mailchimp audience + - Associate member tags with a Mailchimp subscriber + - Use merge fields to sync user data with a Mailchimp subscriber + - Set member events to trigger Mailchimp actions and automations #### Additional setup -This extension uses the following Firebase products: - -- [Authentication](https://firebase.google.com/docs/auth) to manage (add/remove) users -- [Cloud Firestore](https://firebase.google.com/docs/firestore) to create member tags, merge fields, and member events with Mailchimp. +Make sure that you've set up [Firebase Authentication](https://firebase.google.com/docs/auth) to manage your users. -This extension uses Mailchimp, so you'll need to supply your [Mailchimp OAuth Token](http://firebase.mailchimp.com/index.html) and Audience ID when installing this extension. +You must also have a Mailchimp account before installing this extension. #### Billing + +This extension uses the following Firebase services which may have associated charges: -To install an extension, your project must be on the [Blaze (pay as you go) plan](https://firebase.google.com/pricing) - -- You will be charged a small amount (typically around $0.01/month) for the Firebase resources required by this extension (even if it is not used). -- This extension uses other Firebase and Google Cloud Platform services, which have associated charges if you exceed the service’s free tier: - - Cloud Functions (Node.js 10+ runtime. See [FAQs](https://firebase.google.com/support/faq#expandable-24)) - -Usage of this extension also requires you to have a Mailchimp account. You are responsible for any associated costs with your usage of Mailchimp. - -#### Configuration Parameters - -- Cloud Functions location: Where do you want to deploy the functions created for this extension? - -- Mailchimp OAuth Token: To obtain a Mailchimp OAuth Token, click [here](https://firebase.mailchimp.com/index.html). - -- Audience ID: What is the Mailchimp Audience ID to which you want to subscribe new users? To find your Audience ID: visit , click on the desired audience or create a new audience, then select **Settings**. Look for **Audience ID** (for example, `27735fc60a`). +- Cloud Firestore +- Cloud Functions +- Firebase Authentication -- 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. +This extension also uses the following third-party services: -- Firebase Member Tags Watch Path: The Firestore collection to watch for member tag changes +- Mailchimp Billing ([pricing information](https://mailchimp.com/pricing)) -- 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. +You are responsible for any costs associated with your use of these services. - - Required Fields: - - 1. `memberTags` - The Firestore document fields(s) to retrieve data from and classify as subscriber tags in Mailchimp. Acceptable data types include: - - `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"] +#### Note from Firebase - - `Array` - An extended object configuration is supported with the following fields: - - `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". +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) - 2. `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp +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. - Configuration Example: - ```json - { - "memberTags": ["domainKnowledge", "jobTitle"], - "subscriberEmail": "emailAddress" - } - ``` - Or via equivalent extended syntax: - ```json - { - "memberTags": [{ "documentPath": "domainKnowledge" }, { "documentPath": "jobTitle" }], - "subscriberEmail": "emailAddress" - } - ``` +**Configuration Parameters:** - Based on the sample configuration, if the following Firestore document is provided: +* Cloud Functions location: Where do you want to deploy the functions created for this extension? - ```json - { - "firstName": "..", - "lastName": "..", - "phoneNumber": "..", - "courseName": "..", - "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field - "jobTitle": "..", // The config property 'memberTags' maps to this document field - "domainKnowledge": "..", // The config property 'memberTags' maps to this document field - "activity": [] - } - ``` +* Mailchimp OAuth Token: To obtain a Mailchimp OAuth Token, click [here](https://firebase.mailchimp.com/index.html). - 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. - - For complex documents such as: +* 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`). - ```json - { - "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field - "meta": { - "tags": [{ - "label": "Red", - "value": "red" - },{ - "label": "Team 1", - "value": "team1" - }] - } - } - ``` - - A configuration of the following will allow for the tag values of "red", "team1" to be sent to Mailchimp: +* 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. - ```json - { - "memberTags": [{ "documentPath": "meta.tags[*].value" }], - "subscriberEmail": "emailAddress" - } - ``` +* 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. - **NOTE: To disable this cloud function listener, provide an empty JSON config `{}`.** +* Firebase Member Tags Watch Path: The Firestore collection to watch for member tag changes -- Firebase Merge Fields Watch Path: The Firestore collection to watch for merge field changes +* 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. -- 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. +Required Fields: +1) `memberTags` - The Firestore document fields(s) to retrieve data from and classify as subscriber tags in Mailchimp. Acceptable data types include: + + - `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"] - - Required Fields: - 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: - - `mailchimpFieldName` - (required) The name of the Mailchimp Merge Field to map to, e.g. "FNAME". - - `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". + - `Array` - An extended object configuration is supported with the following fields: + - `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". + +2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp + +Configuration Example: +```json +{ + "memberTags": ["domainKnowledge", "jobTitle"], + "subscriberEmail": "emailAddress" +} +``` + +Or via equivalent extended syntax: +```json +{ + "memberTags": [{ "documentPath": "domainKnowledge" }, { "documentPath": "jobTitle" }], + "subscriberEmail": "emailAddress" +} +``` +Based on the sample configuration, if the following Firestore document is provided: +```json +{ + "firstName": "..", + "lastName": "..", + "phoneNumber": "..", + "courseName": "..", + "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field + "jobTitle": "..", // The config property 'memberTags' maps to this document field + "domainKnowledge": "..", // The config property 'memberTags' maps to this document field + "activity": [] +} +``` +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. +For complex documents such as: +```json +{ + "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field + "meta": { + "tags": [{ + "label": "Red", + "value": "red" + },{ + "label": "Team 1", + "value": "team1" + }] + } +} +``` +A configuration of the following will allow for the tag values of "red", "team1" to be sent to Mailchimp: +```json +{ + "memberTags": [{ "documentPath": "meta.tags[*].value" }], + "subscriberEmail": "emailAddress" +} +``` +NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. + +* Firebase Merge Fields Watch Path: The Firestore collection to watch for merge field changes + +* 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. + +Required Fields: +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: + + - `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. + + - `typeConversion` - (optional) Whether to apply a type conversion to the value found at documentPath. Valid options: + + - `none`: no conversion is applied. + + - `timestampToDate`: Converts from a [Firebase Timestamp](https://firebase.google.com/docs/reference/android/com/google/firebase/Timestamp) to YYYY-MM-DD format (UTC). + + - `stringToNumber`: Converts to a number. + + - `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". + +2) `statusField` - An optional configuration setting for syncing the users mailchimp status. Properties are: + + - `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". + + - `statusFormat` - (optional) Indicates the format that the status field is. The options are: + - `"string"` - The default, this will sync the value from the status field as is, with no modification. + - `"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". + +3) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp + +Configuration Example: +```json +{ + "mergeFields": { + "firstName": "FNAME", + "lastName": "LNAME", + "phoneNumber": "PHONE" + }, + "subscriberEmail": "emailAddress" +} +``` +Or via equivalent extended syntax: +```json +{ + "mergeFields": { + "firstName": { "mailchimpFieldName": "FNAME" }, + "lastName":{ "mailchimpFieldName": "LNAME" }, + "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } + }, + "subscriberEmail": "emailAddress" +} +``` + +Based on the sample configuration, if the following Firestore document is provided: +```json +{ + "firstName": "..", // The config property FNAME maps to this document field + "lastName": "..", // The config property LNAME maps to this document field + "phoneNumber": "..", // The config property PHONE maps to this document field + "emailAddress": "..", // The config property "subscriberEmail" maps to this document field + "jobTitle": "..", + "domainKnowledge": "..", + "activity": [] +} +``` + +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. +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: +```json +{ + "mergeFields": { + "firstName": { "mailchimpFieldName": "FNAME", "when": "always" }, + "lastName":{ "mailchimpFieldName": "LNAME", "when": "always" }, + "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } + }, + "subscriberEmail": "emailAddress" +} +``` +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. +If the users status is also captured in the Firestore document, the status can be updated in Mailchimp by using the following configuration: +```json +{ + "statusField": { + "documentPath": "meta.status", + "statusFormat": "string", + }, + "subscriberEmail": "emailAddress" +} +``` +This can be as well, or instead of, the `mergeFields` configuration property being set. +NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. + +* Firebase Member Events Watch Path: The Firestore collection to watch for member event changes + +* 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. + +Required Fields: +1) `memberEvents` - The Firestore document fields(s) to retrieve data from and classify as member events in Mailchimp. Acceptable data types include: + + - `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"] + + - `Array` - An extended object configuration is supported with the following fields: + - `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". + +2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp + +Configuration Example: +```json +{ + "memberEvents": [ + "activity" + ], + "subscriberEmail": "emailAddress" +} +``` +Or via equivalent extended syntax: +```json +{ + "memberEvents": [{ "documentPath": "activity" }], + "subscriberEmail": "emailAddress" +} +``` +Based on the sample configuration, if the following Firestore document is provided: +```json +{ + "firstName": "..", + "lastName": "..", + "phoneNumber": "..", + "courseName": "..", + "jobTitle": "..", + "domainKnowledge": "..", + "emailAddress": "..", // The config property "subscriberEmail" maps to this document field + "activity": ["send_welcome_email"] // The config property "memberTags" maps to this document field +} +``` +Any data associated with the mapped fields (i.e. `activity`) will be considered events and the Mailchimp user's profile will be updated accordingly. +For complex documents such as: +```json +{ + "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field + "meta": { + "events": [{ + "title": "Registered", + "date": "2021-10-08T00:00:00Z" + },{ + "title": "Invited Friend", + "date": "2021-10-09T00:00:00Z" + }] + } +} +``` +A configuration of the following will allow for the events of "Registered", "Invited Friend" to be sent to Mailchimp: +```json +{ + "memberEvents": [{ "documentPath": "meta.events[*].title" }], + "subscriberEmail": "emailAddress" +} +``` +NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. - 2. `statusField` - An optional configuration setting for syncing the users mailchimp status. Properties are: - - `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". - - `statusFormat` - (optional) Indicates the format that the status field is. The options are: - - - `"string"` - The default, this will sync the value from the status field as is, with no modification. - - `"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". - - 3. `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp - - Configuration Example: - - ```json - { - "mergeFields": { - "firstName": "FNAME", - "lastName": "LNAME", - "phoneNumber": "PHONE" - }, - "subscriberEmail": "emailAddress" - } - ``` - - Or via equivalent extended syntax: - - ```json - { - "mergeFields": { - "firstName": { "mailchimpFieldName": "FNAME" }, - "lastName":{ "mailchimpFieldName": "LNAME" }, - "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } - }, - "subscriberEmail": "emailAddress" - } - ``` - - Based on the sample configuration, if the following Firestore document is provided: - - ```json - { - "firstName": "..", // The config property FNAME maps to this document field - "lastName": "..", // The config property LNAME maps to this document field - "phoneNumber": "..", // The config property PHONE maps to this document field - "emailAddress": "..", // The config property "subscriberEmail" maps to this document field - "jobTitle": "..", - "domainKnowledge": "..", - "activity": [] - } - ``` - - 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. - - 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: - - ```json - { - "mergeFields": { - "firstName": { "mailchimpFieldName": "FNAME", "when": "always" }, - "lastName":{ "mailchimpFieldName": "LNAME", "when": "always" }, - "phoneNumber": { "mailchimpFieldName": "PHONE", "when": "changed" } - }, - "subscriberEmail": "emailAddress" - } - ``` - - 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. - - If the users status is also captured in the Firestore document, the status can be updated in Mailchimp by using the following configuration: - - ```json - { - "statusField": { - "documentPath": "meta.status", - "statusFormat": "string", - }, - "subscriberEmail": "emailAddress" - } - ``` - - This can be as well, or instead of, the `mergeFields` configuration property being set. - - **NOTE: To disable this cloud function listener, provide an empty JSON config `{}`.** - -- Firebase Member Events Watch Path: The Firestore collection to watch for member event changes - -- 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. - - - Required Fields: - 1. `memberEvents` - The Firestore document fields(s) to retrieve data from and classify as member events in Mailchimp. Acceptable data types include: - - `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"] - - - `Array` - An extended object configuration is supported with the following fields: - - `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". - - 2. `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp - - Configuration Example: - - ```json - { - "memberEvents": [ - "activity" - ], - "subscriberEmail": "emailAddress" - } - ``` - - Or via equivalent extended syntax: - - ```json - { - "memberEvents": [{ "documentPath": "activity" }], - "subscriberEmail": "emailAddress" - } - ``` - - Based on the sample configuration, if the following Firestore document is provided: - - ```json - { - "firstName": "..", - "lastName": "..", - "phoneNumber": "..", - "courseName": "..", - "jobTitle": "..", - "domainKnowledge": "..", - "emailAddress": "..", // The config property "subscriberEmail" maps to this document field - "activity": ["send_welcome_email"] // The config property "memberTags" maps to this document field - } - ``` - - Any data associated with the mapped fields (i.e. `activity`) will be considered events and the Mailchimp user's profile will be updated accordingly. - - For complex documents such as: - - ```json - { - "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field - "meta": { - "events": [{ - "title": "Registered", - "date": "2021-10-08T00:00:00Z" - },{ - "title": "Invited Friend", - "date": "2021-10-09T00:00:00Z" - }] - } - } - ``` - - A configuration of the following will allow for the events of "Registered", "Invited Friend" to be sent to Mailchimp: - - ```json - { - "memberEvents": [{ "documentPath": "meta.events[*].title" }], - "subscriberEmail": "emailAddress" - } - ``` - - **NOTE: To disable this cloud function listener, provide an empty JSON config `{}`.** **Cloud Functions:** -- **addUserToList:** Listens for new user accounts (as managed by Firebase Authentication), then automatically adds the new user to your specified MailChimp audience. +* **addUserToList:** Listens for new user accounts (as managed by Firebase Authentication), then automatically adds the new user to your specified MailChimp audience. -- **removeUserFromList:** Listens for existing user accounts to be deleted (as managed by Firebase Authentication), then automatically removes them from your specified MailChimp audience. +* **removeUserFromList:** Listens for existing user accounts to be deleted (as managed by Firebase Authentication), then automatically removes them from your specified MailChimp audience. -- **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. +* **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. -- **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. +* **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. -- **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. +* **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. diff --git a/extension.yaml b/extension.yaml index 9e973b4..1f60596 100644 --- a/extension.yaml +++ b/extension.yaml @@ -234,34 +234,41 @@ params: 1) `memberTags` - The Firestore document fields(s) to retrieve data from and classify as subscriber tags in Mailchimp. Acceptable data types include: - - 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"] + - `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"] - - Array\ - An extended object configuration is supported with the following fields: - - `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". + - `Array` - An extended object configuration is supported with the following fields: + - `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". 2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp + Configuration Example: ```json + { "memberTags": ["domainKnowledge", "jobTitle"], "subscriberEmail": "emailAddress" - } + } + ``` + Or via equivalent extended syntax: ```json + { "memberTags": [{ "documentPath": "domainKnowledge" }, { "documentPath": "jobTitle" }], "subscriberEmail": "emailAddress" } + ``` Based on the sample configuration, if the following Firestore document is provided: ```json + { "firstName": "..", "lastName": "..", @@ -272,6 +279,7 @@ params: "domainKnowledge": "..", // The config property 'memberTags' maps to this document field "activity": [] } + ``` 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. @@ -279,6 +287,7 @@ params: For complex documents such as: ```json + { "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field "meta": { @@ -291,15 +300,18 @@ params: }] } } + ``` A configuration of the following will allow for the tag values of "red", "team1" to be sent to Mailchimp: ```json + { "memberTags": [{ "documentPath": "meta.tags[*].value" }], "subscriberEmail": "emailAddress" } + ``` NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. @@ -326,24 +338,33 @@ params: 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: - - `mailchimpFieldName` - (required) The name of the Mailchimp Merge Field to map to, e.g. "FNAME". + - `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. - - `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". + - `typeConversion` - (optional) Whether to apply a type conversion to the value found at documentPath. Valid options: + + - `none`: no conversion is applied. + + - `timestampToDate`: Converts from a [Firebase Timestamp](https://firebase.google.com/docs/reference/android/com/google/firebase/Timestamp) to YYYY-MM-DD format (UTC). + + - `stringToNumber`: Converts to a number. + + - `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". 2) `statusField` - An optional configuration setting for syncing the users mailchimp status. Properties are: - - `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". + - `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". - - `statusFormat` - (optional) Indicates the format that the status field is. The options are: - - `"string"` - The default, this will sync the value from the status field as is, with no modification. - - `"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". + - `statusFormat` - (optional) Indicates the format that the status field is. The options are: + - `"string"` - The default, this will sync the value from the status field as is, with no modification. + - `"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". 3) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp Configuration Example: - ``` + ```json + { "mergeFields": { "firstName": "FNAME", @@ -352,11 +373,13 @@ params: }, "subscriberEmail": "emailAddress" } + ``` Or via equivalent extended syntax: ```json + { "mergeFields": { "firstName": { "mailchimpFieldName": "FNAME" }, @@ -365,12 +388,14 @@ params: }, "subscriberEmail": "emailAddress" } + ``` Based on the sample configuration, if the following Firestore document is provided: - ``` + ```json + { "firstName": "..", // The config property FNAME maps to this document field "lastName": "..", // The config property LNAME maps to this document field @@ -380,6 +405,7 @@ params: "domainKnowledge": "..", "activity": [] } + ``` @@ -387,7 +413,8 @@ params: 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: - ``` + ```json + { "mergeFields": { "firstName": { "mailchimpFieldName": "FNAME", "when": "always" }, @@ -396,13 +423,15 @@ params: }, "subscriberEmail": "emailAddress" } + ``` 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. If the users status is also captured in the Firestore document, the status can be updated in Mailchimp by using the following configuration: - ``` + ```json + { "statusField": { "documentPath": "meta.status", @@ -410,6 +439,7 @@ params: }, "subscriberEmail": "emailAddress" } + ``` This can be as well, or instead of, the `mergeFields` configuration property being set. @@ -438,36 +468,42 @@ params: 1) `memberEvents` - The Firestore document fields(s) to retrieve data from and classify as member events in Mailchimp. Acceptable data types include: - - 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"] + - `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"] - - Array\ - An extended object configuration is supported with the following fields: - - `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". + - `Array` - An extended object configuration is supported with the following fields: + - `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". 2) `subscriberEmail` - The Firestore document field capturing the user email as is recognized by Mailchimp + Configuration Example: - ``` + ```json + { "memberEvents": [ "activity" ], "subscriberEmail": "emailAddress" } + ``` Or via equivalent extended syntax: - ``` + ```json + { "memberEvents": [{ "documentPath": "activity" }], "subscriberEmail": "emailAddress" } + ``` Based on the sample configuration, if the following Firestore document is provided: - ``` + ```json + { "firstName": "..", "lastName": "..", @@ -478,13 +514,15 @@ params: "emailAddress": "..", // The config property "subscriberEmail" maps to this document field "activity": ["send_welcome_email"] // The config property "memberTags" maps to this document field } + ``` Any data associated with the mapped fields (i.e. `activity`) will be considered events and the Mailchimp user's profile will be updated accordingly. For complex documents such as: - ``` + ```json + { "emailAddress": "..", // The config property 'subscriberEmail' maps to this document field "meta": { @@ -497,15 +535,18 @@ params: }] } } + ``` A configuration of the following will allow for the events of "Registered", "Invited Friend" to be sent to Mailchimp: - ``` + ```json + { "memberEvents": [{ "documentPath": "meta.events[*].title" }], "subscriberEmail": "emailAddress" } + ``` NOTE: To disable this cloud function listener, provide an empty JSON config `{}`. diff --git a/functions/index.js b/functions/index.js index 8f64246..bebbb73 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,4 +1,5 @@ const crypto = require("crypto"); +const assert = require("assert"); const _ = require("lodash"); const { auth, firestore, logger } = require("firebase-functions"); const admin = require("firebase-admin"); @@ -138,6 +139,18 @@ async function wait(attempt) { return new Promise((resolve) => setTimeout(resolve, time)); } +/** + * Converts a Firestore Timestamp type to YYYY-MM-DD format + * @param {import('firebase-admin').firestore.Timestamp} timestamp + * @returns {string} The date in string format. + */ +function convertTimestampToMailchimpDate(timestamp) { + assert(timestamp instanceof admin.firestore.Timestamp, `Value ${timestamp} is not a Timestamp`); + const timestampDate = timestamp.toDate(); + const padNumber = (number) => _.padStart(number, 2, "0"); + return `${timestampDate.getUTCFullYear()}-${padNumber(timestampDate.getUTCMonth() + 1)}-${padNumber(timestampDate.getUTCDate())}`; +} + /** * Attempts the provided function * @template T @@ -363,7 +376,20 @@ exports.mergeFieldsHandler = firestore.document(config.mailchimpMergeFieldWatchP // if delta exists or the field should always be sent, then update accumulator collection if (prevMergeFieldValue !== newMergeFieldValue || (_.isObject(mergeFieldConfig) && mergeFieldConfig.when && mergeFieldConfig.when === "always")) { - acc[mergeFieldName] = newMergeFieldValue; + const conversionToApply = _.isObject(mergeFieldConfig) && mergeFieldConfig.typeConversion ? mergeFieldConfig.typeConversion : "none"; + let finalValue = newMergeFieldValue; + switch (conversionToApply) { + case "timestampToDate": + finalValue = convertTimestampToMailchimpDate(newMergeFieldValue); + break; + case "stringToNumber": + finalValue = Number(newMergeFieldValue); + assert(!isNaN(finalValue), `${newMergeFieldValue} could not be converted to a number.`); + break; + default: + break; + } + _.set(acc, mergeFieldName, finalValue); } return acc; }, {}); diff --git a/functions/tests/mergeFieldsHandler.test.js b/functions/tests/mergeFieldsHandler.test.js index cbf1205..80e0359 100644 --- a/functions/tests/mergeFieldsHandler.test.js +++ b/functions/tests/mergeFieldsHandler.test.js @@ -1,6 +1,7 @@ jest.mock("@mailchimp/mailchimp_marketing"); const functions = require("firebase-functions-test"); +const admin = require("firebase-admin"); const mailchimp = require("@mailchimp/mailchimp_marketing"); const { errorWithStatus, defaultConfig } = require("./utils"); @@ -1056,4 +1057,250 @@ describe("mergeFieldsHandler", () => { }, ); }); + + it("should set address data for user when using nested field names", async () => { + configureApi({ + ...defaultConfig, + mailchimpMergeField: JSON.stringify({ + mergeFields: { + firstName: "FNAME", + lastName: "LNAME", + phoneNumber: "PHONE", + addressLine1: "ADDRESS.addr1", + addressLine2: "ADDRESS.addr2", + addressCity: "ADDRESS.city", + addressState: "ADDRESS.state", + addressZip: "ADDRESS.zip", + addressCountry: "ADDRESS.country", + }, + subscriberEmail: "emailAddress", + }), + }); + const wrapped = testEnv.wrap(api.mergeFieldsHandler); + + const testUser = { + uid: "122", + displayName: "lee", + firstName: "first name", + lastName: "last name", + phoneNumber: "phone number", + emailAddress: "test@example.com", + addressLine1: "Line 1", + addressLine2: "Line 2", + addressCity: "City", + addressState: "State", + addressZip: "Zip", + addressCountry: "Country", + }; + + const result = await wrapped({ + after: { + data: () => testUser, + }, + }); + + expect(result).toBe(undefined); + expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); + expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( + "mailchimpAudienceId", + "55502f40dc8b7c769880b10874abc9d0", + { + email_address: "test@example.com", + merge_fields: { + FNAME: "first name", + LNAME: "last name", + PHONE: "phone number", + ADDRESS: { + addr1: "Line 1", + addr2: "Line 2", + city: "City", + country: "Country", + state: "State", + zip: "Zip", + }, + }, + status_if_new: "mailchimpContactStatus", + }, + ); + }); + + it("should convert timestamp to date", async () => { + configureApi({ + ...defaultConfig, + mailchimpMergeField: JSON.stringify({ + mergeFields: { + firstName: "FNAME", + lastName: "LNAME", + phoneNumber: "PHONE", + createdDate: { + mailchimpFieldName: "CREATED_AT", + typeConversion: "timestampToDate", + }, + }, + subscriberEmail: "emailAddress", + }), + }); + const wrapped = testEnv.wrap(api.mergeFieldsHandler); + + const testUser = { + uid: "122", + displayName: "lee", + firstName: "first name", + lastName: "last name", + phoneNumber: "phone number", + emailAddress: "test@example.com", + createdDate: new admin.firestore.Timestamp(1692572400, 233000000), + }; + + const result = await wrapped({ + after: { + data: () => testUser, + }, + }); + + expect(result).toBe(undefined); + expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); + expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( + "mailchimpAudienceId", + "55502f40dc8b7c769880b10874abc9d0", + { + email_address: "test@example.com", + merge_fields: { + FNAME: "first name", + LNAME: "last name", + PHONE: "phone number", + CREATED_AT: "2023-08-20", + }, + status_if_new: "mailchimpContactStatus", + }, + ); + }); + + it("should fail timestamp conversion if type is incorrect", async () => { + configureApi({ + ...defaultConfig, + mailchimpMergeField: JSON.stringify({ + mergeFields: { + firstName: "FNAME", + lastName: "LNAME", + phoneNumber: "PHONE", + createdDate: { + mailchimpFieldName: "CREATED_AT", + typeConversion: "timestampToDate", + }, + }, + subscriberEmail: "emailAddress", + }), + }); + const wrapped = testEnv.wrap(api.mergeFieldsHandler); + + const testUser = { + uid: "122", + displayName: "lee", + firstName: "first name", + lastName: "last name", + phoneNumber: "phone number", + emailAddress: "test@example.com", + createdDate: "1692572400", + }; + + const result = await wrapped({ + after: { + data: () => testUser, + }, + }); + + expect(result).toBe(undefined); + expect(mailchimp.lists.setListMember).not.toHaveBeenCalled(); + }); + + it("should convert number string to number", async () => { + configureApi({ + ...defaultConfig, + mailchimpMergeField: JSON.stringify({ + mergeFields: { + firstName: "FNAME", + lastName: "LNAME", + phoneNumber: "PHONE", + eventCount: { + mailchimpFieldName: "EVENT_COUNT", + typeConversion: "stringToNumber", + }, + }, + subscriberEmail: "emailAddress", + }), + }); + const wrapped = testEnv.wrap(api.mergeFieldsHandler); + + const testUser = { + uid: "122", + displayName: "lee", + firstName: "first name", + lastName: "last name", + phoneNumber: "phone number", + emailAddress: "test@example.com", + eventCount: "3.45", + }; + + const result = await wrapped({ + after: { + data: () => testUser, + }, + }); + + expect(result).toBe(undefined); + expect(mailchimp.lists.setListMember).toHaveBeenCalledTimes(1); + expect(mailchimp.lists.setListMember).toHaveBeenCalledWith( + "mailchimpAudienceId", + "55502f40dc8b7c769880b10874abc9d0", + { + email_address: "test@example.com", + merge_fields: { + FNAME: "first name", + LNAME: "last name", + PHONE: "phone number", + EVENT_COUNT: 3.45, + }, + status_if_new: "mailchimpContactStatus", + }, + ); + }); + + it("should fail string to number conversion", async () => { + configureApi({ + ...defaultConfig, + mailchimpMergeField: JSON.stringify({ + mergeFields: { + firstName: "FNAME", + lastName: "LNAME", + phoneNumber: "PHONE", + eventCount: { + mailchimpFieldName: "EVENT_COUNT", + typeConversion: "stringToNumber", + }, + }, + subscriberEmail: "emailAddress", + }), + }); + const wrapped = testEnv.wrap(api.mergeFieldsHandler); + + const testUser = { + uid: "122", + displayName: "lee", + firstName: "first name", + lastName: "last name", + phoneNumber: "phone number", + emailAddress: "test@example.com", + eventCount: "test", + }; + + const result = await wrapped({ + after: { + data: () => testUser, + }, + }); + + expect(result).toBe(undefined); + expect(mailchimp.lists.setListMember).not.toHaveBeenCalled(); + }); }); diff --git a/functions/validation.js b/functions/validation.js index 2f0cf35..6c209d2 100644 --- a/functions/validation.js +++ b/functions/validation.js @@ -52,6 +52,7 @@ const mergeFieldsExtendedConfigSchema = { type: "object", properties: { mailchimpFieldName: { type: "string" }, + typeConversion: { type: "string", enum: ["none", "timestampToDate", "stringToNumber"] }, when: { type: "string", enum: ["changed", "always"] }, }, required: ["mailchimpFieldName"],