diff --git a/.eslintrc.js b/.eslintrc.js index 511bede27f..7749c2d86b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { root: true, - ignorePatterns: ['node_modules', 'dist', 'templates', '**/node_modules'], + ignorePatterns: ['node_modules', 'dist', 'templates', 'scripts', '**/node_modules'], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 2019, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6a367de782..5ffaa510ea 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -6,11 +6,11 @@ # Actions common lib folder -actions-shared/ @segmentio/build-experience-team +actions-shared/ @segmentio/build-experience-team @segmentio/strategic-connections-team # AJV utils -ajv-human-errors/ @segmentio/build-experience-team +ajv-human-errors/ @segmentio/build-experience-team @segmentio/strategic-connections-team # Browser destinations @@ -18,15 +18,15 @@ browser-destinations/ @segmentio/libraries-web-team @segmentio/strategic-connect # CLI private libs -cli-internal/ @segmentio/build-experience-team +cli-internal/ @segmentio/build-experience-team @segmentio/strategic-connections-team # CLI binary -cli/ @segmentio/build-experience-team +cli/ @segmentio/build-experience-team @segmentio/strategic-connections-team # Core actions runtime -core/ @segmentio/build-experience-team +core/ @segmentio/build-experience-team @segmentio/strategic-connections-team # Destination definitions and their actions @@ -34,4 +34,4 @@ destination-actions/ @segmentio/strategic-connections-team @segmentio/build-expe # Utilities for event payload validation against an action's subscription AST. -destination-subscriptions/ @segmentio/build-experience-team +destination-subscriptions/ @segmentio/build-experience-team @segmentio/strategic-connections-team diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30292a181a..f7cda9abd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,116 +7,178 @@ on: pull_request: jobs: - test-and-build: + test: + name: Unit tests runs-on: ubuntu-20.04 - timeout-minutes: 30 - strategy: matrix: node-version: [18.x] steps: - - uses: actions/checkout@v2 + # See nx recipe: https://nx.dev/recipes/ci/monorepo-ci-github-actions + - uses: actions/checkout@v3 with: persist-credentials: false + fetch-depth: 0 # nx recipe - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} registry-url: 'https://registry.npmjs.org' + cache: yarn - - name: Get yarn cache directory path - id: yarn-cache-dir-path - run: echo "::set-output name=dir::$(yarn cache dir)" + - name: Use Github Personal Access Token + run: git config --global url."https://${{ secrets.GH_PAT }}@github.com/".insteadOf ssh://git@github.com/ - - uses: actions/cache@v2 - id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + - uses: nrwl/nx-set-shas@v3 # nx recipe + + - name: Install Dependencies + run: yarn install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Build (Affected) + run: NODE_ENV=production yarn nx affected -t build --parallel=3 # nx recipe + + - name: Test (Affected) + run: yarn nx affected -t test --parallel=3 # nx recipe + + lint: + name: Lint + runs-on: ubuntu-20.04 + timeout-minutes: 20 + strategy: + matrix: + node-version: [18.x] + + steps: + - uses: actions/checkout@v3 with: - path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-yarn- + persist-credentials: false + fetch-depth: 0 # nx recipe + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + registry-url: 'https://registry.npmjs.org' + cache: yarn - name: Use Github Personal Access Token run: git config --global url."https://${{ secrets.GH_PAT }}@github.com/".insteadOf ssh://git@github.com/ + - uses: nrwl/nx-set-shas@v3 # nx recipe + - name: Install Dependencies run: yarn install --frozen-lockfile env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Build - run: NODE_ENV=production yarn build + - name: Build # TODO: This monorepo should be refactored so packages can be linted invidually. "affected" will not work ATM. + run: NODE_ENV=production yarn build # nx recipe - name: Lint env: NODE_OPTIONS: '--max-old-space-size=4096' run: yarn lint - - name: Validate - run: yarn validate + validate: + name: Validate + runs-on: ubuntu-20.04 + timeout-minutes: 20 + strategy: + matrix: + node-version: [18.x] - - name: Test - run: yarn test + steps: + - uses: actions/checkout@v3 + with: + persist-credentials: false + fetch-depth: 0 # nx recipe + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + registry-url: 'https://registry.npmjs.org' + cache: yarn + + - name: Use Github Personal Access Token + run: git config --global url."https://${{ secrets.GH_PAT }}@github.com/".insteadOf ssh://git@github.com/ + + - uses: nrwl/nx-set-shas@v3 # nx recipe + + - name: Install Dependencies + run: yarn install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Assert yarn.lock is up-to-date + run: bash scripts/assert-lockfile-updated.sh + + - name: Build # TODO: This monorepo should be refactored so packages can be linted invidually. "affected" will not work ATM. + run: NODE_ENV=production yarn build # nx recipe + + - name: Validate Definitions + run: yarn validate - - name: destination-subscriptions size + - name: Destination Subscription Size run: | if $(lerna changed | grep -q destination-subscriptions); then yarn subscriptions size fi - # browser-tests-destination: - # # env: # Disable saucelabs - we blew through our quota. - # # SAUCE_USERNAME: ${{secrets.SAUCE_USERNAME}} - # # SAUCE_ACCESS_KEY: ${{secrets.SAUCE_ACCESS_KEY}} + - name: Assert generated types are up-to-date + run: bash scripts/assert-types-updated.sh - # runs-on: ubuntu-20.04 - - # timeout-minutes: 20 - - # strategy: - # matrix: - # node-version: [18.x] + browser-destination-bundle-qa: + name: Browser Destination Bundle QA + # env: # Disable saucelabs - we blew through our quota. + # SAUCE_USERNAME: ${{secrets.SAUCE_USERNAME}} + # SAUCE_ACCESS_KEY: ${{secrets.SAUCE_ACCESS_KEY}} + runs-on: ubuntu-20.04 + timeout-minutes: 20 + strategy: + matrix: + node-version: [18.x] - # steps: - # - uses: actions/checkout@master + steps: + - uses: actions/checkout@v3 + with: + persist-credentials: false - # - name: Use Node.js ${{ matrix.node-version }} - # uses: actions/setup-node@v2 - # with: - # node-version: ${{ matrix.node-version }} - # registry-url: 'https://registry.npmjs.org' + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + registry-url: 'https://registry.npmjs.org' + cache: yarn - # - name: Get yarn cache directory path - # id: yarn-cache-dir-path - # run: echo "::set-output name=dir::$(yarn cache dir)" + - name: Use Github Personal Access Token + run: git config --global url."https://${{ secrets.GH_PAT }}@github.com/".insteadOf ssh://git@github.com/ - # - uses: actions/cache@v2 - # id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) - # with: - # path: ${{ steps.yarn-cache-dir-path.outputs.dir }} - # key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - # restore-keys: | - # ${{ runner.os }}-yarn- + - name: Install Dependencies + run: yarn install --frozen-lockfile + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # - name: Install Dependencies - # run: yarn install --frozen-lockfile - # env: - # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Build + run: NODE_ENV=production yarn build:browser-bundles - # - name: Build - # run: NODE_ENV=production yarn build:browser-destinations && yarn browser build-web + - name: Size Limit + run: yarn browser size - # - name: Run Saucelabs Tests - # working-directory: packages/browser-destinations-integration-tests - # shell: bash - # run: | - # yarn start-destination-server & - # yarn test:sauce + # - name: Run Saucelabs Tests + # working-directory: packages/browser-destinations-integration-tests + # shell: bash + # run: | + # yarn start-destination-server & + # yarn test:sauce browser-tests-core: + name: 'Browser tests: actions-core' runs-on: ubuntu-20.04 timeout-minutes: 10 @@ -164,6 +226,7 @@ jobs: run: yarn test-browser snyk: + name: Snyk runs-on: ubuntu-20.04 timeout-minutes: 5 diff --git a/.github/workflows/label-prs.yml b/.github/workflows/label-prs.yml new file mode 100644 index 0000000000..8f3355effe --- /dev/null +++ b/.github/workflows/label-prs.yml @@ -0,0 +1,56 @@ +# This workflow labels PRs based on the files that were changed. It uses a custom script to this +# instead of actions/labeler as few of the tags are more than just file changes. + +name: Label PRs +on: + pull_request_target: + types: [opened, synchronize, reopened] + +jobs: + pr-labeler: + runs-on: ubuntu-20.04 + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Compute Labels + id: compute-labels + uses: actions/github-script@v7 + with: + # Required for the script to access team membership information. + # Scope: members:read and contentes:read permission on the organization. + github-token: ${{ secrets.GH_PAT_MEMBER_AND_PULL_REQUEST_READONLY }} + script: | + const script = require('./scripts/compute-labels.js') + await script({github, context, core}) + - name: Apply Labels + uses: actions/github-script@v7 + env: + labelsToAdd: '${{ steps.compute-labels.outputs.add }}' + labelsToRemove: '${{ steps.compute-labels.outputs.remove }}' + with: + script: | + const { labelsToAdd, labelsToRemove } = process.env + if(labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + issue_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: labelsToAdd.split(',') + }); + } + if(labelsToRemove.length > 0) { + const requests = labelsToRemove.split(',').map(label => { + return github.rest.issues.removeLabel({ + issue_number: context.payload.pull_request.number, + name: label, + owner: context.repo.owner, + repo: context.repo.repo + }); + }); + await Promise.all(requests); + } diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 715b854940..c141670881 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,3 +56,35 @@ jobs: - name: Publish run: | yarn lerna publish from-git --yes --allowBranch=main --loglevel=verbose --dist-tag latest + + - name: Generate and Push Release Tag + id: push-release-tag + run: | + git config user.name ${{ github.actor }} + git config user.email ${{ github.actor }}@users.noreply.github.com + + commit=${{ github.sha }} + if ! n=$(git rev-list --count $commit~ --grep "Publish" --since="00:00"); then + echo 'failed to calculate tag' + exit 1 + fi + + case "$n" in + 0) suffix="" ;; # first commit of the day gets no suffix + *) suffix=".$n" ;; # subsequent commits get a suffix, starting with .1 + esac + + tag=$(printf release-$(date '+%Y-%m-%d%%s') $suffix) + git tag -a $tag -m "Release $tag" + git push origin $tag + echo "release-tag=$tag" >> $GITHUB_OUTPUT + + - name: Create Github Release + id: create-github-release + uses: actions/github-script@v7 + env: + RELEASE_TAG: ${{ steps.push-release-tag.outputs.release-tag }} + with: + script: | + const script = require('./scripts/create-github-release.js') + await script({github, context, core, exec}) diff --git a/.gitignore b/.gitignore index 35b316e330..128e678d43 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ node_modules dist .DS_Store *.log -*.tsbuildinfo +*.tsbuildinfo* .eslintcache package-lock.json .env diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..ea31b7529b --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +packages/cli-internal diff --git a/.vscode/launch.json b/.vscode/launch.json index 3807a2101d..61877fb64f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -36,6 +36,17 @@ "port": 47082, "args": ["serve"] }, + { + "type": "node", + "request": "launch", + "name": "Run CLI Serve without UI", + "program": "${workspaceFolder}/bin/run", + "restart": true, + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "port": 47082, + "args": ["serve", "-n"] + }, { "type": "node", "request": "launch", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 40b75bb09c..170f25e318 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ Before continuing, please make sure to read our [Code of Conduct](./CODE_OF_COND 3. Spec out the integration. If you want some guidance, you can use this [template](https://docs.google.com/document/d/1dIJxYge9N700U9Nhawapy25WMD8pUuey72S5qo3uejA/edit#heading=h.92w309fjzhti), which will prompt you to think about: whether you want to build a cloud-mode or device-mode destination, the method of authentication, the settings, and the Actions and default Field Mappings that you want to build. -4. Join the Segment Partners Slack workspace. We’ll send you an invite. The **#dev-center-pilot** channel is the space for questions - partners can share their advice with each other, and the Segment team is there to answer any tricky questions. +4. If you have any questions during the build process, please contact us at partner-support@segment.com. ## Build your integration @@ -50,7 +50,7 @@ Before continuing, please make sure to read our [Code of Conduct](./CODE_OF_COND - For cloud-mode destinations, follow these instructions: [Build & Test Cloud Destinations](./docs/testing.md). - If you are building a device-mode destination, see the [browser-destinations README](./packages/browser-destinations/README.md). -4. When you have questions, ask in the Segment Partners Slack workspace - use the **#dev-center-pilot** channel. +4. When you have questions, reach out to partner-support@segment.com for assistance. ## Submit a pull request diff --git a/README.md b/README.md index 145bd84c09..c2ca148ddb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

# Action Destinations + Action Destinations are the new way to build streaming destinations on Segment. Action Destinations were [launched in December 2021](https://segment.com/blog/introducing-destination-actions/) to enable customers with a customizable framework to map Segment event sources to their favorite 3rd party tools like Google Analytics. @@ -30,6 +31,7 @@ For more detailed instruction, see the following READMEs: - [Presets](#presets) - [perform function](#the-perform-function) - [Batching Requests](#batching-requests) +- [Action Hooks](#action-hooks) - [HTTP Requests](#http-requests) - [Support](#support) @@ -37,7 +39,13 @@ For more detailed instruction, see the following READMEs: ### Local development -This is a monorepo with multiple packages leveraging [`lerna`](https://github.com/lerna/lerna) with [Yarn Workspaces](https://classic.yarnpkg.com/en/docs/workspaces): +This is a monorepo with multiple packages leveraging: + +- [`lerna`](https://github.com/lerna/lerna) for publishing +- [`nx`](https://nx.dev) for dependency-tree aware building, linting, testing, and caching (migration away from `lerna` in progress!). +- [Yarn Workspaces](https://classic.yarnpkg.com/en/docs/workspaces) for package symlinking and hoisting. + +Structure: - `packages/ajv-human-errors` - a wrapper around [AJV](https://ajv.js.org/) errors to produce more friendly validation messages - `packages/browser-destinations` - destination definitions that run on device via Analytics 2.0 @@ -65,9 +73,8 @@ yarn login # Requires node 18.12.1, optionally: nvm use 18.12.1 yarn --ignore-optional -yarn bootstrap -yarn build yarn install +yarn build # Run unit tests to ensure things are working! For partners who don't have access to internal packages, you can run: yarn test-partners @@ -383,6 +390,74 @@ const destination = { } ``` +## Conditional Fields + +Conditional fields enable a field only when a predefined list of conditions are met while the user steps through the mapping editor. This is useful when showing a field becomes unnecessary based on the value of some other field. + +For example, in the Salesforce destination the 'Bulk Upsert External ID' field is only relevant when the user has selected 'Operation: Upsert' and 'Enable Batching: True'. In all other cases the field will be hidden to streamline UX while setting up the mapping. + +To define a conditional field, the `InputField` should implement the `depends_on` property. This property lives in destination-kit and the definition can be found here: [`packages/core/src/destination-kit/types.ts`](https://github.com/segmentio/action-destinations/blame/854a9e154547a54a7323dc3d4bf95bc31d31433a/packages/core/src/destination-kit/types.ts). + +The above Salesforce use case is defined like this: + +```js +export const bulkUpsertExternalId: InputField = { + // other properties skipped for brevity ... + depends_on: { + match: 'all', // match is optional and can be either 'any' or 'all'. If left undefiend it defaults to matching all conditions. + conditions: [ + { + fieldKey: 'operation', // field keys must match some other field in the same action + operator: 'is', + value: 'upsert' + }, + { + fieldKey: 'enable_batching', + operator: 'is', + value: true + } + ] + } +} +``` + +Lists of values can also be included as match conditions. For example: + +```js +export const recordMatcherOperator: InputField = { + // ... + depends_on: { + // This is interpreted as "show recordMatcherOperator if operation is (update or upsert or delete)" + conditions: [ + { + fieldKey: 'operation', + operator: 'is', + value: ['update', 'upsert', 'delete'] + } + ] + } +} +``` + +The value can be undefined, which allows matching against empty fields or fields which contain any value. For example: + +```js +export const name: InputField = { + // ... + depends_on: { + match: 'all', + // The name field will be shown only if conversionRuleId is not empty. + conditions: [ + { + fieldKey: 'conversionRuleId', + operator: 'is_not', + value: undefined + } + ] + } +} +``` + ## Presets Presets are pre-built use cases to enable customers to get started quickly with an action destination. They include everything needed to generate a valid subscription. @@ -431,6 +506,7 @@ The `perform` method accepts two arguments, (1) the request client instance (ext - `features` - The features available in the request based on the customer's sourceID. Features can only be enabled and/or used by internal Twilio/Segment employees. Features cannot be used for Partner builds. - `statsContext` - An object, containing a `statsClient` and `tags`. Stats can only be used by internal Twilio/Segment employees. Stats cannot be used for Partner builds. - `logger` - Logger can only be used by internal Twilio/Segment employees. Logger cannot be used for Partner builds. +- `dataFeedCache` - DataFeedCache can only be used by internal Twilio/Segment employees. DataFeedCache cannot be used for Partner builds. - `transactionContext` - An object, containing transaction variables and a method to update transaction variables which are required for few segment developed actions. Transaction Context cannot be used for Partner builds. - `stateContext` - An object, containing context variables and a method to get and set context variables which are required for few segment developed actions. State Context cannot be used for Partner builds. @@ -511,6 +587,104 @@ Keep in mind a few important things about how batching works: Additionally, you’ll need to coordinate with Segment’s R&D team for the time being. Please reach out to us in your dedicated Slack channel! +## Action Hooks + +**Note: This feature is not yet released.** + +Hooks allow builders to perform requests against a destination at certain points in the lifecycle of a mapping. Values can then be persisted from that request to be used later on in the action's `perform` method. + +**Inputs** + +Builders may define a set of `inputFields` that are used when performing the request to the destination. + +**`performHook`** + +Similar to the `perform` method, the `performHook` method allows builders to trigger a request to the destination whenever the criteria for that hook to be triggered is met. This method uses the `inputFields` defined as request parameters. + +**Outputs** + +Builders define the shape of the hook output with the `outputTypes` property. Successful returns from `performHook` should match the keys defined here. These values are then saved on a per-mapping basis, and can be used in the `perform` or `performBatch` methods when events are sent through the mapping. + +### Example (LinkedIn Conversions API) + +This example has been shorted for brevity. The full code can be seen in the LinkedIn Conversions API 'streamConversion' action. + +```js +const action: ActionDefinition = { + title: 'Stream Conversion Event', + ... + hooks: { + 'onMappingSave': { + type: 'onMappingSave', + label: 'Create a Conversion Rule', + description: + 'When saving this mapping, we will create a conversion rule in LinkedIn using the fields you provided.', + inputFields: { + name: { + type: 'string', + label: 'Name', + description: 'The name of the conversion rule.', + required: true + }, + conversionType: { + type: 'string', + label: 'Conversion Type', + description: 'The type of conversion rule.', + required: true + }, + }, + outputTypes: { + id: { + type: 'string', + label: 'ID', + description: 'The ID of the conversion rule.', + required: true + }, + name: { + type: 'string', + label: 'Name', + description: 'The name of the conversion rule.', + required: true + }, + }, + performHook: async (request, { hookInputs }) => { + const { data } = + await request + ('https://api.linkedin.com/rest/conversions', { + method: 'post', + json: { + name: hookInputs.name, + type: hookInputs.conversionType + } + }) + + return { + successMessage: + `Conversion rule ${data.id} created successfully!`, + savedData: { + id: data.id, + name: data.name, + } + } + } + } + }, + perform: (request, data) => { + return request('https://example.com', { + method: 'post', + json: { + conversion: data.hookOutputs?.onMappingSave?.id, + name: data.hookOutputs?.onMappingSave?.name + } + }) + } + } +``` + +### `onMappingSave` hook + +The `onMappingSave` hook is triggered after a user clicks 'Save' on a mapping. The result of the hook is then saved to the users configuration as if it were a normal field. Builders can access the saved values in the `perform` block by referencing `data.hookOutputs?.onMappingSave?.`. + ## Audience Support (Pilot) In order to support audience destinations, we've introduced a type that extends regular destinations: @@ -646,7 +820,7 @@ For any issues, please contact our support team at partner-support@segment.com. MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/docs/authentication.md b/docs/authentication.md index cd53517165..65dd4d9866 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -98,20 +98,25 @@ const destination = { } ``` -## OAuth2 Authentication Scheme +## OAuth 2.0 Managed Authentication Scheme -_oauth authentication is not generally available to external partners as part of Developer Center Pilot. Please contact Segment team if you require it._ +OAuth 2.0 Managed Authentication scheme is the model to be used for destination APIs which support [OAuth 2.0](https://oauth.net/2/). You’ll be able to define a `refreshAccessToken` function if you want the framework to refresh expired tokens. -OAuth2 Authentication scheme is the model to be used for destination APIs which support [OAuth 2.0](https://oauth.net/2/). You’ll be able to define a `refreshAccessToken` function if you want the framework to refresh expired tokens. +You will have a new `auth` object available in `extendRequest` and `refreshAccessToken` which will surface your destination’s accessToken, refreshToken, clientId and clientSecret (these last two only available in `refreshAccessToken`). Most destination APIs expect the access token to be used as part of the authorization header in every request. You can use `extendRequest` to define that header. -You will have a new `auth` object available in `extendRequest` and `refreshAccessToken` which will surface your destination’s accessToken, refreshToken, clientId and clientSecret (these last two only available in `refreshAccessToken`). +Once your Actions code is deployed and you've received an invitation to manage the destination within our developer portal, you can then provide Segment with the following OAuth parameters. -Most destination APIs expect the access token to be used as part of the authorization header in every request. You can use `extendRequest` to define that header. +- Client ID +- Client Secret +- Authorization server URL + - Specify where to send users to authenticate with your API. +- Access token server URL + - Enter the API endpoint URL where Segment sends the approval code on user redirect. ``` authentication: { - scheme: 'oauth2', + scheme: 'oauth-managed', fields: { subdomain: { type: 'string', @@ -148,8 +153,6 @@ authentication: { } ``` -**Note:** OAuth directly depends on the oauth providers available in Segment's internal OAuth Service. Please contact Segment if you require OAuth for your destination. - ## Unsupported Authentication Schemes We will explore adding built-in support for more authentication schemes when there is sufficient demand. These might include: diff --git a/docs/testing.md b/docs/testing.md index fe47fd8b3e..68c5d39234 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -42,6 +42,8 @@ The default port is set to `3000`. To use a different port, you can specify the After running the `serve` command, select the destination you want to test locally. Once a destination is selected the server should start up. +You can also run the serve command for a specific Destination without the Web UI being started up. For example `./bin/run serve --destination=criteo-audiences -n` will start the process for the criteo-audiences Destination, but will not start the Actions Tester web user interface. + ### Testing an Action's perform() or performBatch() function To test a specific destination action's perform() or performBatch() function you can send a Postman or cURL request with the following URL format: `https://localhost:/`. A list of eligible URLs will also be provided by the CLI command when the server is spun up. @@ -325,7 +327,7 @@ export NODE_ENV=test ## Code Coverage -Code coverage is automatically collected upon completion of `yarn test`. Results may be inspected by examining the HTML report found at `coverage/lcov-report/index.html`, or directly in your IDE if _lcov_ is supported. +Code coverage is collected upon completion of `yarn test --coverage`. Results may be inspected by examining the HTML report found at `coverage/lcov-report/index.html`, or directly in your IDE if _lcov_ is supported. ## Post Deployment Change Testing diff --git a/nx.json b/nx.json new file mode 100644 index 0000000000..3fb6d6e687 --- /dev/null +++ b/nx.json @@ -0,0 +1,37 @@ +{ + "$schema": "./node_modules/nx/schemas/nx-schema.json", + "namedInputs": { + "sharedGlobals": ["{workspaceRoot}/nx.json", "{workspaceRoot}/tsconfig.json"], + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "production": [ + "default", + "{projectRoot}/tsconfig.json", + "{projectRoot}/tsconfig.build.json", + "{projectRoot}/webpack.config*", + "{projectRoot}/babel.config*", + "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", + "!{projectRoot}/**/test/**/*" + ] + }, + "tasksRunnerOptions": { + "default": { + "runner": "nx/tasks-runners/default", + "options": { + "cacheableOperations": ["build", "test"] + } + } + }, + "targetDefaults": { + "build": { + "inputs": ["production", "^production"], + "dependsOn": ["^build"] + }, + "test": { + "inputs": ["default", "^production"], + "dependsOn": ["build"] + } + }, + "affected": { + "defaultBase": "main" + } +} diff --git a/package.json b/package.json index 919d3caead..042bfd9c5b 100644 --- a/package.json +++ b/package.json @@ -18,13 +18,13 @@ "cli-internal": "yarn workspace @segment/actions-cli-internal", "core": "yarn workspace @segment/actions-core", "bootstrap": "lerna bootstrap", - "build": "./bin/run generate:types && lerna run build --concurrency 1 --stream --ignore @segment/actions-cli-internal && yarn browser build-web", - "build:browser-destinations": "yarn lerna run build --concurrency 1 --scope=@segment/destinations-manifest --include-dependencies --stream && yarn browser build-web", + "build": "nx run-many -t build && yarn build:browser-bundles", + "build:browser-bundles": "nx build @segment/destinations-manifest && nx build-web @segment/browser-destinations", "types": "./bin/run generate:types", "validate": "./bin/run validate", "lint": "ls -d ./packages/* | xargs -I {} eslint '{}/**/*.ts' --cache", "subscriptions": "yarn workspace @segment/destination-subscriptions", - "test": "lerna run test --stream", + "test": "nx run-many -t test", "test-partners": "lerna run test --stream --ignore @segment/actions-core --ignore @segment/actions-cli --ignore @segment/ajv-human-errors", "test-browser": "bash scripts/test-browser.sh", "typecheck": "lerna run typecheck --stream", @@ -68,7 +68,7 @@ "prettier": "^2.4.1", "process": "^0.11.10", "timers-browserify": "^2.0.12", - "ts-jest": "^27.0.0", + "ts-jest": "^27.0.7", "ts-node": "^9.1.1", "typescript": "4.3.5", "ws": "^8.5.0" diff --git a/packages/actions-shared/README.md b/packages/actions-shared/README.md index 15f413080d..53ac1334f5 100644 --- a/packages/actions-shared/README.md +++ b/packages/actions-shared/README.md @@ -6,7 +6,7 @@ Shared definitions and utilities MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/actions-shared/package.json b/packages/actions-shared/package.json index 0b7290b9c7..a464e9a0b8 100644 --- a/packages/actions-shared/package.json +++ b/packages/actions-shared/package.json @@ -1,7 +1,7 @@ { "name": "@segment/actions-shared", "description": "Shared destination action methods and definitions.", - "version": "1.65.0", + "version": "1.84.0", "repository": { "type": "git", "url": "https://github.com/segmentio/action-destinations", @@ -23,7 +23,7 @@ "registry": "https://registry.npmjs.org" }, "scripts": { - "build": "yarn clean && yarn tsc -b tsconfig.build.json", + "build": "yarn tsc -b tsconfig.build.json", "clean": "tsc -b tsconfig.build.json --clean", "postclean": "rm -rf dist", "prepublishOnly": "yarn build", @@ -37,7 +37,7 @@ }, "dependencies": { "@amplitude/ua-parser-js": "^0.7.25", - "@segment/actions-core": "^3.83.0", + "@segment/actions-core": "^3.103.0", "cheerio": "^1.0.0-rc.10", "dayjs": "^1.10.7", "escape-goat": "^3", @@ -46,6 +46,11 @@ }, "jest": { "preset": "ts-jest", + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, "testEnvironment": "node", "modulePathIgnorePatterns": [ "/dist/" diff --git a/packages/ajv-human-errors/README.md b/packages/ajv-human-errors/README.md index 64cccbf30d..d61c221fbe 100644 --- a/packages/ajv-human-errors/README.md +++ b/packages/ajv-human-errors/README.md @@ -202,7 +202,7 @@ Returns this error message when validating a non-string object: MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/ajv-human-errors/package.json b/packages/ajv-human-errors/package.json index 811f31a97a..f81911b264 100644 --- a/packages/ajv-human-errors/package.json +++ b/packages/ajv-human-errors/package.json @@ -1,6 +1,6 @@ { "name": "@segment/ajv-human-errors", - "version": "2.11.3", + "version": "2.12.0", "description": "Human-readable error messages for Ajv (Another JSON Schema Validator).", "repository": { "type": "git", diff --git a/packages/browser-destination-runtime/package.json b/packages/browser-destination-runtime/package.json index e02c841693..adc0d9196c 100644 --- a/packages/browser-destination-runtime/package.json +++ b/packages/browser-destination-runtime/package.json @@ -1,15 +1,17 @@ { "name": "@segment/browser-destination-runtime", - "version": "1.14.0", + "version": "1.33.0", "license": "MIT", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" }, "scripts": { - "build": "yarn build:esm && yarn build:cjs", - "build:esm": "tsc --outDir ./dist/esm", - "build:cjs": "tsc --module commonjs --outDir ./dist/cjs" + "build-ts": "yarn tsc -p tsconfig.build.json", + "build": "yarn build-ts && yarn build:esm && yarn build:cjs", + "build:esm": "tsc -p tsconfig.build.json --outDir ./dist/esm", + "build:cjs": "tsc -p tsconfig.build.json --module commonjs --outDir ./dist/cjs", + "clean": "tsc -b tsconfig.build.json --clean" }, "exports": { ".": { @@ -60,12 +62,29 @@ } }, "dependencies": { - "@segment/actions-core": "^3.83.0" + "@segment/actions-core": "^3.103.0" }, "devDependencies": { "@segment/analytics-next": "*" }, "peerDependencies": { "@segment/analytics-next": "*" + }, + "jest": { + "preset": "ts-jest", + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, + "testEnvironment": "node", + "modulePathIgnorePatterns": [ + "/dist/" + ], + "moduleNameMapper": { + "@segment/ajv-human-errors": "/../ajv-human-errors/src", + "@segment/actions-core": "/../core/src", + "@segment/destination-subscriptions": "/../destination-subscriptions/src" + } } } diff --git a/packages/browser-destination-runtime/tsconfig.build.json b/packages/browser-destination-runtime/tsconfig.build.json new file mode 100644 index 0000000000..8c38965829 --- /dev/null +++ b/packages/browser-destination-runtime/tsconfig.build.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "esnext", + "composite": true, + "outDir": "dist", + "rootDir": "src", + "allowJs": true, + "importHelpers": true, + "lib": ["es2020", "dom"], + "baseUrl": ".", + "paths": { + "@segment/actions-core/*": ["../core/src/*"] + } + }, + "exclude": ["**/__tests__/**/*.ts"], + "include": ["src"], + "references": [ + { "path": "../core/tsconfig.build.json" }, + { "path": "../destination-subscriptions/tsconfig.build.json" } + ] +} diff --git a/packages/browser-destination-runtime/tsconfig.json b/packages/browser-destination-runtime/tsconfig.json index b311e107d5..81a9959f93 100644 --- a/packages/browser-destination-runtime/tsconfig.json +++ b/packages/browser-destination-runtime/tsconfig.json @@ -3,7 +3,14 @@ "compilerOptions": { "module": "esnext", "removeComments": false, - "baseUrl": "." + "baseUrl": ".", + "paths": { + "@segment/actions-core": ["../core/src"], + "@segment/actions-core/*": ["../core/src/*"], + "@segment/actions-shared": ["../actions-shared/src"], + "@segment/action-destinations": ["../destination-actions/src"], + "@segment/destination-subscriptions": ["../destination-subscriptions/src"] + } }, "exclude": ["dist"] -} \ No newline at end of file +} diff --git a/packages/browser-destinations-integration-tests/package.json b/packages/browser-destinations-integration-tests/package.json index 3843b78a24..17f02fab8a 100644 --- a/packages/browser-destinations-integration-tests/package.json +++ b/packages/browser-destinations-integration-tests/package.json @@ -12,7 +12,7 @@ "test:sauce": "wdio wdio.conf.sauce.ts", "test:local": "wdio wdio.conf.local.ts", "start-destination-server": "yarn ts-node src/server/start-destination-server.ts", - "browser-destinations:build": "NODE_ENV=production yarn lerna run build --scope=@segment/browser-destinations --include-dependencies --stream" + "browser-destinations:build": "NODE_ENV=production yarn nx build-web @segment/browser-destinations" }, "devDependencies": { "@wdio/cli": "^7.26.0", diff --git a/packages/browser-destinations/README.md b/packages/browser-destinations/README.md index 3c863e17f2..4a3ef03f32 100644 --- a/packages/browser-destinations/README.md +++ b/packages/browser-destinations/README.md @@ -56,7 +56,7 @@ Coming Soon MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/browser-destinations/__tests__/window.test.ts b/packages/browser-destinations/__tests__/window.test.ts new file mode 100644 index 0000000000..b0fdea83f8 --- /dev/null +++ b/packages/browser-destinations/__tests__/window.test.ts @@ -0,0 +1,28 @@ +import { Analytics, Context } from '@segment/analytics-next' +import segmentUtilitiesDestination from '../destinations/segment-utilities-web/src/index' + +it('window object shouldnt be changed by actions core', async () => { + const windowBefore = Object.keys(window) + + // load a plugin that doesn't alter window object + const [plugin] = await segmentUtilitiesDestination({ + throttleWindow: 3000, + passThroughCount: 1, + subscriptions: [ + { + partnerAction: 'throttle', + name: 'Throttle', + enabled: true, + subscribe: 'type = "track"', + mapping: {} + } + ] + }) + + await plugin.load(Context.system(), {} as Analytics) + + const windowAfter = Object.keys(window) + + // window object shouldn't change as long as actions-core isn't changing it + expect(windowBefore.sort()).toEqual(windowAfter.sort()) +}) diff --git a/packages/browser-destinations/destinations/1flow/README.md b/packages/browser-destinations/destinations/1flow/README.md new file mode 100644 index 0000000000..d1f3ce5580 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-actions-1flow + +The 1Flow browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2024 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/1flow/package.json b/packages/browser-destinations/destinations/1flow/package.json new file mode 100644 index 0000000000..eac6a2e696 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/package.json @@ -0,0 +1,23 @@ +{ + "name": "@segment/analytics-browser-actions-1flow", + "version": "1.16.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/1flow/src/1flow.ts b/packages/browser-destinations/destinations/1flow/src/1flow.ts new file mode 100644 index 0000000000..a7e07e105c --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/1flow.ts @@ -0,0 +1,22 @@ +/* eslint-disable */ +// @ts-nocheck + +export function initScript({ projectApiKey }) { + //Set your APP_ID + const apiKey = projectApiKey + + const autoURLTracking = false + ;(function (w, o, s, t, k, a, r) { + ;(w._1flow = function (e, d, v) { + s(function () { + w._1flow(e, d, !v ? {} : v) + }, 5) + }), + (a = o.getElementsByTagName('head')[0]) + r = o.createElement('script') + r.async = 1 + r.setAttribute('data-api-key', k) + r.src = t + a.appendChild(r) + })(window, document, setTimeout, 'https://1flow.app/js/1flow.js', apiKey) +} diff --git a/packages/browser-destinations/destinations/1flow/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/1flow/src/__tests__/index.test.ts new file mode 100644 index 0000000000..cd839e0ed5 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/__tests__/index.test.ts @@ -0,0 +1,14 @@ +import _1FlowDestination from '../index' +import { _1Flow } from '../api' + +describe('_1Flow', () => { + beforeAll(() => { + jest.mock('@segment/browser-destination-runtime/load-script', () => ({ + loadScript: (_src: any, _attributes: any) => {} + })) + jest.mock('@segment/browser-destination-runtime/resolve-when', () => ({ + resolveWhen: (_fn: any, _timeout: any) => {} + })) + }) + test('it maps event parameters correctly to identify function ', async () => {}) +}) diff --git a/packages/browser-destinations/destinations/1flow/src/api.ts b/packages/browser-destinations/destinations/1flow/src/api.ts new file mode 100644 index 0000000000..b8279d0f4e --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/api.ts @@ -0,0 +1,11 @@ +type method = 'track' | 'identify' + +type _1FlowApi = { + richLinkProperties: string[] | undefined + activator: string | undefined + projectApiKey: string +} + +type _1FlowFunction = (method: method, ...args: unknown[]) => void + +export type _1Flow = _1FlowFunction & _1FlowApi diff --git a/packages/browser-destinations/destinations/1flow/src/generated-types.ts b/packages/browser-destinations/destinations/1flow/src/generated-types.ts new file mode 100644 index 0000000000..ad85489880 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * This is the unique app_id for your 1Flow application, serving as the identifier for data storage and retrieval. This field is mandatory. + */ + projectApiKey: string +} diff --git a/packages/browser-destinations/destinations/1flow/src/identifyUser/__tests__/index.test.ts b/packages/browser-destinations/destinations/1flow/src/identifyUser/__tests__/index.test.ts new file mode 100644 index 0000000000..f080cf0579 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/identifyUser/__tests__/index.test.ts @@ -0,0 +1,14 @@ +import _1FlowDestination from '../../index' + +describe('identify', () => { + beforeAll(() => { + jest.mock('@segment/browser-destination-runtime/load-script', () => ({ + loadScript: (_src: any, _attributes: any) => {} + })) + jest.mock('@segment/browser-destination-runtime/resolve-when', () => ({ + resolveWhen: (_fn: any, _timeout: any) => {} + })) + }) + + test('it maps event parameters correctly to identify function ', async () => {}) +}) diff --git a/packages/browser-destinations/destinations/1flow/src/identifyUser/generated-types.ts b/packages/browser-destinations/destinations/1flow/src/identifyUser/generated-types.ts new file mode 100644 index 0000000000..f5b1336f24 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/identifyUser/generated-types.ts @@ -0,0 +1,34 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * A unique identifier for the user. + */ + userId?: string + /** + * An anonymous identifier for the user. + */ + anonymousId?: string + /** + * The user's custom attributes. + */ + traits?: { + [k: string]: unknown + } + /** + * The user's first name. + */ + first_name?: string + /** + * The user's last name. + */ + last_name?: string + /** + * The user's phone number. + */ + phone?: string + /** + * The user's email address. + */ + email?: string +} diff --git a/packages/browser-destinations/destinations/1flow/src/identifyUser/index.ts b/packages/browser-destinations/destinations/1flow/src/identifyUser/index.ts new file mode 100644 index 0000000000..80401e35a7 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/identifyUser/index.ts @@ -0,0 +1,90 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import { _1Flow } from '../api' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: BrowserActionDefinition = { + title: 'Identify User', + description: 'Create or update a user in 1Flow.', + defaultSubscription: 'type = "identify"', + platform: 'web', + fields: { + userId: { + description: 'A unique identifier for the user.', + label: 'User ID', + type: 'string', + required: false, + default: { + '@path': '$.userId' + } + }, + anonymousId: { + description: 'An anonymous identifier for the user.', + label: 'Anonymous ID', + type: 'string', + required: false, + default: { + '@path': '$.anonymousId' + } + }, + traits: { + description: "The user's custom attributes.", + label: 'Custom Attributes', + type: 'object', + required: false, + defaultObjectUI: 'keyvalue', + default: { + '@path': '$.traits' + } + }, + first_name: { + description: "The user's first name.", + label: 'First Name', + type: 'string', + required: false, + default: { + '@path': '$.traits.first_name' + } + }, + last_name: { + description: "The user's last name.", + label: 'First Name', + type: 'string', + required: false, + default: { + '@path': '$.traits.last_name' + } + }, + phone: { + description: "The user's phone number.", + label: 'Phone Number', + type: 'string', + required: false, + default: { + '@path': '$.traits.phone' + } + }, + + email: { + description: "The user's email address.", + label: 'Email Address', + type: 'string', + required: false, + default: { + '@path': '$.traits.email' + } + } + }, + perform: (_1Flow, event) => { + const { userId, anonymousId, traits, first_name, last_name, phone, email } = event.payload + _1Flow('identify', userId, anonymousId, { + ...traits, + first_name: first_name, + last_name: last_name, + phone: phone, + email: email + }) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/1flow/src/index.ts b/packages/browser-destinations/destinations/1flow/src/index.ts new file mode 100644 index 0000000000..59edc58318 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/index.ts @@ -0,0 +1,58 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import trackEvent from './trackEvent' +import { initScript } from './1flow' +import { _1Flow } from './api' +import identifyUser from './identifyUser' +import { defaultValues } from '@segment/actions-core' +declare global { + interface Window { + _1Flow: _1Flow + } +} + +export const destination: BrowserDestinationDefinition = { + name: '1Flow Web (Actions)', + slug: 'actions-1flow', + mode: 'device', + description: 'Send analytics from Segment to 1Flow', + settings: { + projectApiKey: { + description: + 'This is the unique app_id for your 1Flow application, serving as the identifier for data storage and retrieval. This field is mandatory.', + label: 'Project API Key', + type: 'string', + required: true + } + }, + presets: [ + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'trackEvent', + mapping: defaultValues(trackEvent.fields), + type: 'automatic' + }, + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identifyUser', + mapping: defaultValues(identifyUser.fields), + type: 'automatic' + } + ], + + initialize: async ({ settings }, deps) => { + const projectApiKey = settings.projectApiKey + initScript({ projectApiKey }) + await deps.resolveWhen(() => Object.prototype.hasOwnProperty.call(window, '_1Flow'), 100) + return window._1Flow + }, + actions: { + trackEvent, + identifyUser + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/1flow/src/trackEvent/__tests__/index.test.ts b/packages/browser-destinations/destinations/1flow/src/trackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..8564c31fea --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/trackEvent/__tests__/index.test.ts @@ -0,0 +1,14 @@ +import _1flowDestination from '../../index' + +describe('track', () => { + beforeAll(() => { + jest.mock('@segment/browser-destination-runtime/load-script', () => ({ + loadScript: (_src: any, _attributes: any) => {} + })) + jest.mock('@segment/browser-destination-runtime/resolve-when', () => ({ + resolveWhen: (_fn: any, _timeout: any) => {} + })) + }) + + test('it maps event parameters correctly to track function', async () => {}) +}) diff --git a/packages/browser-destinations/destinations/1flow/src/trackEvent/generated-types.ts b/packages/browser-destinations/destinations/1flow/src/trackEvent/generated-types.ts new file mode 100644 index 0000000000..5c7fd1c746 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/trackEvent/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the event. + */ + event_name: string + /** + * A unique identifier for the user. + */ + userId?: string + /** + * An anonymous identifier for the user. + */ + anonymousId?: string + /** + * Information associated with the event + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/1flow/src/trackEvent/index.ts b/packages/browser-destinations/destinations/1flow/src/trackEvent/index.ts new file mode 100644 index 0000000000..0fec9ca043 --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/src/trackEvent/index.ts @@ -0,0 +1,59 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import { _1Flow } from '../api' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: BrowserActionDefinition = { + title: 'Track Event', + description: 'Submit an event to 1Flow.', + defaultSubscription: 'type = "track"', + platform: 'web', + fields: { + event_name: { + description: 'The name of the event.', + label: 'Event Name', + type: 'string', + required: true, + default: { + '@path': '$.event' + } + }, + userId: { + description: 'A unique identifier for the user.', + label: 'User ID', + type: 'string', + required: false, + default: { + '@path': '$.userId' + } + }, + anonymousId: { + description: 'An anonymous identifier for the user.', + label: 'Anonymous ID', + type: 'string', + required: false, + default: { + '@path': '$.anonymousId' + } + }, + properties: { + description: 'Information associated with the event', + label: 'Event Properties', + type: 'object', + required: false, + default: { + '@path': '$.properties' + } + } + }, + perform: (_1Flow, event) => { + const { event_name, userId, anonymousId, properties } = event.payload + _1Flow('track', event_name, { + userId: userId, + anonymousId: anonymousId, + properties: properties + }) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/1flow/tsconfig.json b/packages/browser-destinations/destinations/1flow/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/1flow/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/adobe-target/package.json b/packages/browser-destinations/destinations/adobe-target/package.json index 78b0f2144f..90d0c7942a 100644 --- a/packages/browser-destinations/destinations/adobe-target/package.json +++ b/packages/browser-destinations/destinations/adobe-target/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-adobe-target", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -16,7 +16,7 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/algolia-plugins/README.md b/packages/browser-destinations/destinations/algolia-plugins/README.md new file mode 100644 index 0000000000..a6d1391fd6 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-actions-algolia-plugins + +The Algolia Plugins browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2023 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/algolia-plugins/package.json b/packages/browser-destinations/destinations/algolia-plugins/package.json new file mode 100644 index 0000000000..11371cb1d5 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/package.json @@ -0,0 +1,23 @@ +{ + "name": "@segment/analytics-browser-actions-algolia-plugins", + "version": "1.11.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/algolia-plugins/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/algolia-plugins/src/__tests__/index.test.ts new file mode 100644 index 0000000000..0ce3cffc05 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/src/__tests__/index.test.ts @@ -0,0 +1,52 @@ +import { Analytics, Context, Plugin } from '@segment/analytics-next' +import { Subscription } from '@segment/browser-destination-runtime/types' +import browserPluginsDestination from '../' +import { queryIdIntegrationFieldName } from '../utils' + +const example: Subscription[] = [ + { + partnerAction: 'algoliaPlugin', + name: 'Algolia Plugin', + enabled: true, + subscribe: 'type = "track"', + mapping: {} + } +] + +let browserActions: Plugin[] +let algoliaPlugin: Plugin +let ajs: Analytics + +beforeEach(async () => { + browserActions = await browserPluginsDestination({ subscriptions: example }) + algoliaPlugin = browserActions[0] + + ajs = new Analytics({ + writeKey: 'w_123' + }) + Object.defineProperty(window, 'location', { + value: { + search: 'queryID=1234567' + }, + writable: true + }) +}) + +describe('ajs-integration', () => { + test('updates the original event with an Algolia query ID', async () => { + await algoliaPlugin.load(Context.system(), ajs) + + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + + const updatedCtx = await algoliaPlugin.track?.(ctx) + + const algoliaIntegrationsObj = updatedCtx?.event?.integrations['Algolia Insights (Actions)'] + expect(algoliaIntegrationsObj[queryIdIntegrationFieldName]).toEqual('1234567') + }) +}) diff --git a/packages/browser-destinations/destinations/algolia-plugins/src/algoliaPlugin/generated-types.ts b/packages/browser-destinations/destinations/algolia-plugins/src/algoliaPlugin/generated-types.ts new file mode 100644 index 0000000000..944d22b085 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/src/algoliaPlugin/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload {} diff --git a/packages/browser-destinations/destinations/algolia-plugins/src/algoliaPlugin/index.ts b/packages/browser-destinations/destinations/algolia-plugins/src/algoliaPlugin/index.ts new file mode 100644 index 0000000000..86e8accaa8 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/src/algoliaPlugin/index.ts @@ -0,0 +1,30 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { UniversalStorage } from '@segment/analytics-next' +import { storageFallback, storageQueryIdKey, queryIdIntegrationFieldName } from '../utils' + +const action: BrowserActionDefinition = { + title: 'Algolia Browser Plugin', + description: 'Enriches all Segment payloads with the Algolia query_id value', + platform: 'web', + hidden: false, + defaultSubscription: 'type = "track" or type = "identify" or type = "page" or type = "group" or type = "alias"', + fields: {}, + lifecycleHook: 'enrichment', + perform: (_, { context, analytics }) => { + const storage = (analytics.storage as UniversalStorage>) ?? storageFallback + + const query_id: string | null = storage.get(storageQueryIdKey) + + if (query_id && (context.event.integrations?.All !== false || context.event.integrations['Algolia Insights (Actions)'])) { + const integrationsData: Record = {} + integrationsData[queryIdIntegrationFieldName] = query_id + context.updateEvent(`integrations.Algolia Insights (Actions)`, integrationsData) + } + + return + } +} + +export default action diff --git a/packages/browser-destinations/destinations/algolia-plugins/src/generated-types.ts b/packages/browser-destinations/destinations/algolia-plugins/src/generated-types.ts new file mode 100644 index 0000000000..1ceb7fa3b9 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/src/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * QueryString name you use for when storing the Algolia QueryID in a page URL. + */ + queryIdQueryStringName?: string +} diff --git a/packages/browser-destinations/destinations/algolia-plugins/src/index.ts b/packages/browser-destinations/destinations/algolia-plugins/src/index.ts new file mode 100644 index 0000000000..12ac5288f6 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/src/index.ts @@ -0,0 +1,42 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import { UniversalStorage } from '@segment/analytics-next' +import { storageFallback, storageQueryIdKey, queryIdQueryStringNameDefault } from './utils' + +import algoliaPlugin from './algoliaPlugin' + +// Switch from unknown to the partner SDK client types +export const destination: BrowserDestinationDefinition = { + name: 'Algolia Plugins', + slug: 'actions-algolia-plugins', + mode: 'device', + settings: { + queryIdQueryStringName: { + label: 'QueryID QueryString Name', + description: 'QueryString name you use for when storing the Algolia QueryID in a page URL.', + type: 'string', + default: queryIdQueryStringNameDefault, + required: false + } + }, + initialize: async ({ analytics, settings }) => { + const storage = (analytics.storage as UniversalStorage>) ?? storageFallback + + const urlParams = new URLSearchParams(window.location.search) + + const queryId: string | null = + urlParams.get(settings.queryIdQueryStringName ?? queryIdQueryStringNameDefault) || null + + if (queryId) { + storage.set(storageQueryIdKey, queryId) + } + + return {} + }, + actions: { + algoliaPlugin + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/algolia-plugins/src/utils.ts b/packages/browser-destinations/destinations/algolia-plugins/src/utils.ts new file mode 100644 index 0000000000..bb9ddb4b62 --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/src/utils.ts @@ -0,0 +1,17 @@ +// The name of the storage location where we'll cache the Query ID value +export const storageQueryIdKey = 'analytics_algolia_query_id' + +export const queryIdQueryStringNameDefault = 'queryID' + +// The field name to include for the Algolia query_id in 'context.integrations.Algolia Insights (Actions)' +export const queryIdIntegrationFieldName = 'query_id' + +export const storageFallback = { + get: (key: string) => { + const data = window.localStorage.getItem(key) + return data + }, + set: (key: string, value: string) => { + return window.localStorage.setItem(key, value) + } +} diff --git a/packages/browser-destinations/destinations/algolia-plugins/tsconfig.json b/packages/browser-destinations/destinations/algolia-plugins/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/algolia-plugins/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/amplitude-plugins/package.json b/packages/browser-destinations/destinations/amplitude-plugins/package.json index 5090f61975..9822177965 100644 --- a/packages/browser-destinations/destinations/amplitude-plugins/package.json +++ b/packages/browser-destinations/destinations/amplitude-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-amplitude-plugins", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,7 +15,7 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/braze-cloud-plugins/package.json b/packages/browser-destinations/destinations/braze-cloud-plugins/package.json index 84fd76e262..50c54fbb7c 100644 --- a/packages/browser-destinations/destinations/braze-cloud-plugins/package.json +++ b/packages/browser-destinations/destinations/braze-cloud-plugins/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-braze-cloud-plugins", - "version": "1.16.0", + "version": "1.37.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/analytics-browser-actions-braze": "^1.16.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/analytics-browser-actions-braze": "^1.37.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/braze/package.json b/packages/browser-destinations/destinations/braze/package.json index 57584cc39a..dbe08afedb 100644 --- a/packages/browser-destinations/destinations/braze/package.json +++ b/packages/browser-destinations/destinations/braze/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-braze", - "version": "1.16.0", + "version": "1.37.0", "license": "MIT", "publishConfig": { "access": "public", @@ -35,8 +35,8 @@ "dependencies": { "@braze/web-sdk": "npm:@braze/web-sdk@^4.1.0", "@braze/web-sdk-v3": "npm:@braze/web-sdk@^3.5.1", - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/braze/src/updateUserProfile/index.ts b/packages/browser-destinations/destinations/braze/src/updateUserProfile/index.ts index 7edf1f64c7..a45ccc5414 100644 --- a/packages/browser-destinations/destinations/braze/src/updateUserProfile/index.ts +++ b/packages/browser-destinations/destinations/braze/src/updateUserProfile/index.ts @@ -79,12 +79,7 @@ const action: BrowserActionDefinition email_subscribe: { label: 'Email Subscribe', description: `The user's email subscription preference: “opted_in” (explicitly registered to receive email messages), “unsubscribed” (explicitly opted out of email messages), and “subscribed” (neither opted in nor out).`, - type: 'string', - choices: [ - { label: 'OTPED_IN', value: 'opted_in' }, - { label: 'SUBSCRIBED', value: 'subscribed' }, - { label: 'UNSUBSCRIBED', value: 'unsubscribed' } - ] + type: 'string' }, first_name: { label: 'First Name', diff --git a/packages/browser-destinations/destinations/bucket/package.json b/packages/browser-destinations/destinations/bucket/package.json new file mode 100644 index 0000000000..171c32a74a --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/package.json @@ -0,0 +1,25 @@ +{ + "name": "@segment/analytics-browser-actions-bucket", + "version": "1.14.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@bucketco/tracking-sdk": "^2.0.0", + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/bucket/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/bucket/src/__tests__/index.test.ts new file mode 100644 index 0000000000..b47472f8a4 --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/__tests__/index.test.ts @@ -0,0 +1,160 @@ +import { Analytics, Context, User } from '@segment/analytics-next' +import bucketWebDestination, { destination } from '../index' +import { Subscription } from '@segment/browser-destination-runtime/types' +import { JSONArray } from '@segment/actions-core/*' +import { bucketTestHooks, getBucketCallLog } from '../test-utils' + +const subscriptions: Subscription[] = [ + { + partnerAction: 'trackEvent', + name: 'Track Event', + enabled: true, + subscribe: 'type = "track"', + mapping: { + name: { + '@path': '$.name' + } + } + } +] + +describe('Bucket', () => { + bucketTestHooks() + + it('loads the Bucket SDK', async () => { + const [instance] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + jest.spyOn(destination, 'initialize') + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + + await instance.load(Context.system(), analyticsInstance) + expect(destination.initialize).toHaveBeenCalled() + + const scripts = Array.from(window.document.querySelectorAll('script')) + expect(scripts).toMatchInlineSnapshot(` + Array [ + , + ] + `) + + expect(window.bucket).toMatchObject({ + init: expect.any(Function), + user: expect.any(Function), + company: expect.any(Function), + track: expect.any(Function), + reset: expect.any(Function) + }) + }) + + it('resets the Bucket SDK', async () => { + const [instance] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + + await instance.load(Context.system(), analyticsInstance) + + analyticsInstance.reset() + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', {}] }, + { method: 'reset', args: [] } + ]) + }) + + it('passes options to bucket.init()', async () => { + const [instance] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + host: 'http://localhost:3200', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + + await instance.load(Context.system(), analyticsInstance) + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', { host: 'http://localhost:3200' }] } + ]) + }) + + it('allows sdkVersion override', async () => { + const [instance] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + sdkVersion: 'latest', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + + await instance.load(Context.system(), analyticsInstance) + + const scripts = Array.from(window.document.querySelectorAll('script')) + expect(scripts).toMatchInlineSnapshot(` + Array [ + , + ] + `) + + expect(getBucketCallLog()).toStrictEqual([{ method: 'init', args: ['testTrackingKey', {}] }]) + }) + + describe('when not logged in', () => { + it('initializes Bucket SDK', async () => { + const [instance] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + + await instance.load(Context.system(), analyticsInstance) + + expect(getBucketCallLog()).toStrictEqual([{ method: 'init', args: ['testTrackingKey', {}] }]) + }) + }) + + describe('when logged in', () => { + it('initializes Bucket SDK and registers user', async () => { + const [instance] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + jest.spyOn(analyticsInstance, 'user').mockImplementation( + () => + ({ + id: () => 'test-user-id-1' + } as User) + ) + + await instance.load(Context.system(), analyticsInstance) + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', {}] }, + { method: 'user', args: ['test-user-id-1', {}, { active: false }] } + ]) + }) + }) +}) diff --git a/packages/browser-destinations/destinations/bucket/src/generated-types.ts b/packages/browser-destinations/destinations/bucket/src/generated-types.ts new file mode 100644 index 0000000000..ed3d76cd3b --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Bucket App tracking key, found on the tracking page. + */ + trackingKey: string +} diff --git a/packages/browser-destinations/destinations/bucket/src/group/__tests__/index.test.ts b/packages/browser-destinations/destinations/bucket/src/group/__tests__/index.test.ts new file mode 100644 index 0000000000..fbba8d8467 --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/group/__tests__/index.test.ts @@ -0,0 +1,186 @@ +import { Analytics, Context, User } from '@segment/analytics-next' +import bucketWebDestination, { destination } from '../../index' +import { Subscription } from '@segment/browser-destination-runtime/types' +import { JSONArray } from '@segment/actions-core/*' +import { bucketTestHooks, getBucketCallLog } from '../../test-utils' + +const subscriptions: Subscription[] = [ + { + partnerAction: 'group', + name: 'Identify Company', + enabled: true, + subscribe: 'type = "group"', + mapping: { + groupId: { + '@path': '$.groupId' + }, + userId: { + '@path': '$.userId' + }, + traits: { + '@path': '$.traits' + } + } + } +] + +describe('Bucket.company', () => { + bucketTestHooks() + + describe('when logged in', () => { + describe('from analytics.js previous session', () => { + it('maps parameters correctly to Bucket', async () => { + const [bucketPlugin] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + jest.spyOn(analyticsInstance, 'user').mockImplementation( + () => + ({ + id: () => 'user-id-1' + } as User) + ) + await bucketPlugin.load(Context.system(), analyticsInstance) + + jest.spyOn(destination.actions.group, 'perform') + + await bucketPlugin.group?.( + new Context({ + type: 'group', + userId: 'user-id-1', + groupId: 'group-id-1', + traits: { + name: 'ACME INC' + } + }) + ) + + expect(destination.actions.group.perform).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: { + userId: 'user-id-1', + groupId: 'group-id-1', + traits: { + name: 'ACME INC' + } + } + }) + ) + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', {}] }, + { + method: 'user', + args: ['user-id-1', {}, { active: false }] + }, + { + method: 'company', + args: [ + 'group-id-1', + { + name: 'ACME INC' + }, + 'user-id-1' + ] + } + ]) + }) + }) + + describe('from am identify call', () => { + it('maps parameters correctly to Bucket', async () => { + const [bucketPlugin] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + await bucketPlugin.load(Context.system(), new Analytics({ writeKey: 'test-writekey' })) + + jest.spyOn(destination.actions.group, 'perform') + + // Bucket rejects group calls without previous identify calls + await window.bucket.user('user-id-1') + + await bucketPlugin.group?.( + new Context({ + type: 'group', + userId: 'user-id-1', + groupId: 'group-id-1', + traits: { + name: 'ACME INC' + } + }) + ) + + expect(destination.actions.group.perform).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: { + userId: 'user-id-1', + groupId: 'group-id-1', + traits: { + name: 'ACME INC' + } + } + }) + ) + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', {}] }, + { + method: 'user', + args: ['user-id-1'] + }, + { + method: 'company', + args: [ + 'group-id-1', + { + name: 'ACME INC' + }, + 'user-id-1' + ] + } + ]) + }) + }) + }) + + describe('when not logged in', () => { + it('should not call Bucket.group', async () => { + const [bucketPlugin] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + await bucketPlugin.load(Context.system(), analyticsInstance) + + jest.spyOn(destination.actions.group, 'perform') + + // Manually mimicking a group call without a userId. + // The analytics client will probably never do this if + // userId doesn't exist, since the subscription marks it as required + await bucketPlugin.group?.( + new Context({ + type: 'group', + anonymousId: 'anonymous-id-1', + groupId: 'group-id-1', + traits: { + name: 'ACME INC' + } + }) + ) + + // TODO: Ideally we should be able to assert that the destination action was never + // called, but couldn't figure out how to create an anlytics instance with the plugin + // and then trigger the full flow trhough analytics.group() with only an anonymous ID + // expect(destination.actions.group.perform).not.toHaveBeenCalled() + + expect(getBucketCallLog()).toStrictEqual([{ method: 'init', args: ['testTrackingKey', {}] }]) + }) + }) +}) diff --git a/packages/browser-destinations/destinations/bucket/src/group/generated-types.ts b/packages/browser-destinations/destinations/bucket/src/group/generated-types.ts new file mode 100644 index 0000000000..ff9f16892c --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/group/generated-types.ts @@ -0,0 +1,18 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique identifier for the company + */ + groupId: string + /** + * Unique identifier for the user + */ + userId: string + /** + * Additional information to associate with the Company in Bucket + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/bucket/src/group/index.ts b/packages/browser-destinations/destinations/bucket/src/group/index.ts new file mode 100644 index 0000000000..a4162da1e1 --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/group/index.ts @@ -0,0 +1,49 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import type { Bucket } from '../types' + +const action: BrowserActionDefinition = { + title: 'Identify Company', + description: 'Creates or updates a Company in Bucket and associates the user with it', + platform: 'web', + defaultSubscription: 'type = "group"', + fields: { + groupId: { + type: 'string', + required: true, + description: 'Unique identifier for the company', + label: 'Company ID', + default: { + '@path': '$.groupId' + } + }, + userId: { + type: 'string', + required: true, + allowNull: false, + description: 'Unique identifier for the user', + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + traits: { + type: 'object', + required: false, + description: 'Additional information to associate with the Company in Bucket', + label: 'Company Attributes', + default: { + '@path': '$.traits' + } + } + }, + perform: (bucket, { payload }) => { + // Ensure we never call Bucket.company() without a user ID + if (payload.userId) { + void bucket.company(payload.groupId, payload.traits, payload.userId) + } + } +} + +export default action diff --git a/packages/browser-destinations/destinations/bucket/src/identifyUser/__tests__/index.test.ts b/packages/browser-destinations/destinations/bucket/src/identifyUser/__tests__/index.test.ts new file mode 100644 index 0000000000..51a7e58239 --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/identifyUser/__tests__/index.test.ts @@ -0,0 +1,78 @@ +import { Analytics, Context } from '@segment/analytics-next' +import bucketWebDestination, { destination } from '../../index' +import { Subscription } from '@segment/browser-destination-runtime/types' +import { JSONArray } from '@segment/actions-core/*' +import { bucketTestHooks, getBucketCallLog } from '../../test-utils' + +const subscriptions: Subscription[] = [ + { + partnerAction: 'identifyUser', + name: 'Identify User', + enabled: true, + subscribe: 'type = "identify"', + mapping: { + userId: { + '@path': '$.userId' + }, + traits: { + '@path': '$.traits' + } + } + } +] + +describe('Bucket.user', () => { + bucketTestHooks() + + test('it maps event parameters correctly to bucket.user', async () => { + const [identifyEvent] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + await identifyEvent.load(Context.system(), new Analytics({ writeKey: 'test-writekey' })) + + jest.spyOn(destination.actions.identifyUser, 'perform') + + await identifyEvent.identify?.( + new Context({ + type: 'identify', + userId: 'user-id-1', + traits: { + name: 'John Doe', + email: 'test-email-2@gmail.com', + age: 42 + } + }) + ) + + expect(destination.actions.identifyUser.perform).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: { + userId: 'user-id-1', + traits: { + name: 'John Doe', + email: 'test-email-2@gmail.com', + age: 42 + } + } + }) + ) + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', {}] }, + { + method: 'user', + args: [ + 'user-id-1', + { + name: 'John Doe', + email: 'test-email-2@gmail.com', + age: 42 + } + ] + } + ]) + }) +}) diff --git a/packages/browser-destinations/destinations/bucket/src/identifyUser/generated-types.ts b/packages/browser-destinations/destinations/bucket/src/identifyUser/generated-types.ts new file mode 100644 index 0000000000..2703e2cb3c --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/identifyUser/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique identifier for the User + */ + userId: string + /** + * Additional information to associate with the User in Bucket + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/bucket/src/identifyUser/index.ts b/packages/browser-destinations/destinations/bucket/src/identifyUser/index.ts new file mode 100644 index 0000000000..4472b52cc9 --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/identifyUser/index.ts @@ -0,0 +1,36 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import type { Bucket } from '../types' + +const action: BrowserActionDefinition = { + title: 'Identify User', + description: 'Creates or updates a user profile in Bucket. Also initializes Live Satisfaction', + platform: 'web', + defaultSubscription: 'type = "identify"', + fields: { + userId: { + type: 'string', + required: true, + description: 'Unique identifier for the User', + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + traits: { + type: 'object', + required: false, + description: 'Additional information to associate with the User in Bucket', + label: 'User Attributes', + default: { + '@path': '$.traits' + } + } + }, + perform: (bucket, { payload }) => { + void bucket.user(payload.userId, payload.traits) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/bucket/src/index.ts b/packages/browser-destinations/destinations/bucket/src/index.ts new file mode 100644 index 0000000000..067679a67c --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/index.ts @@ -0,0 +1,96 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import { Bucket } from './types' +import identifyUser from './identifyUser' +import trackEvent from './trackEvent' +import { defaultValues } from '@segment/actions-core' +import group from './group' + +declare global { + interface Window { + bucket: Bucket + } +} + +export const destination: BrowserDestinationDefinition = { + name: 'Bucket Web (Actions)', + description: + 'Loads the Bucket browser SDK, maps identify(), group() and track() events and enables LiveSatisfaction connections', + slug: 'bucket-web', + mode: 'device', + + presets: [ + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identifyUser', + mapping: defaultValues(identifyUser.fields), + type: 'automatic' + }, + { + name: 'Group', + subscribe: 'type = "group"', + partnerAction: 'group', + mapping: defaultValues(group.fields), + type: 'automatic' + }, + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'trackEvent', + mapping: defaultValues(trackEvent.fields), + type: 'automatic' + } + ], + + settings: { + trackingKey: { + description: 'Your Bucket App tracking key, found on the tracking page.', + label: 'Tracking Key', + type: 'string', + required: true + } + }, + + actions: { + identifyUser, + group, + trackEvent + }, + + initialize: async ({ settings, analytics }, deps) => { + const { + // @ts-expect-error versionSettings is not part of the settings object but they are injected by Analytics 2.0, making Braze SDK raise a warning when we initialize it. + versionSettings, + // @ts-expect-error same as above. + subscriptions, + + trackingKey, + // @ts-expect-error Code-only SDK version override. Can be set via analytics.load() integrations overrides + sdkVersion = '2', + ...options + } = settings + await deps.loadScript(`https://cdn.jsdelivr.net/npm/@bucketco/tracking-sdk@${sdkVersion}`) + await deps.resolveWhen(() => window.bucket != undefined, 100) + + window.bucket.init(settings.trackingKey, options) + + // If the analytics client already has a logged in user from a + // previous session or page, consider the user logged in. + // In this case we need to call `bucket.user()` to set the persisted + // user id in bucket and initialize Live Satisfaction + const segmentPersistedUserId = analytics.user().id() + if (segmentPersistedUserId) { + void window.bucket.user(segmentPersistedUserId, {}, { active: false }) + } + + analytics.on('reset', () => { + window.bucket.reset() + }) + + return window.bucket + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/bucket/src/test-utils.ts b/packages/browser-destinations/destinations/bucket/src/test-utils.ts new file mode 100644 index 0000000000..baabbb6e6d --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/test-utils.ts @@ -0,0 +1,63 @@ +import nock from 'nock' +import { Bucket } from 'src/types' + +const bucketTestMock = ` +(() => { + const noop = () => {}; + + const bucketTestInterface = { + init: noop, + user: noop, + company: noop, + track: noop, + reset: noop + }; + + const callLog = []; + + window.bucket = new Proxy(bucketTestInterface, { + get(bucket, property) { + if (typeof bucket[property] === 'function') { + return (...args) => { + callLog.push({ method: property, args }) + return bucket[property](...args) + } + } + + if (property === 'callLog') { + return callLog; + } + } + }); +})(); +` + +export function bucketTestHooks() { + beforeAll(() => { + nock.disableNetConnect() + }) + + beforeEach(() => { + nock('https://cdn.jsdelivr.net') + .get((uri) => uri.startsWith('/npm/@bucketco/tracking-sdk@')) + .reply(200, bucketTestMock) + }) + + afterEach(function () { + if (!nock.isDone()) { + // @ts-expect-error no-unsafe-call + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + this.test.error(new Error('Not all nock interceptors were used!')) + } + + nock.cleanAll() + }) + + afterAll(() => { + nock.enableNetConnect() + }) +} + +export function getBucketCallLog() { + return (window.bucket as unknown as { callLog: Array<{ method: keyof Bucket; args: Array }> }).callLog +} diff --git a/packages/browser-destinations/destinations/bucket/src/trackEvent/__tests__/index.test.ts b/packages/browser-destinations/destinations/bucket/src/trackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..5c94ef5aab --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/trackEvent/__tests__/index.test.ts @@ -0,0 +1,159 @@ +import { Analytics, Context, User } from '@segment/analytics-next' +import bucketWebDestination, { destination } from '../../index' +import { Subscription } from '@segment/browser-destination-runtime/types' +import { JSONArray } from '@segment/actions-core/*' +import { bucketTestHooks, getBucketCallLog } from '../../test-utils' + +const subscriptions: Subscription[] = [ + { + partnerAction: 'trackEvent', + name: 'Track Event', + enabled: true, + subscribe: 'type = "track"', + mapping: { + name: { + '@path': '$.name' + }, + userId: { + '@path': '$.userId' + }, + properties: { + '@path': '$.properties' + } + } + } +] + +describe('trackEvent', () => { + bucketTestHooks() + + describe('when logged in', () => { + describe('from analytics.js previous session', () => { + it('maps parameters correctly to Bucket', async () => { + const [bucketPlugin] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + jest.spyOn(analyticsInstance, 'user').mockImplementation( + () => + ({ + id: () => 'user-id-1' + } as User) + ) + await bucketPlugin.load(Context.system(), analyticsInstance) + + jest.spyOn(destination.actions.trackEvent, 'perform') + + const properties = { property1: 'value1', property2: false } + await bucketPlugin.track?.( + new Context({ + type: 'track', + name: 'Button Clicked', + userId: 'user-id-1', + properties + }) + ) + + expect(destination.actions.trackEvent.perform).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: { name: 'Button Clicked', userId: 'user-id-1', properties } + }) + ) + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', {}] }, + { + method: 'user', + args: ['user-id-1', {}, { active: false }] + }, + { + method: 'track', + args: ['Button Clicked', properties, 'user-id-1'] + } + ]) + }) + }) + + describe('from am identify call', () => { + it('maps parameters correctly to Bucket', async () => { + const [bucketPlugin] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + await bucketPlugin.load(Context.system(), new Analytics({ writeKey: 'test-writekey' })) + + jest.spyOn(destination.actions.trackEvent, 'perform') + + // Bucket rejects group calls without previous identify calls + await window.bucket.user('user-id-1') + + const properties = { property1: 'value1', property2: false } + await bucketPlugin.track?.( + new Context({ + type: 'track', + name: 'Button Clicked', + userId: 'user-id-1', + properties + }) + ) + + expect(destination.actions.trackEvent.perform).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: { name: 'Button Clicked', userId: 'user-id-1', properties } + }) + ) + + expect(getBucketCallLog()).toStrictEqual([ + { method: 'init', args: ['testTrackingKey', {}] }, + { + method: 'user', + args: ['user-id-1'] + }, + { + method: 'track', + args: ['Button Clicked', properties, 'user-id-1'] + } + ]) + }) + }) + }) + + describe('when not logged in', () => { + it('should not call Bucket.group', async () => { + const [bucketPlugin] = await bucketWebDestination({ + trackingKey: 'testTrackingKey', + subscriptions: subscriptions as unknown as JSONArray + }) + + const analyticsInstance = new Analytics({ writeKey: 'test-writekey' }) + await bucketPlugin.load(Context.system(), analyticsInstance) + + jest.spyOn(destination.actions.trackEvent, 'perform') + + // Manually mimicking a track call without a userId. + // The analytics client will probably never do this if + // userId doesn't exist, since the subscription marks it as required + const properties = { property1: 'value1', property2: false } + await bucketPlugin.track?.( + new Context({ + type: 'track', + name: 'Button Clicked', + anonymousId: 'user-id-1', + properties + }) + ) + + // TODO: Ideally we should be able to assert that the destination action was never + // called, but couldn't figure out how to create an anlytics instance with the plugin + // and then trigger the full flow trhough analytics.track() with only an anonymous ID + // expect(destination.actions.trackEvent.perform).not.toHaveBeenCalled() + + expect(getBucketCallLog()).toStrictEqual([{ method: 'init', args: ['testTrackingKey', {}] }]) + }) + }) +}) diff --git a/packages/browser-destinations/destinations/bucket/src/trackEvent/generated-types.ts b/packages/browser-destinations/destinations/bucket/src/trackEvent/generated-types.ts new file mode 100644 index 0000000000..2d7e5e4aed --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/trackEvent/generated-types.ts @@ -0,0 +1,18 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The event name + */ + name: string + /** + * Unique identifier for the user + */ + userId: string + /** + * Object containing the properties of the event + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/bucket/src/trackEvent/index.ts b/packages/browser-destinations/destinations/bucket/src/trackEvent/index.ts new file mode 100644 index 0000000000..29bf9a30fb --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/trackEvent/index.ts @@ -0,0 +1,49 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import type { Bucket } from '../types' + +const action: BrowserActionDefinition = { + title: 'Track Event', + description: 'Map a Segment track() event to Bucket', + platform: 'web', + defaultSubscription: 'type = "track"', + fields: { + name: { + description: 'The event name', + label: 'Event name', + required: true, + type: 'string', + default: { + '@path': '$.event' + } + }, + userId: { + type: 'string', + required: true, + allowNull: false, + description: 'Unique identifier for the user', + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + properties: { + type: 'object', + required: false, + description: 'Object containing the properties of the event', + label: 'Event Properties', + default: { + '@path': '$.properties' + } + } + }, + perform: (bucket, { payload }) => { + // Ensure we never call Bucket.track() without a user ID + if (payload.userId) { + void bucket.track(payload.name, payload.properties, payload.userId) + } + } +} + +export default action diff --git a/packages/browser-destinations/destinations/bucket/src/types.ts b/packages/browser-destinations/destinations/bucket/src/types.ts new file mode 100644 index 0000000000..4307622bca --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/src/types.ts @@ -0,0 +1,3 @@ +import type bucket from '@bucketco/tracking-sdk' + +export type Bucket = typeof bucket diff --git a/packages/browser-destinations/destinations/bucket/tsconfig.json b/packages/browser-destinations/destinations/bucket/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/bucket/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/cdpresolution/README.md b/packages/browser-destinations/destinations/cdpresolution/README.md index fa3b1c68eb..7450e7d182 100644 --- a/packages/browser-destinations/destinations/cdpresolution/README.md +++ b/packages/browser-destinations/destinations/cdpresolution/README.md @@ -6,7 +6,7 @@ The Cdpresolution browser action destination for use with @segment/analytics-nex MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/browser-destinations/destinations/cdpresolution/package.json b/packages/browser-destinations/destinations/cdpresolution/package.json index a47bef769e..7af5dd75c1 100644 --- a/packages/browser-destinations/destinations/cdpresolution/package.json +++ b/packages/browser-destinations/destinations/cdpresolution/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-cdpresolution", - "version": "1.2.0", + "version": "1.21.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,7 +15,7 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/cdpresolution/src/index.ts b/packages/browser-destinations/destinations/cdpresolution/src/index.ts index 6ae95119c4..c5cf2356f4 100644 --- a/packages/browser-destinations/destinations/cdpresolution/src/index.ts +++ b/packages/browser-destinations/destinations/cdpresolution/src/index.ts @@ -50,9 +50,9 @@ export const destination: BrowserDestinationDefinition } }, - initialize: async () => { + initialize: async (_, deps) => { window.cdpResolution = { - sync: (endpoint: string, clientIdentifier: string, anonymousId: string): void => { + sync: async (endpoint: string, clientIdentifier: string, anonymousId: string): Promise => { let cdpcookieset = '' const name = 'cdpresolutionset' + '=' const ca = document.cookie.split(';') @@ -82,7 +82,7 @@ export const destination: BrowserDestinationDefinition if (cdpcookieset == '') { document.cookie = 'cdpresolutionset=true' - void fetch(endpointUrl, { mode: 'no-cors' }) + await deps.loadScript(endpointUrl) return } } diff --git a/packages/browser-destinations/destinations/commandbar/package.json b/packages/browser-destinations/destinations/commandbar/package.json index 45394db32c..cbfc8039f9 100644 --- a/packages/browser-destinations/destinations/commandbar/package.json +++ b/packages/browser-destinations/destinations/commandbar/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-commandbar", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/devrev/README.md b/packages/browser-destinations/destinations/devrev/README.md index ac6991638b..26a134bd28 100644 --- a/packages/browser-destinations/destinations/devrev/README.md +++ b/packages/browser-destinations/destinations/devrev/README.md @@ -6,7 +6,7 @@ The Devrev browser action destination for use with @segment/analytics-next. MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/browser-destinations/destinations/devrev/package.json b/packages/browser-destinations/destinations/devrev/package.json index d834e446b1..ab11cdbcec 100644 --- a/packages/browser-destinations/destinations/devrev/package.json +++ b/packages/browser-destinations/destinations/devrev/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-devrev", - "version": "1.2.0", + "version": "1.21.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,7 +15,7 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/friendbuy/package.json b/packages/browser-destinations/destinations/friendbuy/package.json index 94f6853bb6..37f45db0fe 100644 --- a/packages/browser-destinations/destinations/friendbuy/package.json +++ b/packages/browser-destinations/destinations/friendbuy/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-friendbuy", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,9 +15,9 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/actions-shared": "^1.65.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/actions-shared": "^1.84.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/fullstory/package.json b/packages/browser-destinations/destinations/fullstory/package.json index 53a147afa9..1e1ee52bec 100644 --- a/packages/browser-destinations/destinations/fullstory/package.json +++ b/packages/browser-destinations/destinations/fullstory/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-fullstory", - "version": "1.16.0", + "version": "1.36.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,9 +15,9 @@ }, "typings": "./dist/esm", "dependencies": { - "@fullstory/browser": "^1.4.9", - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@fullstory/browser": "^2.0.3", + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/fullstory/src/__tests__/fullstory.test.ts b/packages/browser-destinations/destinations/fullstory/src/__tests__/fullstory.test.ts index c3cf7dff89..861614e765 100644 --- a/packages/browser-destinations/destinations/fullstory/src/__tests__/fullstory.test.ts +++ b/packages/browser-destinations/destinations/fullstory/src/__tests__/fullstory.test.ts @@ -1,6 +1,12 @@ import { Analytics, Context } from '@segment/analytics-next' -import fullstory, { destination } from '..' +import fullstory from '..' +import trackEvent from '../trackEvent' +import identifyUser from '../identifyUser' +import viewedPage from '../viewedPage' import { Subscription } from '@segment/browser-destination-runtime/types' +import { defaultValues } from '@segment/actions-core/*' + +const FakeOrgId = 'asdf-qwer' const example: Subscription[] = [ { @@ -8,82 +14,36 @@ const example: Subscription[] = [ name: 'Track Event', enabled: true, subscribe: 'type = "track"', - mapping: { - name: { - '@path': '$.name' - }, - properties: { - '@path': '$.properties' - } - } + mapping: defaultValues(trackEvent.fields) }, { partnerAction: 'identifyUser', name: 'Identify User', enabled: true, subscribe: 'type = "identify"', - mapping: { - anonymousId: { - '@path': '$.anonymousId' - }, - userId: { - '@path': '$.userId' - }, - email: { - '@path': '$.traits.email' - }, - traits: { - '@path': '$.traits' - }, - displayName: { - '@path': '$.traits.name' - } - } + mapping: defaultValues(identifyUser.fields) + }, + { + partnerAction: 'viewedPage', + name: 'Viewed Page', + enabled: true, + subscribe: 'type = "page"', + mapping: defaultValues(viewedPage.fields) } ] -test('can load fullstory', async () => { - const [event] = await fullstory({ - orgId: 'thefullstory.com', - subscriptions: example - }) - - jest.spyOn(destination.actions.trackEvent, 'perform') - jest.spyOn(destination, 'initialize') - - await event.load(Context.system(), {} as Analytics) - expect(destination.initialize).toHaveBeenCalled() - - const ctx = await event.track?.( - new Context({ - type: 'track', - properties: { - banana: '📞' - } - }) - ) - - expect(destination.actions.trackEvent.perform).toHaveBeenCalled() - expect(ctx).not.toBeUndefined() - - const scripts = window.document.querySelectorAll('script') - expect(scripts).toMatchInlineSnapshot(` - NodeList [ - , - ] - `) +beforeEach(() => { + delete window._fs_initialized + if (window._fs_namespace) { + delete window[window._fs_namespace] + delete window._fs_namespace + } }) describe('#track', () => { it('sends record events to fullstory on "event"', async () => { const [event] = await fullstory({ - orgId: 'thefullstory.com', + orgId: FakeOrgId, subscriptions: example }) @@ -93,7 +53,7 @@ describe('#track', () => { await event.track?.( new Context({ type: 'track', - name: 'hello!', + event: 'hello!', properties: { banana: '📞' } @@ -112,16 +72,16 @@ describe('#track', () => { describe('#identify', () => { it('should default to anonymousId', async () => { - const [_, identifyUser] = await fullstory({ - orgId: 'thefullstory.com', + const [_, identify] = await fullstory({ + orgId: FakeOrgId, subscriptions: example }) - await identifyUser.load(Context.system(), {} as Analytics) + await identify.load(Context.system(), {} as Analytics) const fs = jest.spyOn(window.FS, 'setUserVars') const fsId = jest.spyOn(window.FS, 'identify') - await identifyUser.identify?.( + await identify.identify?.( new Context({ type: 'identify', anonymousId: 'anon', @@ -137,7 +97,7 @@ describe('#identify', () => { }), it('should send an id', async () => { const [_, identifyUser] = await fullstory({ - orgId: 'thefullstory.com', + orgId: FakeOrgId, subscriptions: example }) await identifyUser.load(Context.system(), {} as Analytics) @@ -147,14 +107,14 @@ describe('#identify', () => { expect(fsId).toHaveBeenCalledWith('id', {}, 'segment-browser-actions') }), it('should camelCase custom traits', async () => { - const [_, identifyUser] = await fullstory({ - orgId: 'thefullstory.com', + const [_, identify] = await fullstory({ + orgId: FakeOrgId, subscriptions: example }) - await identifyUser.load(Context.system(), {} as Analytics) + await identify.load(Context.system(), {} as Analytics) const fsId = jest.spyOn(window.FS, 'identify') - await identifyUser.identify?.( + await identify.identify?.( new Context({ type: 'identify', userId: 'id', @@ -173,15 +133,15 @@ describe('#identify', () => { }) it('can set user vars', async () => { - const [_, identifyUser] = await fullstory({ - orgId: 'thefullstory.com', + const [_, identify] = await fullstory({ + orgId: FakeOrgId, subscriptions: example }) - await identifyUser.load(Context.system(), {} as Analytics) + await identify.load(Context.system(), {} as Analytics) const fs = jest.spyOn(window.FS, 'setUserVars') - await identifyUser.identify?.( + await identify.identify?.( new Context({ type: 'identify', traits: { @@ -204,15 +164,15 @@ describe('#identify', () => { }) it('should set displayName correctly', async () => { - const [_, identifyUser] = await fullstory({ - orgId: 'thefullstory.com', + const [_, identify] = await fullstory({ + orgId: FakeOrgId, subscriptions: example }) - await identifyUser.load(Context.system(), {} as Analytics) + await identify.load(Context.system(), {} as Analytics) const fs = jest.spyOn(window.FS, 'identify') - await identifyUser.identify?.( + await identify.identify?.( new Context({ type: 'identify', userId: 'userId', @@ -236,3 +196,93 @@ describe('#identify', () => { ) }) }) + +describe('#page', () => { + it('sends page events to fullstory on "page" (category edition)', async () => { + const [, , viewed] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await viewed.load(Context.system(), {} as Analytics) + const fs = jest.spyOn(window.FS, 'setVars') + + await viewed.page?.( + new Context({ + type: 'page', + category: 'Walruses', + name: 'Walrus Page', + properties: { + banana: '📞' + } + }) + ) + + expect(fs).toHaveBeenCalledWith( + 'page', + { + pageName: 'Walruses', + banana: '📞' + }, + 'segment-browser-actions' + ) + }) + + it('sends page events to fullstory on "page" (name edition)', async () => { + const [, , viewed] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await viewed.load(Context.system(), {} as Analytics) + const fs = jest.spyOn(window.FS, 'setVars') + + await viewed.page?.( + new Context({ + type: 'page', + name: 'Walrus Page', + properties: { + banana: '📞' + } + }) + ) + + expect(fs).toHaveBeenCalledWith( + 'page', + { + pageName: 'Walrus Page', + banana: '📞' + }, + 'segment-browser-actions' + ) + }) + + it('sends page events to fullstory on "page" (no pageName edition)', async () => { + const [, , viewed] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await viewed.load(Context.system(), {} as Analytics) + const fs = jest.spyOn(window.FS, 'setVars') + + await viewed.page?.( + new Context({ + type: 'page', + properties: { + banana: '📞', + keys: '🗝🔑' + } + }) + ) + + expect(fs).toHaveBeenCalledWith( + 'page', + { + banana: '📞', + keys: '🗝🔑' + }, + 'segment-browser-actions' + ) + }) +}) diff --git a/packages/browser-destinations/destinations/fullstory/src/__tests__/fullstoryV2.test.ts b/packages/browser-destinations/destinations/fullstory/src/__tests__/fullstoryV2.test.ts new file mode 100644 index 0000000000..2399ce96c4 --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/__tests__/fullstoryV2.test.ts @@ -0,0 +1,277 @@ +import { Analytics, Context } from '@segment/analytics-next' +import fullstory from '..' +import trackEventV2 from '../trackEventV2' +import identifyUserV2 from '../identifyUserV2' +import viewedPageV2 from '../viewedPageV2' +import { FS as FSApi } from '../types' +import { Subscription } from '@segment/browser-destination-runtime/types' +import { defaultValues } from '@segment/actions-core/*' + +jest.mock('@fullstory/browser', () => ({ + ...jest.requireActual('@fullstory/browser'), + init: () => { + window.FS = jest.fn() as unknown as FSApi + } +})) + +const FakeOrgId = 'asdf-qwer' + +const example: Subscription[] = [ + { + partnerAction: 'trackEventV2', + name: 'Track Event', + enabled: true, + subscribe: 'type = "track"', + mapping: defaultValues(trackEventV2.fields) + }, + { + partnerAction: 'identifyUserV2', + name: 'Identify User', + enabled: true, + subscribe: 'type = "identify"', + mapping: defaultValues(identifyUserV2.fields) + }, + { + partnerAction: 'viewedPageV2', + name: 'Viewed Page', + enabled: true, + subscribe: 'type = "page"', + mapping: defaultValues(viewedPageV2.fields) + } +] + +describe('#track', () => { + it('sends record events to fullstory on "event"', async () => { + const [event] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await event.load(Context.system(), {} as Analytics) + + await event.track?.( + new Context({ + type: 'track', + event: 'hello!', + properties: { + banana: '📞' + } + }) + ) + + expect(window.FS).toHaveBeenCalledWith( + 'trackEvent', + { + name: 'hello!', + properties: { + banana: '📞' + } + }, + 'segment-browser-actions' + ) + }) +}) + +describe('#identify', () => { + it('should default to anonymousId', async () => { + const [_, identifyUser] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await identifyUser.load(Context.system(), {} as Analytics) + + await identifyUser.identify?.( + new Context({ + type: 'identify', + anonymousId: 'anon', + traits: { + testProp: false + } + }) + ) + + expect(window.FS).toHaveBeenCalledTimes(1) + expect(window.FS).toHaveBeenCalledWith( + 'setProperties', + { type: 'user', properties: { segmentAnonymousId: 'anon', testProp: false } }, + 'segment-browser-actions' + ) + }) + + it('should send an id', async () => { + const [_, identifyUser] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + await identifyUser.load(Context.system(), {} as Analytics) + + await identifyUser.identify?.(new Context({ type: 'identify', userId: 'id' })) + expect(window.FS).toHaveBeenCalledWith('setIdentity', { uid: 'id', properties: {} }, 'segment-browser-actions') + }) + + it('can set user vars', async () => { + const [_, identifyUser] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await identifyUser.load(Context.system(), {} as Analytics) + + await identifyUser.identify?.( + new Context({ + type: 'identify', + traits: { + name: 'Hasbulla', + email: 'thegoat@world', + height: '50cm' + } + }) + ) + + expect(window.FS).toHaveBeenCalledWith( + 'setProperties', + { + type: 'user', + properties: { + displayName: 'Hasbulla', + email: 'thegoat@world', + height: '50cm', + name: 'Hasbulla' + } + }, + 'segment-browser-actions' + ) + }) + + it('should set displayName correctly', async () => { + const [_, identifyUser] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await identifyUser.load(Context.system(), {} as Analytics) + + await identifyUser.identify?.( + new Context({ + type: 'identify', + userId: 'userId', + traits: { + name: 'Hasbulla', + email: 'thegoat@world', + height: '50cm' + } + }) + ) + + expect(window.FS).toHaveBeenCalledWith( + 'setIdentity', + { + uid: 'userId', + properties: { + displayName: 'Hasbulla', + email: 'thegoat@world', + height: '50cm', + name: 'Hasbulla' + } + }, + 'segment-browser-actions' + ) + }) +}) + +describe('#page', () => { + it('sends page events to fullstory on "page" (category edition)', async () => { + const [, , viewed] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await viewed.load(Context.system(), {} as Analytics) + + await viewed.page?.( + new Context({ + type: 'page', + category: 'Walruses', + name: 'Walrus Page', + properties: { + banana: '📞' + } + }) + ) + + expect(window.FS).toHaveBeenCalledWith( + 'setProperties', + { + type: 'page', + properties: { + pageName: 'Walruses', + banana: '📞' + } + }, + 'segment-browser-actions' + ) + }) + + it('sends page events to fullstory on "page" (name edition)', async () => { + const [, , viewed] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await viewed.load(Context.system(), {} as Analytics) + + await viewed.page?.( + new Context({ + type: 'page', + name: 'Walrus Page', + properties: { + banana: '📞' + } + }) + ) + + expect(window.FS).toHaveBeenCalledWith( + 'setProperties', + { + type: 'page', + properties: { + pageName: 'Walrus Page', + banana: '📞' + } + }, + 'segment-browser-actions' + ) + }) + + it('sends page events to fullstory on "page" (no pageName edition)', async () => { + const [, , viewed] = await fullstory({ + orgId: FakeOrgId, + subscriptions: example + }) + + await viewed.load(Context.system(), {} as Analytics) + + await viewed.page?.( + new Context({ + type: 'page', + properties: { + banana: '📞', + keys: '🗝🔑' + } + }) + ) + + expect(window.FS).toHaveBeenCalledWith( + 'setProperties', + { + type: 'page', + properties: { + banana: '📞', + keys: '🗝🔑' + } + }, + 'segment-browser-actions' + ) + }) +}) diff --git a/packages/browser-destinations/destinations/fullstory/src/__tests__/initialization.test.ts b/packages/browser-destinations/destinations/fullstory/src/__tests__/initialization.test.ts new file mode 100644 index 0000000000..9f0274bc96 --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/__tests__/initialization.test.ts @@ -0,0 +1,81 @@ +import { Analytics, Context } from '@segment/analytics-next' +import fullstory, { destination } from '..' +import { Subscription } from '@segment/browser-destination-runtime/types' + +const example: Subscription[] = [ + { + partnerAction: 'trackEvent', + name: 'Track Event', + enabled: true, + subscribe: 'type = "track"', + mapping: { + name: { + '@path': '$.name' + }, + properties: { + '@path': '$.properties' + } + } + }, + { + partnerAction: 'identifyUser', + name: 'Identify User', + enabled: true, + subscribe: 'type = "identify"', + mapping: { + anonymousId: { + '@path': '$.anonymousId' + }, + userId: { + '@path': '$.userId' + }, + email: { + '@path': '$.traits.email' + }, + traits: { + '@path': '$.traits' + }, + displayName: { + '@path': '$.traits.name' + } + } + } +] + +test('can load fullstory', async () => { + const [event] = await fullstory({ + orgId: 'thefullstory.com', + subscriptions: example + }) + + jest.spyOn(destination.actions.trackEvent, 'perform') + jest.spyOn(destination, 'initialize') + + await event.load(Context.system(), {} as Analytics) + expect(destination.initialize).toHaveBeenCalled() + + const ctx = await event.track?.( + new Context({ + type: 'track', + properties: { + banana: '📞' + } + }) + ) + + expect(destination.actions.trackEvent.perform).toHaveBeenCalled() + expect(ctx).not.toBeUndefined() + + const scripts = window.document.querySelectorAll('script') + expect(scripts).toMatchInlineSnapshot(` + NodeList [ + , + ] + `) +}) diff --git a/packages/browser-destinations/destinations/fullstory/src/identifyUserV2/generated-types.ts b/packages/browser-destinations/destinations/fullstory/src/identifyUserV2/generated-types.ts new file mode 100644 index 0000000000..e325fbc445 --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/identifyUserV2/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's id + */ + userId?: string + /** + * The user's anonymous id + */ + anonymousId?: string + /** + * The user's display name + */ + displayName?: string + /** + * The user's email + */ + email?: string + /** + * The Segment traits to be forwarded to FullStory + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/fullstory/src/identifyUserV2/index.ts b/packages/browser-destinations/destinations/fullstory/src/identifyUserV2/index.ts new file mode 100644 index 0000000000..97e7db1e7f --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/identifyUserV2/index.ts @@ -0,0 +1,95 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import type { FS } from '../types' +import { segmentEventSource } from '..' + +// Change from unknown to the partner SDK types +const action: BrowserActionDefinition = { + title: 'Identify User V2', + description: 'Sets user identity properties', + platform: 'web', + defaultSubscription: 'type = "identify"', + fields: { + userId: { + type: 'string', + required: false, + description: "The user's id", + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + type: 'string', + required: false, + description: "The user's anonymous id", + label: 'Anonymous ID', + default: { + '@path': '$.anonymousId' + } + }, + displayName: { + type: 'string', + required: false, + description: "The user's display name", + label: 'Display Name', + default: { + '@path': '$.traits.name' + } + }, + email: { + type: 'string', + required: false, + description: "The user's email", + label: 'Email', + default: { + '@path': '$.traits.email' + } + }, + traits: { + type: 'object', + required: false, + description: 'The Segment traits to be forwarded to FullStory', + label: 'Traits', + default: { + '@path': '$.traits' + } + } + }, + perform: (FS, event) => { + const newTraits: Record = event.payload.traits || {} + + if (event.payload.anonymousId) { + newTraits.segmentAnonymousId = event.payload.anonymousId + } + + const userProperties = { + ...newTraits, + ...(event.payload.email !== undefined && { email: event.payload.email }), + ...(event.payload.displayName !== undefined && { displayName: event.payload.displayName }) + } + + if (event.payload.userId) { + FS( + 'setIdentity', + { + uid: event.payload.userId, + properties: userProperties + }, + segmentEventSource + ) + } else { + FS( + 'setProperties', + { + type: 'user', + properties: userProperties + }, + segmentEventSource + ) + } + } +} + +export default action diff --git a/packages/browser-destinations/destinations/fullstory/src/index.ts b/packages/browser-destinations/destinations/fullstory/src/index.ts index c7494cabe4..01b1ac4d75 100644 --- a/packages/browser-destinations/destinations/fullstory/src/index.ts +++ b/packages/browser-destinations/destinations/fullstory/src/index.ts @@ -1,11 +1,14 @@ import type { FS } from './types' import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' -import { FSPackage } from './types' +import { initFullStory } from './types' import { browserDestination } from '@segment/browser-destination-runtime/shim' import type { Settings } from './generated-types' import trackEvent from './trackEvent' +import trackEventV2 from './trackEventV2' import identifyUser from './identifyUser' +import identifyUserV2 from './identifyUserV2' import viewedPage from './viewedPage' +import viewedPageV2 from './viewedPageV2' import { defaultValues } from '@segment/actions-core' declare global { @@ -24,15 +27,22 @@ export const destination: BrowserDestinationDefinition = { { name: 'Track Event', subscribe: 'type = "track"', - partnerAction: 'trackEvent', - mapping: defaultValues(trackEvent.fields), + partnerAction: 'trackEventV2', + mapping: defaultValues(trackEventV2.fields), type: 'automatic' }, { name: 'Identify User', subscribe: 'type = "identify"', - partnerAction: 'identifyUser', - mapping: defaultValues(identifyUser.fields), + partnerAction: 'identifyUserV2', + mapping: defaultValues(identifyUserV2.fields), + type: 'automatic' + }, + { + name: 'Viewed Page', + subscribe: 'type = "page"', + partnerAction: 'viewedPageV2', + mapping: defaultValues(viewedPageV2.fields), type: 'automatic' } ], @@ -60,11 +70,14 @@ export const destination: BrowserDestinationDefinition = { }, actions: { trackEvent, + trackEventV2, identifyUser, - viewedPage + identifyUserV2, + viewedPage, + viewedPageV2 }, initialize: async ({ settings }, dependencies) => { - FSPackage.init(settings) + initFullStory(settings) await dependencies.resolveWhen(() => Object.prototype.hasOwnProperty.call(window, 'FS'), 100) return window.FS } diff --git a/packages/browser-destinations/destinations/fullstory/src/trackEventV2/generated-types.ts b/packages/browser-destinations/destinations/fullstory/src/trackEventV2/generated-types.ts new file mode 100644 index 0000000000..6ec21ec140 --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/trackEventV2/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the event. + */ + name: string + /** + * A JSON object containing additional information about the event that will be indexed by FullStory. + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/fullstory/src/trackEventV2/index.ts b/packages/browser-destinations/destinations/fullstory/src/trackEventV2/index.ts new file mode 100644 index 0000000000..50f2f7b2c0 --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/trackEventV2/index.ts @@ -0,0 +1,44 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import type { FS } from '../types' +import { segmentEventSource } from '..' + +const action: BrowserActionDefinition = { + title: 'Track Event V2', + description: 'Track events', + platform: 'web', + defaultSubscription: 'type = "track"', + fields: { + name: { + description: 'The name of the event.', + label: 'Name', + required: true, + type: 'string', + default: { + '@path': '$.event' + } + }, + properties: { + description: 'A JSON object containing additional information about the event that will be indexed by FullStory.', + label: 'Properties', + required: false, + type: 'object', + default: { + '@path': '$.properties' + } + } + }, + perform: (FS, event) => { + FS( + 'trackEvent', + { + name: event.payload.name, + properties: event.payload.properties ?? {} + }, + segmentEventSource + ) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/fullstory/src/types.ts b/packages/browser-destinations/destinations/fullstory/src/types.ts index fa6f59e129..7a23e1a0e5 100644 --- a/packages/browser-destinations/destinations/fullstory/src/types.ts +++ b/packages/browser-destinations/destinations/fullstory/src/types.ts @@ -1,10 +1,4 @@ -import * as FullStory from '@fullstory/browser' +import { FullStory, init as initFullStory } from '@fullstory/browser' -export const FSPackage = FullStory -export type FS = typeof FullStory & { - // setVars is not available on the FS client yet. - setVars: (eventName: string, eventProperties: object, source: string) => {} - setUserVars: (eventProperties: object, source: string) => void - event: (eventName: string, eventProperties: { [key: string]: unknown }, source: string) => void - identify: (uid: string, customVars: FullStory.UserVars, source: string) => void -} +export type FS = typeof FullStory +export { FullStory, initFullStory } diff --git a/packages/browser-destinations/destinations/fullstory/src/viewedPageV2/generated-types.ts b/packages/browser-destinations/destinations/fullstory/src/viewedPageV2/generated-types.ts new file mode 100644 index 0000000000..fa90a6ee12 --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/viewedPageV2/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the page that was viewed. + */ + pageName?: string + /** + * The properties of the page that was viewed. + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/fullstory/src/viewedPageV2/index.ts b/packages/browser-destinations/destinations/fullstory/src/viewedPageV2/index.ts new file mode 100644 index 0000000000..a531c0df22 --- /dev/null +++ b/packages/browser-destinations/destinations/fullstory/src/viewedPageV2/index.ts @@ -0,0 +1,52 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import type { FS } from '../types' +import { segmentEventSource } from '..' + +const action: BrowserActionDefinition = { + title: 'Viewed Page V2', + description: 'Sets page properties', + defaultSubscription: 'type = "page"', + platform: 'web', + fields: { + pageName: { + type: 'string', + required: false, + description: 'The name of the page that was viewed.', + label: 'Page Name', + default: { + '@if': { + exists: { '@path': '$.category' }, + then: { '@path': '$.category' }, + else: { '@path': '$.name' } + } + } + }, + properties: { + type: 'object', + required: false, + description: 'The properties of the page that was viewed.', + label: 'Properties', + default: { + '@path': '$.properties' + } + } + }, + perform: (FS, event) => { + const properties: object = event.payload.pageName + ? { pageName: event.payload.pageName, ...event.payload.properties } + : { ...event.payload.properties } + + FS( + 'setProperties', + { + type: 'page', + properties + }, + segmentEventSource + ) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/package.json b/packages/browser-destinations/destinations/google-analytics-4-web/package.json index 1f3bca6926..35b30c824e 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/package.json +++ b/packages/browser-destinations/destinations/google-analytics-4-web/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-google-analytics-4", - "version": "1.19.0", + "version": "1.40.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addPaymentInfo.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addPaymentInfo.test.ts index 01a93f084a..9a25125aa5 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addPaymentInfo.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addPaymentInfo.test.ts @@ -18,6 +18,9 @@ const subscriptions: Subscription[] = [ coupon: { '@path': '$.properties.coupon' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -64,7 +67,41 @@ describe('GoogleAnalytics4Web.addPaymentInfo', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 addPaymentInfo Event', async () => { + test('GA4 addPaymentInfo Event when send to is false', async () => { + const context = new Context({ + event: 'Payment Info Entered', + type: 'track', + properties: { + currency: 'USD', + value: 10, + coupon: 'SUMMER_123', + payment_method: 'Credit Card', + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await addPaymentInfoEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('add_payment_info'), + expect.objectContaining({ + coupon: 'SUMMER_123', + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' + }) + ) + }) + + test('GA4 addPaymentInfo Event when send to is true', async () => { const context = new Context({ event: 'Payment Info Entered', type: 'track', @@ -73,6 +110,7 @@ describe('GoogleAnalytics4Web.addPaymentInfo', () => { value: 10, coupon: 'SUMMER_123', payment_method: 'Credit Card', + send_to: true, products: [ { product_id: '12345', @@ -91,7 +129,41 @@ describe('GoogleAnalytics4Web.addPaymentInfo', () => { coupon: 'SUMMER_123', currency: 'USD', items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: settings.measurementID + }) + ) + }) + + test('GA4 addPaymentInfo Event when send to is undefined', async () => { + const context = new Context({ + event: 'Payment Info Entered', + type: 'track', + properties: { + currency: 'USD', + value: 10, + coupon: 'SUMMER_123', + payment_method: 'Credit Card', + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await addPaymentInfoEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('add_payment_info'), + expect.objectContaining({ + coupon: 'SUMMER_123', + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToCart.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToCart.test.ts index dd2852d7aa..024f34742c 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToCart.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToCart.test.ts @@ -15,6 +15,9 @@ const subscriptions: Subscription[] = [ value: { '@path': '$.properties.value' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -61,13 +64,14 @@ describe('GoogleAnalytics4Web.addToCart', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 addToCart Event', async () => { + test('GA4 addToCart Event when send to is false', async () => { const context = new Context({ event: 'Add To Cart', type: 'track', properties: { currency: 'USD', value: 10, + send_to: false, products: [ { product_id: '12345', @@ -85,7 +89,68 @@ describe('GoogleAnalytics4Web.addToCart', () => { expect.objectContaining({ currency: 'USD', items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 addToCart Event when send to is true', async () => { + const context = new Context({ + event: 'Add To Cart', + type: 'track', + properties: { + currency: 'USD', + value: 10, + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await addToCartEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('add_to_cart'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: settings.measurementID + }) + ) + }) + + test('GA4 addToCart Event when send to is undefined', async () => { + const context = new Context({ + event: 'Add To Cart', + type: 'track', + properties: { + currency: 'USD', + value: 10, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await addToCartEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('add_to_cart'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToWishlist.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToWishlist.test.ts index 4dfc53432a..fdd99f0664 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToWishlist.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/addToWishlist.test.ts @@ -15,6 +15,9 @@ const subscriptions: Subscription[] = [ value: { '@path': '$.properties.value' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -61,13 +64,45 @@ describe('GoogleAnalytics4Web.addToWishlist', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('Track call without parameters', async () => { + test('Track call without parameters when send to is false', async () => { + const context = new Context({ + event: 'Add To Wishlist', + type: 'track', + properties: { + currency: 'USD', + value: 10, + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await addToWishlistEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('add_to_wishlist'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' + }) + ) + }) + + test('Track call without parameters when send to is true', async () => { const context = new Context({ event: 'Add To Wishlist', type: 'track', properties: { currency: 'USD', value: 10, + send_to: true, products: [ { product_id: '12345', @@ -85,7 +120,38 @@ describe('GoogleAnalytics4Web.addToWishlist', () => { expect.objectContaining({ currency: 'USD', items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: settings.measurementID + }) + ) + }) + + test('Track call without parameters when send to is undefined', async () => { + const context = new Context({ + event: 'Add To Wishlist', + type: 'track', + properties: { + currency: 'USD', + value: 10, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await addToWishlistEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('add_to_wishlist'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/beginCheckout.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/beginCheckout.test.ts index f59339b1d7..23d72e68e1 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/beginCheckout.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/beginCheckout.test.ts @@ -18,6 +18,9 @@ const subscriptions: Subscription[] = [ coupon: { '@path': '$.properties.coupon' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -64,7 +67,73 @@ describe('GoogleAnalytics4Web.beginCheckout', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 beginCheckout Event', async () => { + test('GA4 beginCheckout Event when send to is false', async () => { + const context = new Context({ + event: 'Begin Checkout', + type: 'track', + properties: { + currency: 'USD', + value: 10, + coupon: 'SUMMER_123', + payment_method: 'Credit Card', + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await beginCheckoutEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('begin_checkout'), + expect.objectContaining({ + coupon: 'SUMMER_123', + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 beginCheckout Event when send to is true', async () => { + const context = new Context({ + event: 'Begin Checkout', + type: 'track', + properties: { + currency: 'USD', + value: 10, + coupon: 'SUMMER_123', + payment_method: 'Credit Card', + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await beginCheckoutEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('begin_checkout'), + expect.objectContaining({ + coupon: 'SUMMER_123', + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: settings.measurementID + }) + ) + }) + test('GA4 beginCheckout Event when send to is undefined', async () => { const context = new Context({ event: 'Begin Checkout', type: 'track', @@ -91,7 +160,8 @@ describe('GoogleAnalytics4Web.beginCheckout', () => { coupon: 'SUMMER_123', currency: 'USD', items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/customEvent.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/customEvent.test.ts index 32c263736e..c155cbcc89 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/customEvent.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/customEvent.test.ts @@ -12,6 +12,9 @@ const subscriptions: Subscription[] = [ name: { '@path': '$.event' }, + send_to: { + '@path': '$.properties.send_to' + }, params: { '@path': '$.properties.params' } @@ -42,7 +45,34 @@ describe('GoogleAnalytics4Web.customEvent', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 customEvent Event', async () => { + test('GA4 customEvent Event when send_to is false', async () => { + const context = new Context({ + event: 'Custom Event', + type: 'track', + properties: { + send_to: false, + params: [ + { + paramOne: 'test123', + paramTwo: 'test123', + paramThree: 123 + } + ] + } + }) + await customEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Custom_Event'), + expect.objectContaining({ + send_to: 'default', + ...[{ paramOne: 'test123', paramThree: 123, paramTwo: 'test123' }] + }) + ) + }) + + test('GA4 customEvent Event when send_to is undefined', async () => { const context = new Context({ event: 'Custom Event', type: 'track', @@ -61,7 +91,37 @@ describe('GoogleAnalytics4Web.customEvent', () => { expect(mockGA4).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('Custom_Event'), - expect.objectContaining([{ paramOne: 'test123', paramThree: 123, paramTwo: 'test123' }]) + expect.objectContaining({ + send_to: 'default', + ...[{ paramOne: 'test123', paramThree: 123, paramTwo: 'test123' }] + }) + ) + }) + + test('GA4 customEvent Event when send_to is true', async () => { + const context = new Context({ + event: 'Custom Event', + type: 'track', + properties: { + params: [ + { + paramOne: 'test123', + paramTwo: 'test123', + paramThree: 123 + } + ], + send_to: true + } + }) + await customEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Custom_Event'), + expect.objectContaining({ + send_to: settings.measurementID, + ...[{ paramOne: 'test123', paramThree: 123, paramTwo: 'test123' }] + }) ) }) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/generateLead.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/generateLead.test.ts index cc7cebb251..f334f4df38 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/generateLead.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/generateLead.test.ts @@ -14,6 +14,9 @@ const subscriptions: Subscription[] = [ }, value: { '@path': '$.properties.value' + }, + send_to: { + '@path': '$.properties.send_to' } } } @@ -42,13 +45,36 @@ describe('GoogleAnalytics4Web.generateLead', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 generateLead Event', async () => { + test('GA4 generateLead Event when send to is false', async () => { const context = new Context({ event: 'Generate Lead', type: 'track', properties: { currency: 'USD', - value: 10 + value: 10, + send_to: false + } + }) + await generateLeadEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('generate_lead'), + expect.objectContaining({ + currency: 'USD', + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 generateLead Event when send to is true', async () => { + const context = new Context({ + event: 'Generate Lead', + type: 'track', + properties: { + currency: 'USD', + value: 10, + send_to: true } }) await generateLeadEvent.track?.(context) @@ -57,8 +83,30 @@ describe('GoogleAnalytics4Web.generateLead', () => { expect.anything(), expect.stringContaining('generate_lead'), expect.objectContaining({ + currency: 'USD', + value: 10, + send_to: settings.measurementID + }) + ) + }) + test('GA4 generateLead Event when send to is undefined', async () => { + const context = new Context({ + event: 'Generate Lead', + type: 'track', + properties: { currency: 'USD', value: 10 + } + }) + await generateLeadEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('generate_lead'), + expect.objectContaining({ + currency: 'USD', + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/login.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/login.test.ts index 3aadda5fcb..b50577a251 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/login.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/login.test.ts @@ -11,6 +11,9 @@ const subscriptions: Subscription[] = [ mapping: { method: { '@path': '$.properties.method' + }, + send_to: { + '@path': '$.properties.send_to' } } } @@ -39,12 +42,33 @@ describe('GoogleAnalytics4Web.login', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 login Event', async () => { + test('GA4 login Event when send to is false', async () => { const context = new Context({ event: 'Login', type: 'track', properties: { - method: 'Google' + method: 'Google', + send_to: false + } + }) + await loginEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('login'), + expect.objectContaining({ + method: 'Google', + send_to: 'default' + }) + ) + }) + test('GA4 login Event when send to is true', async () => { + const context = new Context({ + event: 'Login', + type: 'track', + properties: { + method: 'Google', + send_to: true } }) await loginEvent.track?.(context) @@ -53,7 +77,28 @@ describe('GoogleAnalytics4Web.login', () => { expect.anything(), expect.stringContaining('login'), expect.objectContaining({ + method: 'Google', + send_to: settings.measurementID + }) + ) + }) + + test('GA4 login Event when send to is undefined', async () => { + const context = new Context({ + event: 'Login', + type: 'track', + properties: { method: 'Google' + } + }) + await loginEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('login'), + expect.objectContaining({ + method: 'Google', + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/purchase.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/purchase.test.ts index d231073f1d..37d2d0aec7 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/purchase.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/purchase.test.ts @@ -21,6 +21,9 @@ const subscriptions: Subscription[] = [ transaction_id: { '@path': '$.properties.transaction_id' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -67,7 +70,7 @@ describe('GoogleAnalytics4Web.purchase', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 purchase Event', async () => { + test('GA4 purchase Event when send to is false', async () => { const context = new Context({ event: 'Purchase', type: 'track', @@ -75,6 +78,7 @@ describe('GoogleAnalytics4Web.purchase', () => { currency: 'USD', value: 10, transaction_id: 12321, + send_to: false, products: [ { product_id: '12345', @@ -93,7 +97,72 @@ describe('GoogleAnalytics4Web.purchase', () => { currency: 'USD', transaction_id: 12321, items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 purchase Event when send to is true', async () => { + const context = new Context({ + event: 'Purchase', + type: 'track', + properties: { + currency: 'USD', + value: 10, + transaction_id: 12321, + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await purchaseEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('purchase'), + expect.objectContaining({ + currency: 'USD', + transaction_id: 12321, + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: settings.measurementID + }) + ) + }) + + test('GA4 purchase Event when send to is undefined', async () => { + const context = new Context({ + event: 'Purchase', + type: 'track', + properties: { + currency: 'USD', + value: 10, + transaction_id: 12321, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await purchaseEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('purchase'), + expect.objectContaining({ + currency: 'USD', + transaction_id: 12321, + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/refund.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/refund.test.ts index feba1a6a9e..3ebf3d5fa1 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/refund.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/refund.test.ts @@ -15,6 +15,9 @@ const subscriptions: Subscription[] = [ value: { '@path': '$.properties.value' }, + send_to: { + '@path': '$.properties.send_to' + }, coupon: { '@path': '$.properties.coupon' }, @@ -67,7 +70,71 @@ describe('GoogleAnalytics4Web.refund', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 Refund Event', async () => { + test('GA4 Refund Event when send to is false', async () => { + const context = new Context({ + event: 'Refund', + type: 'track', + properties: { + currency: 'USD', + value: 10, + transaction_id: 12321, + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await refundEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('refund'), + expect.objectContaining({ + currency: 'USD', + transaction_id: 12321, + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 Refund Event when send to is true', async () => { + const context = new Context({ + event: 'Refund', + type: 'track', + properties: { + currency: 'USD', + value: 10, + transaction_id: 12321, + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + await refundEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('refund'), + expect.objectContaining({ + currency: 'USD', + transaction_id: 12321, + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: settings.measurementID + }) + ) + }) + test('GA4 Refund Event when send to is undefined', async () => { const context = new Context({ event: 'Refund', type: 'track', @@ -93,7 +160,8 @@ describe('GoogleAnalytics4Web.refund', () => { currency: 'USD', transaction_id: 12321, items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/removeFromCart.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/removeFromCart.test.ts index 31a938a121..2ecdaa5f20 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/removeFromCart.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/removeFromCart.test.ts @@ -18,6 +18,9 @@ const subscriptions: Subscription[] = [ coupon: { '@path': '$.properties.coupon' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -64,13 +67,45 @@ describe('GoogleAnalytics4Web.removeFromCart', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 removeFromCart Event', async () => { + test('GA4 removeFromCart Event when send to is false', async () => { + const context = new Context({ + event: 'Remove from Cart', + type: 'track', + properties: { + currency: 'USD', + value: 10, + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await removeFromCartEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('remove_from_cart'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 removeFromCart Event when send to is true', async () => { const context = new Context({ event: 'Remove from Cart', type: 'track', properties: { currency: 'USD', value: 10, + send_to: true, products: [ { product_id: '12345', @@ -89,7 +124,39 @@ describe('GoogleAnalytics4Web.removeFromCart', () => { expect.objectContaining({ currency: 'USD', items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: settings.measurementID + }) + ) + }) + + test('GA4 removeFromCart Event when send to is undefined', async () => { + const context = new Context({ + event: 'Remove from Cart', + type: 'track', + properties: { + currency: 'USD', + value: 10, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await removeFromCartEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('remove_from_cart'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/search.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/search.test.ts index 902006f63f..a7b1c19c33 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/search.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/search.test.ts @@ -11,6 +11,9 @@ const subscriptions: Subscription[] = [ mapping: { search_term: { '@path': '$.properties.search_term' + }, + send_to: { + '@path': '$.properties.send_to' } } } @@ -39,12 +42,34 @@ describe('GoogleAnalytics4Web.search', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 search Event', async () => { + test('GA4 search Event when send to is false', async () => { const context = new Context({ event: 'search', type: 'track', properties: { - search_term: 'Monopoly: 3rd Edition' + search_term: 'Monopoly: 3rd Edition', + send_to: false + } + }) + + await searchEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('search'), + expect.objectContaining({ + search_term: 'Monopoly: 3rd Edition', + send_to: 'default' + }) + ) + }) + test('GA4 search Event when send to is true', async () => { + const context = new Context({ + event: 'search', + type: 'track', + properties: { + search_term: 'Monopoly: 3rd Edition', + send_to: true } }) @@ -54,7 +79,28 @@ describe('GoogleAnalytics4Web.search', () => { expect.anything(), expect.stringContaining('search'), expect.objectContaining({ + search_term: 'Monopoly: 3rd Edition', + send_to: settings.measurementID + }) + ) + }) + test('GA4 search Event when send to is undefined', async () => { + const context = new Context({ + event: 'search', + type: 'track', + properties: { search_term: 'Monopoly: 3rd Edition' + } + }) + + await searchEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('search'), + expect.objectContaining({ + search_term: 'Monopoly: 3rd Edition', + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectItem.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectItem.test.ts index b2eeedcd38..345a2884ce 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectItem.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectItem.test.ts @@ -15,6 +15,9 @@ const subscriptions: Subscription[] = [ item_list_name: { '@path': '$.properties.item_list_name' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -61,7 +64,70 @@ describe('GoogleAnalytics4Web.selectItem', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 selectItem Event', async () => { + test('GA4 selectItem Event when send to is false', async () => { + const context = new Context({ + event: 'Select Item', + type: 'track', + properties: { + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await selectItemEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('select_item'), + expect.objectContaining({ + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' + }) + ) + }) + test('GA4 selectItem Event when send to is true', async () => { + const context = new Context({ + event: 'Select Item', + type: 'track', + properties: { + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await selectItemEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('select_item'), + expect.objectContaining({ + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: settings.measurementID + }) + ) + }) + + test('GA4 selectItem Event when send to is undefined', async () => { const context = new Context({ event: 'Select Item', type: 'track', @@ -86,7 +152,8 @@ describe('GoogleAnalytics4Web.selectItem', () => { expect.objectContaining({ item_list_id: 12321, item_list_name: 'Monopoly: 3rd Edition', - items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }] + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectPromotion.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectPromotion.test.ts index a5d4b3446f..c35a910668 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectPromotion.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/selectPromotion.test.ts @@ -24,6 +24,9 @@ const subscriptions: Subscription[] = [ promotion_name: { '@path': '$.properties.promotion_name' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -70,7 +73,82 @@ describe('GoogleAnalytics4Web.selectPromotion', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 selectPromotion Event', async () => { + test('GA4 selectPromotion Event when send to is false', async () => { + const context = new Context({ + event: 'Select Promotion', + type: 'track', + properties: { + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await selectPromotionEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('select_promotion'), + expect.objectContaining({ + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' + }) + ) + }) + test('GA4 selectPromotion Event when send to is true', async () => { + const context = new Context({ + event: 'Select Promotion', + type: 'track', + properties: { + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await selectPromotionEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('select_promotion'), + expect.objectContaining({ + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: settings.measurementID + }) + ) + }) + + test('GA4 selectPromotion Event when send to is undefined', async () => { const context = new Context({ event: 'Select Promotion', type: 'track', @@ -101,7 +179,8 @@ describe('GoogleAnalytics4Web.selectPromotion', () => { location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', promotion_id: 'P_12345', promotion_name: 'Summer Sale', - items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }] + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/setConfigurationFields.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/setConfigurationFields.test.ts new file mode 100644 index 0000000000..feca9da7d3 --- /dev/null +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/setConfigurationFields.test.ts @@ -0,0 +1,809 @@ +import googleAnalytics4Web, { destination } from '../index' +import { Analytics, Context } from '@segment/analytics-next' +import { Subscription } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' + +let mockGtag: GA +let setConfigurationEvent: any +const subscriptions: Subscription[] = [ + { + partnerAction: 'setConfigurationFields', + name: 'Set Configuration Fields', + enabled: true, + subscribe: 'type = "page"', + mapping: { + ads_storage_consent_state: { + '@path': '$.properties.ads_storage_consent_state' + }, + analytics_storage_consent_state: { + '@path': '$.properties.analytics_storage_consent_state' + }, + screen_resolution: { + '@path': '$.properties.screen_resolution' + }, + user_id: { + '@path': '$.properties.user_id' + }, + page_title: { + '@path': '$.properties.page_title' + }, + page_referrer: { + '@path': '$.properties.page_referrer' + }, + language: { + '@path': '$.properties.language' + }, + content_group: { + '@path': '$.properties.content_group' + }, + campaign_content: { + '@path': '$.properties.campaign_content' + }, + campaign_id: { + '@path': '$.properties.campaign_id' + }, + campaign_medium: { + '@path': '$.properties.campaign_medium' + }, + campaign_name: { + '@path': '$.properties.campaign_name' + }, + campaign_source: { + '@path': '$.properties.campaign_source' + }, + campaign_term: { + '@path': '$.properties.campaign_term' + }, + ad_user_data_consent_state: { + '@path': '$.properties.ad_user_data_consent_state' + }, + ad_personalization_consent_state: { + '@path': '$.properties.ad_personalization_consent_state' + }, + send_page_view: { + '@path': '$.properties.send_page_view' + } + } + } +] + +describe('Set Configuration Fields action', () => { + const defaultSettings: Settings = { + enableConsentMode: false, + measurementID: 'G-XXXXXXXXXX', + allowAdPersonalizationSignals: false, + allowGoogleSignals: false, + cookieDomain: 'auto', + cookieExpirationInSeconds: 63072000, + cookieUpdate: true, + pageView: true + } + beforeEach(async () => { + jest.restoreAllMocks() + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...defaultSettings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + + jest.spyOn(destination, 'initialize').mockImplementation(() => { + mockGtag = jest.fn() + return Promise.resolve(mockGtag) + }) + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + }) + + it('should configure consent when consent mode is enabled', async () => { + defaultSettings.enableConsentMode = true + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...defaultSettings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ads_storage_consent_state: 'granted', + analytics_storage_consent_state: 'denied' + } + }) + + setConfigurationEvent.page?.(context) + + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', { + ad_storage: 'granted', + analytics_storage: 'denied' + }) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true + }) + }) + it('should configure cookie expiry time other then default value', async () => { + const settings = { + ...defaultSettings, + cookieExpirationInSeconds: 500 + } + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: {} + }) + + setConfigurationEvent.page?.(context) + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + cookie_expires: 500 + }) + }) + it('should configure cookie domain other then default value', async () => { + const settings = { + ...defaultSettings, + cookieDomain: 'example.com' + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: {} + }) + + setConfigurationEvent.page?.(context) + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + cookie_domain: 'example.com' + }) + }) + it('should configure cookie prefix other then default value', async () => { + const settings = { + ...defaultSettings, + cookiePrefix: 'stage' + } + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: {} + }) + + setConfigurationEvent.page?.(context) + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + cookie_prefix: 'stage' + }) + }) + it('should configure cookie path other then default value', async () => { + const settings = { + ...defaultSettings, + cookiePath: '/home' + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: {} + }) + + setConfigurationEvent.page?.(context) + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + cookie_path: '/home' + }) + }) + it('should configure cookie update other then default value', async () => { + const settings = { + ...defaultSettings, + cookieUpdate: false + } + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: {} + }) + + setConfigurationEvent.page?.(context) + + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + cookie_update: false + }) + }) + it('should not configure consent when consent mode is disabled', async () => { + const settings = { + ...defaultSettings, + enableConsentMode: false + } + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: {} + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true + }) + }) + it('should update config if payload has screen resolution', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + screen_resolution: '800*600' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + screen_resolution: '800*600' + }) + }) + it('should update config if payload has user_id', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + user_id: 'segment-123' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + user_id: 'segment-123' + }) + }) + it('should update config if payload has page_title', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + page_title: 'User Registration Page' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + page_title: 'User Registration Page' + }) + }) + it('should update config if payload has language', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + language: 'EN' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + language: 'EN' + }) + }) + it('should update config if payload has content_group', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + content_group: '/home/login' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + content_group: '/home/login' + }) + }) + it('should update config if payload has campaign_term', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + campaign_term: 'running+shoes' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + campaign_term: 'running+shoes' + }) + }) + it('should update config if payload has campaign_source', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + campaign_source: 'google' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + campaign_source: 'google' + }) + }) + it('should update config if payload has campaign_name', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + campaign_name: 'spring_sale' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + campaign_name: 'spring_sale' + }) + }) + it('should update config if payload has campaign_medium', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + campaign_medium: 'cpc' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + campaign_medium: 'cpc' + }) + }) + it('should update config if payload has campaign_id', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + campaign_id: 'abc.123' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + campaign_id: 'abc.123' + }) + }) + it('should update config if payload has campaign_content', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + campaign_content: 'logolink' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true, + campaign_content: 'logolink' + }) + }) + + it('pageView is true and send_page_view is true -> nothing', async () => { + const settings = { + ...defaultSettings, + pageView: true + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: true + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false + }) + }) + it('pageView is true and send_page_view is false -> false', async () => { + const settings = { + ...defaultSettings, + pageView: true + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: false + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: false + }) + }) + it('pageView is true and send_page_view is undefined -> true', async () => { + const settings = { + ...defaultSettings, + pageView: true + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: undefined + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true + }) + }) + + it('pageView is false and send_page_view is true -> true', async () => { + const settings = { + ...defaultSettings, + pageView: false + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: true + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true + }) + }) + + it('pageView is false and send_page_view is false -> false', async () => { + const settings = { + ...defaultSettings, + pageView: false + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: false + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: false + }) + }) + + it('pageView is false and send_page_view is undefined -> false', async () => { + const settings = { + ...defaultSettings, + pageView: false + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: undefined + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: false + }) + }) + + it('pageView is undefined and send_page_view is undefined -> true', async () => { + const settings = { + ...defaultSettings, + pageView: undefined + } + + const [setConfigurationEventPlugin] = await googleAnalytics4Web({ + ...settings, + subscriptions + }) + setConfigurationEvent = setConfigurationEventPlugin + await setConfigurationEventPlugin.load(Context.system(), {} as Analytics) + + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: undefined + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true + }) + }) + + it('should update consent if payload has ad_user_data_consent_state granted', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ad_user_data_consent_state: 'granted' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', { + ad_user_data: 'granted' + }) + }) + + it('should update consent if payload has ad_user_data_consent_state denied', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ad_user_data_consent_state: 'denied' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', { + ad_user_data: 'denied' + }) + }) + + it('should update consent if payload has ad_user_data_consent_state undefined', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ad_user_data_consent_state: undefined + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', {}) + }) + + it('should update consent if payload has ad_personalization_consent_state granted', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ad_personalization_consent_state: 'granted' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', { + ad_personalization: 'granted' + }) + }) + it('should update consent if payload has ad_personalization_consent_state denied', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ad_personalization_consent_state: 'denied' + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', { + ad_personalization: 'denied' + }) + }) + it('should update consent if payload has ad_personalization_consent_state undefined', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + ad_personalization_consent_state: undefined + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('consent', 'update', {}) + }) + it('should update config if payload has send_page_view undefined', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: undefined + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: true + }) + }) + it('should update config if payload has send_page_view true', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: true + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false + }) + }) + + it('should update config if payload has send_page_view false', () => { + const context = new Context({ + event: 'setConfigurationFields', + type: 'page', + properties: { + send_page_view: false + } + }) + + setConfigurationEvent.page?.(context) + expect(mockGtag).toHaveBeenCalledWith('config', 'G-XXXXXXXXXX', { + allow_ad_personalization_signals: false, + allow_google_signals: false, + send_page_view: false + }) + }) +}) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/signUp.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/signUp.test.ts index 60ea396d23..e17079d77f 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/signUp.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/signUp.test.ts @@ -11,6 +11,9 @@ const subscriptions: Subscription[] = [ mapping: { method: { '@path': '$.properties.method' + }, + send_to: { + '@path': '$.properties.send_to' } } } @@ -39,7 +42,44 @@ describe('GoogleAnalytics4Web.signUp', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 signUp Event', async () => { + test('GA4 signUp Event when send to is false', async () => { + const context = new Context({ + event: 'signUp', + type: 'track', + properties: { + method: 'Google', + send_to: false + } + }) + + await signUpEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('sign_up'), + expect.objectContaining({ method: 'Google', send_to: 'default' }) + ) + }) + test('GA4 signUp Event when send to is true', async () => { + const context = new Context({ + event: 'signUp', + type: 'track', + properties: { + method: 'Google', + send_to: true + } + }) + + await signUpEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('sign_up'), + expect.objectContaining({ method: 'Google', send_to: settings.measurementID }) + ) + }) + + test('GA4 signUp Event when send to is undefined', async () => { const context = new Context({ event: 'signUp', type: 'track', @@ -53,7 +93,7 @@ describe('GoogleAnalytics4Web.signUp', () => { expect(mockGA4).toHaveBeenCalledWith( expect.anything(), expect.stringContaining('sign_up'), - expect.objectContaining({ method: 'Google' }) + expect.objectContaining({ method: 'Google', send_to: 'default' }) ) }) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewCart.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewCart.test.ts index d4df588843..da2c7c52da 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewCart.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewCart.test.ts @@ -15,6 +15,9 @@ const subscriptions: Subscription[] = [ value: { '@path': '$.properties.value' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -61,13 +64,45 @@ describe('GoogleAnalytics4Web.viewCart', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 viewCart Event', async () => { + test('GA4 viewCart Event when send to is false', async () => { + const context = new Context({ + event: 'View Cart', + type: 'track', + properties: { + currency: 'USD', + value: 10, + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewCartEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_cart'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 viewCart Event when send to is true', async () => { const context = new Context({ event: 'View Cart', type: 'track', properties: { currency: 'USD', value: 10, + send_to: true, products: [ { product_id: '12345', @@ -86,7 +121,39 @@ describe('GoogleAnalytics4Web.viewCart', () => { expect.objectContaining({ currency: 'USD', items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: settings.measurementID + }) + ) + }) + + test('GA4 viewCart Event when send to is false', async () => { + const context = new Context({ + event: 'View Cart', + type: 'track', + properties: { + currency: 'USD', + value: 10, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewCartEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_cart'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItem.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItem.test.ts index bcd6b35f86..08d3ab4706 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItem.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItem.test.ts @@ -15,6 +15,9 @@ const subscriptions: Subscription[] = [ value: { '@path': '$.properties.value' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -61,7 +64,69 @@ describe('GoogleAnalytics4Web.viewItem', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 viewItem Event', async () => { + test('GA4 viewItem Event when send to is false', async () => { + const context = new Context({ + event: 'View Item', + type: 'track', + properties: { + currency: 'USD', + value: 10, + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewItemEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_item'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: 'default' + }) + ) + }) + test('GA4 viewItem Event when send to is true', async () => { + const context = new Context({ + event: 'View Item', + type: 'track', + properties: { + currency: 'USD', + value: 10, + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewItemEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_item'), + expect.objectContaining({ + currency: 'USD', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + value: 10, + send_to: settings.measurementID + }) + ) + }) + test('GA4 viewItem Event when send to is undefined', async () => { const context = new Context({ event: 'View Item', type: 'track', @@ -86,7 +151,8 @@ describe('GoogleAnalytics4Web.viewItem', () => { expect.objectContaining({ currency: 'USD', items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], - value: 10 + value: 10, + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItemList.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItemList.test.ts index e6630315f3..b9ec9bd1ad 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItemList.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewItemList.test.ts @@ -15,6 +15,9 @@ const subscriptions: Subscription[] = [ item_list_name: { '@path': '$.properties.item_list_name' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -61,7 +64,71 @@ describe('GoogleAnalytics4Web.viewItemList', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 viewItemList Event', async () => { + test('GA4 viewItemList Event when send to is false', async () => { + const context = new Context({ + event: 'View Item List', + type: 'track', + properties: { + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewItemListEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_item_list'), + expect.objectContaining({ + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' + }) + ) + }) + + test('GA4 viewItemList Event when send to is true', async () => { + const context = new Context({ + event: 'View Item List', + type: 'track', + properties: { + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewItemListEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_item_list'), + expect.objectContaining({ + item_list_id: 12321, + item_list_name: 'Monopoly: 3rd Edition', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: settings.measurementID + }) + ) + }) + + test('GA4 viewItemList Event when send to is undefined', async () => { const context = new Context({ event: 'View Item List', type: 'track', @@ -86,7 +153,8 @@ describe('GoogleAnalytics4Web.viewItemList', () => { expect.objectContaining({ item_list_id: 12321, item_list_name: 'Monopoly: 3rd Edition', - items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }] + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewPromotion.test.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewPromotion.test.ts index 7b5f605c1c..3e5356242b 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewPromotion.test.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/__tests__/viewPromotion.test.ts @@ -24,6 +24,9 @@ const subscriptions: Subscription[] = [ promotion_name: { '@path': '$.properties.promotion_name' }, + send_to: { + '@path': '$.properties.send_to' + }, items: [ { item_name: { @@ -70,7 +73,81 @@ describe('GoogleAnalytics4Web.viewPromotion', () => { await trackEventPlugin.load(Context.system(), {} as Analytics) }) - test('GA4 viewPromotion Event', async () => { + test('GA4 viewPromotion Event when send to is false', async () => { + const context = new Context({ + event: 'Select Promotion', + type: 'track', + properties: { + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + send_to: false, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewPromotionEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_promotion'), + expect.objectContaining({ + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' + }) + ) + }) + test('GA4 viewPromotion Event when send to is true', async () => { + const context = new Context({ + event: 'Select Promotion', + type: 'track', + properties: { + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + send_to: true, + products: [ + { + product_id: '12345', + name: 'Monopoly: 3rd Edition', + currency: 'USD' + } + ] + } + }) + + await viewPromotionEvent.track?.(context) + + expect(mockGA4).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('view_promotion'), + expect.objectContaining({ + creative_name: 'summer_banner2', + creative_slot: 'featured_app_1', + location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', + promotion_id: 'P_12345', + promotion_name: 'Summer Sale', + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: settings.measurementID + }) + ) + }) + test('GA4 viewPromotion Event when send to is undefined', async () => { const context = new Context({ event: 'Select Promotion', type: 'track', @@ -101,7 +178,8 @@ describe('GoogleAnalytics4Web.viewPromotion', () => { location_id: 'ChIJIQBpAG2ahYAR_6128GcTUEo', promotion_id: 'P_12345', promotion_name: 'Summer Sale', - items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }] + items: [{ currency: 'USD', item_id: '12345', item_name: 'Monopoly: 3rd Edition' }], + send_to: 'default' }) ) }) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/generated-types.ts index 8babfe4438..c083cd3a00 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/generated-types.ts @@ -101,6 +101,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -114,4 +115,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/index.ts index d770fa1e20..b4d891e3b8 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/addPaymentInfo/index.ts @@ -1,7 +1,6 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { updateUser } from '../ga4-functions' import { user_id, user_properties, @@ -10,7 +9,8 @@ import { coupon, payment_type, items_multi_products, - params + params, + send_to } from '../ga4-properties' // Change from unknown to the partner SDK types @@ -30,16 +30,19 @@ const action: BrowserActionDefinition = { required: true }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) + perform: (gtag, { payload, settings }) => { gtag('event', 'add_payment_info', { currency: payload.currency, value: payload.value, coupon: payload.coupon, payment_type: payload.payment_type, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/generated-types.ts index 6e0f5789e3..93061827ac 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/generated-types.ts @@ -89,6 +89,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The monetary value of the event. @@ -106,4 +107,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/index.ts index e11cf5ec89..359fbdd59c 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToCart/index.ts @@ -1,9 +1,8 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { updateUser } from '../ga4-functions' -import { user_properties, params, value, currency, items_single_products, user_id } from '../ga4-properties' +import { user_properties, params, value, currency, items_single_products, user_id, send_to } from '../ga4-properties' const action: BrowserActionDefinition = { title: 'Add to Cart', @@ -19,15 +18,17 @@ const action: BrowserActionDefinition = { }, value: value, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'add_to_cart', { currency: payload.currency, value: payload.value, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/generated-types.ts index a7f5a2e20e..b6d8985b27 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -106,4 +107,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/index.ts index b5a1145d0e..f416414283 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/addToWishlist/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, value, currency, items_single_products, user_id } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, value, currency, items_single_products, user_id, send_to } from '../ga4-properties' // Change from unknown to the partner SDK types const action: BrowserActionDefinition = { @@ -21,15 +20,17 @@ const action: BrowserActionDefinition = { required: true }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'add_to_wishlist', { currency: payload.currency, value: payload.value, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/generated-types.ts index 284bf69d75..084dee203d 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The monetary value of the event. @@ -110,4 +111,8 @@ export interface Payload { user_properties?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/index.ts index 02d55fe142..37cfe3c65d 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/beginCheckout/index.ts @@ -2,8 +2,16 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { params, coupon, currency, value, items_multi_products, user_id, user_properties } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { + params, + coupon, + currency, + value, + items_multi_products, + user_id, + user_properties, + send_to +} from '../ga4-properties' const action: BrowserActionDefinition = { title: 'Begin Checkout', @@ -20,16 +28,18 @@ const action: BrowserActionDefinition = { }, value: value, params: params, - user_properties: user_properties + user_properties: user_properties, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'begin_checkout', { currency: payload.currency, value: payload.value, coupon: payload.coupon, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/generated-types.ts index 3aa8ae426e..fbab80b0b2 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/generated-types.ts @@ -25,4 +25,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/index.ts index d8428e46a0..d711da5953 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/customEvent/index.ts @@ -1,8 +1,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_id, user_properties, params } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_id, user_properties, params, send_to } from '../ga4-properties' const normalizeEventName = (name: string, lowercase: boolean | undefined): string => { name = name.trim() @@ -39,13 +38,18 @@ const action: BrowserActionDefinition = { }, user_id: { ...user_id }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) + perform: (gtag, { payload, settings }) => { const event_name = normalizeEventName(payload.name, payload.lowercase) - gtag('event', event_name, payload.params) + gtag('event', event_name, { + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', + ...payload.params + }) } } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/ga4-functions.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/ga4-functions.ts deleted file mode 100644 index 0525e99038..0000000000 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/ga4-functions.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function updateUser(userID: string | undefined, userProps: object | undefined, gtag: Function): void { - if (userID) { - gtag('set', { user_id: userID }) - } - if (userProps) { - gtag('set', { user_properties: userProps }) - } -} diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/ga4-properties.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/ga4-properties.ts index f5a542afd1..bdbd03d360 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/ga4-properties.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/ga4-properties.ts @@ -190,6 +190,7 @@ export const minimal_items: InputField = { description: 'The list of products purchased.', type: 'object', multiple: true, + additionalProperties: true, properties: { item_id: { label: 'Product ID', @@ -366,3 +367,10 @@ export const items_multi_products: InputField = { ] } } +export const send_to: InputField = { + label: 'Send To', + type: 'boolean', + default: true, + description: + 'If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag' +} diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/generated-types.ts index 2e7db72077..744738a197 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/generated-types.ts @@ -25,4 +25,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/index.ts index c73003aefb..c2302fa3b3 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/generateLead/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, user_id, currency, value } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, user_id, currency, value, send_to } from '../ga4-properties' const action: BrowserActionDefinition = { title: 'Generate Lead', @@ -16,14 +15,16 @@ const action: BrowserActionDefinition = { currency: currency, value: value, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'generate_lead', { currency: payload.currency, value: payload.value, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/generated-types.ts index 37712dfed4..3582face62 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/generated-types.ts @@ -26,9 +26,9 @@ export interface Settings { */ cookieFlags?: string[] /** - * Specifies the subpath used to store the analytics cookie. + * Specifies the subpath used to store the analytics cookie. We recommend to add a forward slash, / , in the first field as it is the Default Value for GA4. */ - cookiePath?: string[] + cookiePath?: string /** * Specifies a prefix to prepend to the analytics cookie name. */ @@ -49,6 +49,14 @@ export interface Settings { * The default value for analytics cookies consent state. This is only used if Enable Consent Mode is on. Set to “granted” if it is not explicitly set. Consent state can be updated for each user in the Set Configuration Fields action. */ defaultAnalyticsStorageConsentState?: string + /** + * Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on. + */ + adUserDataConsentState?: string + /** + * Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on. + */ + adPersonalizationConsentState?: string /** * If your CMP loads asynchronously, it might not always run before the Google tag. To handle such situations, specify a millisecond value to control how long to wait before the consent state update is sent. Please input the wait_for_update in milliseconds. */ diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/index.ts index 480e43e0db..22400aa327 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/index.ts @@ -35,7 +35,7 @@ type ConsentParamsArg = 'granted' | 'denied' | undefined const presets: DestinationDefinition['presets'] = [ { name: `Set Configuration Fields`, - subscribe: 'type = "page" or type = "identify"', + subscribe: 'type = "page"', partnerAction: 'setConfigurationFields', mapping: defaultValues(setConfigurationFields.fields), type: 'automatic' @@ -83,18 +83,20 @@ export const destination: BrowserDestinationDefinition = { description: `Appends additional flags to the analytics cookie. See [write a new cookie](https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#write_a_new_cookie) for some examples of flags to set.`, label: 'Cookie Flag', type: 'string', + default: undefined, multiple: true }, cookiePath: { - description: `Specifies the subpath used to store the analytics cookie.`, + description: `Specifies the subpath used to store the analytics cookie. We recommend to add a forward slash, / , in the first field as it is the Default Value for GA4.`, label: 'Cookie Path', type: 'string', - multiple: true + default: '/' }, cookiePrefix: { description: `Specifies a prefix to prepend to the analytics cookie name.`, label: 'Cookie Prefix', type: 'string', + default: undefined, multiple: true }, cookieUpdate: { @@ -131,6 +133,28 @@ export const destination: BrowserDestinationDefinition = { ], default: 'granted' }, + adUserDataConsentState: { + description: + 'Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on.', + label: 'Ad User Data Consent State', + type: 'string', + choices: [ + { label: 'Granted', value: 'granted' }, + { label: 'Denied', value: 'denied' } + ], + default: undefined + }, + adPersonalizationConsentState: { + description: + 'Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on.', + label: 'Ad Personalization Consent State', + type: 'string', + choices: [ + { label: 'Granted', value: 'granted' }, + { label: 'Denied', value: 'denied' } + ], + default: undefined + }, waitTimeToUpdateConsentStage: { description: 'If your CMP loads asynchronously, it might not always run before the Google tag. To handle such situations, specify a millisecond value to control how long to wait before the consent state update is sent. Please input the wait_for_update in milliseconds.', @@ -154,11 +178,24 @@ export const destination: BrowserDestinationDefinition = { window.gtag('js', new Date()) if (settings.enableConsentMode) { - window.gtag('consent', 'default', { + const consent: { + ad_storage: ConsentParamsArg + analytics_storage: ConsentParamsArg + wait_for_update: number | undefined + ad_user_data?: ConsentParamsArg + ad_personalization?: ConsentParamsArg + } = { ad_storage: settings.defaultAdsStorageConsentState as ConsentParamsArg, analytics_storage: settings.defaultAnalyticsStorageConsentState as ConsentParamsArg, wait_for_update: settings.waitTimeToUpdateConsentStage - }) + } + if (settings.adUserDataConsentState) { + consent.ad_user_data = settings.adUserDataConsentState as ConsentParamsArg + } + if (settings.adPersonalizationConsentState) { + consent.ad_personalization = settings.adPersonalizationConsentState as ConsentParamsArg + } + gtag('consent', 'default', consent) } const script = `https://www.googletagmanager.com/gtag/js?id=${settings.measurementID}` await deps.loadScript(script) diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/login/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/login/generated-types.ts index c305ce6764..e1e758672f 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/login/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/login/generated-types.ts @@ -21,4 +21,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/login/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/login/index.ts index 0b85ed3678..122cae19eb 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/login/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/login/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, user_id, method } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, user_id, method, send_to } from '../ga4-properties' // Change from unknown to the partner SDK types const action: BrowserActionDefinition = { @@ -15,14 +14,16 @@ const action: BrowserActionDefinition = { user_id: user_id, method: method, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'login', { method: payload.method, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/generated-types.ts index 498bba5c46..59e0cd9074 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The unique identifier of a transaction. @@ -122,4 +123,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/index.ts index b694450600..09a5ef4017 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/purchase/index.ts @@ -11,9 +11,9 @@ import { tax, items_multi_products, params, - user_properties + user_properties, + send_to } from '../ga4-properties' -import { updateUser } from '../ga4-functions' const action: BrowserActionDefinition = { title: 'Purchase', @@ -33,11 +33,10 @@ const action: BrowserActionDefinition = { tax: tax, value: { ...value, default: { '@path': '$.properties.total' } }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'purchase', { currency: payload.currency, transaction_id: payload.transaction_id, @@ -46,6 +45,9 @@ const action: BrowserActionDefinition = { tax: payload.tax, shipping: payload.shipping, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/generated-types.ts index e97d5a5bb2..e2a9e691e2 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/generated-types.ts @@ -113,6 +113,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -126,4 +127,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/index.ts index a3f2da2911..bfc5f0386b 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/refund/index.ts @@ -13,9 +13,9 @@ import { items_multi_products, params, user_properties, - tax + tax, + send_to } from '../ga4-properties' -import { updateUser } from '../ga4-functions' const action: BrowserActionDefinition = { title: 'Refund', @@ -35,11 +35,10 @@ const action: BrowserActionDefinition = { ...items_multi_products }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'refund', { currency: payload.currency, transaction_id: payload.transaction_id, // Transaction ID. Required for purchases and refunds. @@ -49,6 +48,9 @@ const action: BrowserActionDefinition = { shipping: payload.shipping, tax: payload.tax, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/generated-types.ts index a7f5a2e20e..b6d8985b27 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -106,4 +107,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/index.ts index 314e9791b5..b72832ff69 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/removeFromCart/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, value, user_id, currency, items_single_products } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, value, user_id, currency, items_single_products, send_to } from '../ga4-properties' // Change from unknown to the partner SDK types const action: BrowserActionDefinition = { @@ -20,15 +19,17 @@ const action: BrowserActionDefinition = { required: true }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'remove_from_cart', { currency: payload.currency, value: payload.value, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/search/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/search/generated-types.ts index 94b8ec5941..686241fd58 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/search/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/search/generated-types.ts @@ -21,4 +21,8 @@ export interface Payload { * The term that was searched for. */ search_term?: string + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/search/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/search/index.ts index caaa88163c..9a4ca89a71 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/search/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/search/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, user_id, search_term } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, user_id, search_term, send_to } from '../ga4-properties' // Change from unknown to the partner SDK types const action: BrowserActionDefinition = { @@ -15,13 +14,15 @@ const action: BrowserActionDefinition = { user_id: user_id, user_properties: user_properties, params: params, - search_term: search_term + search_term: search_term, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'search', { search_term: payload.search_term, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/generated-types.ts index 32d9891f1e..70b44615db 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -106,4 +107,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/index.ts index d6c7f30004..a64c762435 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectItem/index.ts @@ -8,9 +8,9 @@ import { user_id, items_single_products, item_list_name, - item_list_id + item_list_id, + send_to } from '../ga4-properties' -import { updateUser } from '../ga4-functions' const action: BrowserActionDefinition = { title: 'Select Item', @@ -26,15 +26,17 @@ const action: BrowserActionDefinition = { required: true }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'select_item', { item_list_id: payload.item_list_id, item_list_name: payload.item_list_name, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/generated-types.ts index a429b75c73..91725ada01 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/generated-types.ts @@ -121,6 +121,7 @@ export interface Payload { * The ID of the promotion associated with the event. */ promotion_id?: string + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -134,4 +135,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/index.ts index 9384484e34..28eee4036f 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/selectPromotion/index.ts @@ -12,10 +12,9 @@ import { items_single_products, params, user_properties, - location_id + location_id, + send_to } from '../ga4-properties' -import { updateUser } from '../ga4-functions' - const action: BrowserActionDefinition = { title: 'Select Promotion', description: 'This event signifies a promotion was selected from a list.', @@ -47,11 +46,10 @@ const action: BrowserActionDefinition = { } }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'select_promotion', { creative_name: payload.creative_name, creative_slot: payload.creative_slot, @@ -59,6 +57,9 @@ const action: BrowserActionDefinition = { promotion_id: payload.promotion_id, promotion_name: payload.promotion_name, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/generated-types.ts index bc813c4763..36d3f9f0a0 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/generated-types.ts @@ -19,6 +19,14 @@ export interface Payload { * Consent state indicated by the user for ad cookies. Value must be “granted” or “denied.” This is only used if the Enable Consent Mode setting is on. */ analytics_storage_consent_state?: string + /** + * Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on. + */ + ad_user_data_consent_state?: string + /** + * Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on. + */ + ad_personalization_consent_state?: string /** * Use campaign content to differentiate ads or links that point to the same URL. Setting this value will override the utm_content query parameter. */ @@ -67,4 +75,14 @@ export interface Payload { * The resolution of the screen. Format should be two positive integers separated by an x (i.e. 800x600). If not set, calculated from the user's window.screen value. */ screen_resolution?: string + /** + * Selection overrides toggled value set within Settings + */ + send_page_view?: boolean + /** + * The event parameters to send to Google Analytics 4. + */ + params?: { + [k: string]: unknown + } } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts index 92c2c93766..bfc4451ceb 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/setConfigurationFields/index.ts @@ -1,17 +1,18 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_id, user_properties } from '../ga4-properties' -import { updateUser } from '../ga4-functions' - +import { user_id, user_properties, params } from '../ga4-properties' type ConsentParamsArg = 'granted' | 'denied' | undefined +const defaultCookieExpiryInSecond = 63072000 +const defaultCookieDomain = 'auto' // Change from unknown to the partner SDK types const action: BrowserActionDefinition = { title: 'Set Configuration Fields', description: 'Set custom values for the GA4 configuration fields.', platform: 'web', - defaultSubscription: 'type = "identify" or type = "page"', + defaultSubscription: 'type = "page"', + lifecycleHook: 'before', fields: { user_id: user_id, user_properties: user_properties, @@ -27,6 +28,28 @@ const action: BrowserActionDefinition = { label: 'Analytics Storage Consent State', type: 'string' }, + ad_user_data_consent_state: { + description: + 'Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on.', + label: 'Ad User Data Consent State', + type: 'string', + choices: [ + { label: 'Granted', value: 'granted' }, + { label: 'Denied', value: 'denied' } + ], + default: undefined + }, + ad_personalization_consent_state: { + description: + 'Consent state indicated by the user for ad cookies. Value must be "granted" or "denied." This is only used if the Enable Consent Mode setting is on.', + label: 'Ad Personalization Consent State', + type: 'string', + choices: [ + { label: 'Granted', value: 'granted' }, + { label: 'Denied', value: 'denied' } + ], + default: undefined + }, campaign_content: { description: 'Use campaign content to differentiate ads or links that point to the same URL. Setting this value will override the utm_content query parameter.', @@ -92,27 +115,73 @@ const action: BrowserActionDefinition = { description: `The resolution of the screen. Format should be two positive integers separated by an x (i.e. 800x600). If not set, calculated from the user's window.screen value.`, label: 'Screen Resolution', type: 'string' - } + }, + send_page_view: { + description: 'Selection overrides toggled value set within Settings', + label: 'Send Page Views', + type: 'boolean', + choices: [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' } + ], + default: true + }, + params: params }, perform: (gtag, { payload, settings }) => { - updateUser(payload.user_id, payload.user_properties, gtag) + const checkCookiePathDefaultValue = + settings.cookiePath != undefined && settings.cookiePath?.length !== 1 && settings.cookiePath !== '/' + if (settings.enableConsentMode) { - window.gtag('consent', 'update', { - ad_storage: payload.ads_storage_consent_state as ConsentParamsArg, - analytics_storage: payload.analytics_storage_consent_state as ConsentParamsArg - }) + const consentParams: { + ad_storage?: ConsentParamsArg + analytics_storage?: ConsentParamsArg + ad_user_data?: ConsentParamsArg + ad_personalization?: ConsentParamsArg + } = {} + if (payload.ads_storage_consent_state) { + consentParams.ad_storage = payload.ads_storage_consent_state as ConsentParamsArg + } + if (payload.analytics_storage_consent_state) { + consentParams.analytics_storage = payload.analytics_storage_consent_state as ConsentParamsArg + } + if (payload.ad_user_data_consent_state) { + consentParams.ad_user_data = payload.ad_user_data_consent_state as ConsentParamsArg + } + if (payload.ad_personalization_consent_state) { + consentParams.ad_personalization = payload.ad_personalization_consent_state as ConsentParamsArg + } + gtag('consent', 'update', consentParams) } type ConfigType = { [key: string]: unknown } const config: ConfigType = { - send_page_view: settings.pageView ?? true, - cookie_update: settings.cookieUpdate, - cookie_domain: settings.cookieDomain, - cookie_prefix: settings.cookiePrefix, - cookie_expires: settings.cookieExpirationInSeconds, - cookie_path: settings.cookiePath, allow_ad_personalization_signals: settings.allowAdPersonalizationSignals, - allow_google_signals: settings.allowGoogleSignals + allow_google_signals: settings.allowGoogleSignals, + ...payload.params + } + + if (settings.cookieUpdate != true) { + config.cookie_update = false + } + if (settings.cookieDomain != defaultCookieDomain) { + config.cookie_domain = settings.cookieDomain + } + if (settings.cookiePrefix) { + config.cookie_prefix = settings.cookiePrefix + } + if (settings.cookieExpirationInSeconds != defaultCookieExpiryInSecond) { + config.cookie_expires = settings.cookieExpirationInSeconds + } + if (checkCookiePathDefaultValue) { + config.cookie_path = settings.cookiePath + } + + if (payload.send_page_view != true || settings.pageView != true) { + config.send_page_view = payload.send_page_view ?? settings.pageView ?? true + } + if (settings.cookieFlags) { + config.cookie_flags = settings.cookieFlags } if (payload.screen_resolution) { @@ -157,6 +226,7 @@ const action: BrowserActionDefinition = { if (payload.campaign_content) { config.campaign_content = payload.campaign_content } + gtag('config', settings.measurementID, config) } } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/generated-types.ts index c305ce6764..e1e758672f 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/generated-types.ts @@ -21,4 +21,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/index.ts index eefae9248f..6e777976b0 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/signUp/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, user_id, method } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, user_id, method, send_to } from '../ga4-properties' const action: BrowserActionDefinition = { title: 'Sign Up', @@ -14,13 +13,15 @@ const action: BrowserActionDefinition = { user_id: user_id, method: method, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'sign_up', { method: payload.method, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/generated-types.ts index a7f5a2e20e..b6d8985b27 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -106,4 +107,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/index.ts index 5419eda24b..63c366cf30 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewCart/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, currency, value, user_id, items_multi_products } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, currency, value, user_id, items_multi_products, send_to } from '../ga4-properties' const action: BrowserActionDefinition = { title: 'View Cart', @@ -19,15 +18,17 @@ const action: BrowserActionDefinition = { required: true }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'view_cart', { currency: payload.currency, value: payload.value, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/generated-types.ts index a7f5a2e20e..b6d8985b27 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -106,4 +107,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/index.ts index 7bdc4bb6c3..1a23e19801 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItem/index.ts @@ -2,8 +2,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, currency, user_id, value, items_single_products } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { user_properties, params, currency, user_id, value, items_single_products, send_to } from '../ga4-properties' const action: BrowserActionDefinition = { title: 'View Item', @@ -20,15 +19,17 @@ const action: BrowserActionDefinition = { required: true }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'view_item', { currency: payload.currency, value: payload.value, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/generated-types.ts index ad463886d4..c92981ba95 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/generated-types.ts @@ -93,6 +93,7 @@ export interface Payload { * Item quantity. */ quantity?: number + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -106,4 +107,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/index.ts index 6874780bb8..4e994d5110 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewItemList/index.ts @@ -2,8 +2,15 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runti import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_properties, params, user_id, items_multi_products, item_list_name, item_list_id } from '../ga4-properties' -import { updateUser } from '../ga4-functions' +import { + user_properties, + params, + user_id, + items_multi_products, + item_list_name, + item_list_id, + send_to +} from '../ga4-properties' const action: BrowserActionDefinition = { title: 'View Item List', @@ -19,15 +26,17 @@ const action: BrowserActionDefinition = { required: true }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'view_item_list', { item_list_id: payload.item_list_id, item_list_name: payload.item_list_name, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/generated-types.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/generated-types.ts index a08bd958e6..826cba82d2 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/generated-types.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/generated-types.ts @@ -121,6 +121,7 @@ export interface Payload { * The ID of the promotion associated with the event. */ promotion_id?: string + [k: string]: unknown }[] /** * The user properties to send to Google Analytics 4. You must create user-scoped dimensions to ensure custom properties are picked up by Google. See Google’s [Custom user properties](https://support.google.com/analytics/answer/9269570) to learn how to set and register user properties. @@ -134,4 +135,8 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * If the send_to parameter is not set, events are routed to all Tag Ids (AW-xxx, G-xxx) set via Google Tag + */ + send_to?: boolean } diff --git a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/index.ts b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/index.ts index 7d1d49c4fb..539d564fd5 100644 --- a/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/index.ts +++ b/packages/browser-destinations/destinations/google-analytics-4-web/src/viewPromotion/index.ts @@ -11,9 +11,9 @@ import { items_single_products, params, user_properties, - location_id + location_id, + send_to } from '../ga4-properties' -import { updateUser } from '../ga4-functions' const action: BrowserActionDefinition = { title: 'View Promotion', @@ -47,11 +47,10 @@ const action: BrowserActionDefinition = { } }, user_properties: user_properties, - params: params + params: params, + send_to: send_to }, - perform: (gtag, { payload }) => { - updateUser(payload.user_id, payload.user_properties, gtag) - + perform: (gtag, { payload, settings }) => { gtag('event', 'view_promotion', { creative_name: payload.creative_name, creative_slot: payload.creative_slot, @@ -59,6 +58,9 @@ const action: BrowserActionDefinition = { promotion_id: payload.promotion_id, promotion_name: payload.promotion_name, items: payload.items, + user_id: payload.user_id ?? undefined, + user_properties: payload.user_properties, + send_to: payload.send_to == true ? settings.measurementID : 'default', ...payload.params }) } diff --git a/packages/browser-destinations/destinations/google-campaign-manager/README.md b/packages/browser-destinations/destinations/google-campaign-manager/README.md index 35e5069d7d..b6cd3877f9 100644 --- a/packages/browser-destinations/destinations/google-campaign-manager/README.md +++ b/packages/browser-destinations/destinations/google-campaign-manager/README.md @@ -6,7 +6,7 @@ The Google Campaign Manager browser action destination for use with @segment/ana MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/browser-destinations/destinations/google-campaign-manager/package.json b/packages/browser-destinations/destinations/google-campaign-manager/package.json index fb15e83df2..6701b66dd4 100644 --- a/packages/browser-destinations/destinations/google-campaign-manager/package.json +++ b/packages/browser-destinations/destinations/google-campaign-manager/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-google-campaign-manager", - "version": "1.5.0", + "version": "1.24.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,7 +15,7 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/heap/package.json b/packages/browser-destinations/destinations/heap/package.json index b0a57ed43d..9ced226fc1 100644 --- a/packages/browser-destinations/destinations/heap/package.json +++ b/packages/browser-destinations/destinations/heap/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-heap", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/heap/src/trackEvent/__tests__/index.test.ts b/packages/browser-destinations/destinations/heap/src/trackEvent/__tests__/index.test.ts index f680a5c2d9..84726972ad 100644 --- a/packages/browser-destinations/destinations/heap/src/trackEvent/__tests__/index.test.ts +++ b/packages/browser-destinations/destinations/heap/src/trackEvent/__tests__/index.test.ts @@ -42,76 +42,58 @@ describe('#trackEvent', () => { type: 'track', name: 'hello!', properties: { - banana: '📞', - apple: [ + products: [ { - carrot: 12, - broccoli: [ - { - onion: 'crisp', - tomato: 'fruit' + name: 'Test Product 1', + properties: { + color: 'red', + qty: 2, + custom_vars: { + position: 0, + something_else: 'test', + another_one: ['one', 'two', 'three'] } - ] + } }, { - carrot: 21, - broccoli: [ - { - tomato: 'vegetable' - }, - { - tomato: 'fruit' - }, - [ - { - pickle: 'vinegar' - }, - { - pie: 3.1415 - } - ] - ] + name: 'Test Product 2', + properties: { + color: 'blue', + qty: 1, + custom_vars: { + position: 1, + something_else: 'blah', + another_one: ['four', 'five', 'six'] + } + } } - ], - emptyArray: [], - float: 1.2345, - booleanTrue: true, - booleanFalse: false, - nullValue: null, - undefinedValue: undefined + ] } }) ) expect(heapTrackSpy).toHaveBeenCalledTimes(3) - expect(heapTrackSpy).toHaveBeenNthCalledWith(1, 'hello! apple item', { - carrot: 12, - 'broccoli.0.onion': 'crisp', - 'broccoli.0.tomato': 'fruit', + expect(heapTrackSpy).toHaveBeenNthCalledWith(1, 'hello! products item', { + name: 'Test Product 1', + color: 'red', + qty: '2', + 'custom_vars.position': '0', + 'custom_vars.something_else': 'test', + 'custom_vars.another_one': '["one","two","three"]', segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME }) - expect(heapTrackSpy).toHaveBeenNthCalledWith(2, 'hello! apple item', { - carrot: 21, - 'broccoli.0.tomato': 'vegetable', - 'broccoli.1.tomato': 'fruit', - 'broccoli.2.0.pickle': 'vinegar', - 'broccoli.2.1.pie': '3.1415', + expect(heapTrackSpy).toHaveBeenNthCalledWith(2, 'hello! products item', { + name: 'Test Product 2', + color: 'blue', + qty: '1', + 'custom_vars.position': '1', + 'custom_vars.something_else': 'blah', + 'custom_vars.another_one': '["four","five","six"]', segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME }) expect(heapTrackSpy).toHaveBeenNthCalledWith(3, 'hello!', { - banana: '📞', - float: 1.2345, - booleanTrue: true, - booleanFalse: false, - nullValue: null, - segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME, - 'apple.0.broccoli.0.onion': 'crisp', - 'apple.0.broccoli.0.tomato': 'fruit', - 'apple.0.carrot': '12', - 'apple.1.broccoli.0.tomato': 'vegetable', - 'apple.1.broccoli.1.tomato': 'fruit', - 'apple.1.broccoli.2.0.pickle': 'vinegar', - 'apple.1.broccoli.2.1.pie': '3.1415', - 'apple.1.carrot': '21' + products: + '[{"name":"Test Product 1","properties":{"color":"red","qty":2,"custom_vars":{"position":0,"something_else":"test","another_one":["one","two","three"]}}},{"name":"Test Product 2","properties":{"color":"blue","qty":1,"custom_vars":{"position":1,"something_else":"blah","another_one":["four","five","six"]}}}]', + segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME }) expect(addUserPropertiesSpy).toHaveBeenCalledTimes(0) expect(identifySpy).toHaveBeenCalledTimes(0) @@ -144,12 +126,8 @@ describe('#trackEvent', () => { } expect(heapTrackSpy).toHaveBeenNthCalledWith(6, 'hello!', { segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME, - 'testArray1.0.val': '1', - 'testArray1.1.val': '2', - 'testArray1.2.val': '3', - 'testArray2.0.val': '4', - 'testArray2.1.val': '5', - 'testArray2.2.val': 'N/A' + testArray1: '[{"val":1},{"val":2},{"val":3}]', + testArray2: '[{"val":4},{"val":5},{"val":"N/A"}]' }) }) @@ -167,12 +145,62 @@ describe('#trackEvent', () => { expect(heapTrackSpy).toHaveBeenCalledTimes(1) expect(heapTrackSpy).toHaveBeenCalledWith('hello!', { - testArray1: [{ val: 1 }, { val: 2 }, { val: 3 }], - testArray2: [{ val: 4 }, { val: 5 }, { val: 'N/A' }], + testArray1: '[{"val":1},{"val":2},{"val":3}]', + testArray2: '[{"val":4},{"val":5},{"val":"N/A"}]', segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME }) }) + it('should stringify array', async () => { + await event.track?.( + new Context({ + type: 'track', + name: 'hello!', + properties: { + testArray1: ['test', 'testing', 'tester'] + } + }) + ) + expect(heapTrackSpy).toHaveBeenCalledTimes(1) + + expect(heapTrackSpy).toHaveBeenCalledWith('hello!', { + testArray1: '["test","testing","tester"]', + segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME + }) + }) + + it('should flatten properties', async () => { + await event.track?.( + new Context({ + type: 'track', + name: 'hello!', + properties: { + isAutomated: true, + isClickable: true, + custom_vars: { + bodyText: 'Testing text', + ctaText: 'Click me', + position: 0, + testNestedValues: { + count: 5, + color: 'green' + } + } + } + }) + ) + expect(heapTrackSpy).toHaveBeenCalledWith('hello!', { + segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME, + isAutomated: true, + isClickable: true, + bodyText: 'Testing text', + ctaText: 'Click me', + position: '0', + 'testNestedValues.count': '5', + 'testNestedValues.color': 'green' + }) + }) + it('should send segment_library property if no other properties were provided', async () => { await event.track?.( new Context({ @@ -238,4 +266,92 @@ describe('#trackEvent', () => { }) expect(identifySpy).toHaveBeenCalledTimes(0) }) + + describe('data tests', () => { + it('should unroll and flatten', async () => { + await eventWithUnrolling.track?.( + new Context({ + type: 'track', + name: 'Product List Viewed', + properties: { + membership_status: 'lead', + products: [ + { + sku: 'PT2252152-0001-00', + url: '/products/THE-ONE-JOGGER-PT2252152-0001-2', + variant: 'Black', + vip_price: 59.95, + membership_brand_id: 1, + quantity: 1 + }, + { + sku: 'PT2252152-4846-00', + url: '/products/THE-ONE-JOGGER-PT2252152-4846', + variant: 'Deep Navy', + vip_price: 59.95, + membership_brand_id: 1, + quantity: 1 + }, + { + sku: 'PT2458220-0001-00', + url: '/products/THE-YEAR-ROUND-TERRY-JOGGER-PT2458220-0001', + variant: 'Black', + vip_price: 59.95, + membership_brand_id: 1, + quantity: 1 + } + ], + store_group_id: '16', + session_id: '14322962105', + user_status_initial: 'lead', + utm_campaign: null, + utm_medium: null, + utm_source: null, + customer_id: '864832720' + } + }) + ) + expect(heapTrackSpy).toHaveBeenCalledTimes(4) + expect(heapTrackSpy).toHaveBeenNthCalledWith(1, 'Product List Viewed products item', { + sku: 'PT2252152-0001-00', + url: '/products/THE-ONE-JOGGER-PT2252152-0001-2', + variant: 'Black', + vip_price: 59.95, + membership_brand_id: 1, + quantity: 1, + segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME + }) + expect(heapTrackSpy).toHaveBeenNthCalledWith(2, 'Product List Viewed products item', { + sku: 'PT2252152-4846-00', + url: '/products/THE-ONE-JOGGER-PT2252152-4846', + variant: 'Deep Navy', + vip_price: 59.95, + membership_brand_id: 1, + quantity: 1, + segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME + }) + expect(heapTrackSpy).toHaveBeenNthCalledWith(3, 'Product List Viewed products item', { + sku: 'PT2458220-0001-00', + url: '/products/THE-YEAR-ROUND-TERRY-JOGGER-PT2458220-0001', + variant: 'Black', + vip_price: 59.95, + membership_brand_id: 1, + quantity: 1, + segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME + }) + expect(heapTrackSpy).toHaveBeenNthCalledWith(4, 'Product List Viewed', { + membership_status: 'lead', + products: + '[{"sku":"PT2252152-0001-00","url":"/products/THE-ONE-JOGGER-PT2252152-0001-2","variant":"Black","vip_price":59.95,"membership_brand_id":1,"quantity":1},{"sku":"PT2252152-4846-00","url":"/products/THE-ONE-JOGGER-PT2252152-4846","variant":"Deep Navy","vip_price":59.95,"membership_brand_id":1,"quantity":1},{"sku":"PT2458220-0001-00","url":"/products/THE-YEAR-ROUND-TERRY-JOGGER-PT2458220-0001","variant":"Black","vip_price":59.95,"membership_brand_id":1,"quantity":1}]', + store_group_id: '16', + session_id: '14322962105', + user_status_initial: 'lead', + utm_campaign: null, + utm_medium: null, + utm_source: null, + customer_id: '864832720', + segment_library: HEAP_SEGMENT_BROWSER_LIBRARY_NAME + }) + }) + }) }) diff --git a/packages/browser-destinations/destinations/heap/src/trackEvent/index.ts b/packages/browser-destinations/destinations/heap/src/trackEvent/index.ts index 5096e82d08..a81255bed1 100644 --- a/packages/browser-destinations/destinations/heap/src/trackEvent/index.ts +++ b/packages/browser-destinations/destinations/heap/src/trackEvent/index.ts @@ -78,6 +78,8 @@ const action: BrowserActionDefinition = { if (browserArrayLimitSet) { eventProperties = heapTrackArrays(heap, eventName, eventProperties, browserArrayLimit) + } else { + eventProperties = flattenProperties(eventProperties) } heapTrack(heap, eventName, eventProperties) diff --git a/packages/browser-destinations/destinations/heap/src/utils.ts b/packages/browser-destinations/destinations/heap/src/utils.ts index f19a1c039b..6b021fb841 100644 --- a/packages/browser-destinations/destinations/heap/src/utils.ts +++ b/packages/browser-destinations/destinations/heap/src/utils.ts @@ -19,12 +19,14 @@ export function flat(data?: Properties, prefix = ''): FlattenProperties | undefi } let result: FlattenProperties = {} for (const key in data) { - if (typeof data[key] == 'object' && data[key] !== null) { + if (typeof data[key] == 'object' && data[key] !== null && !Array.isArray(data[key])) { const flatten = flat(data[key] as Properties, prefix + '.' + key) result = { ...result, ...flatten } } else { const stringifiedValue = stringify(data[key]) - result[(prefix + '.' + key).replace(/^\./, '')] = stringifiedValue + // replaces the first . or .word. + const identifier = (prefix + '.' + key).replace(/^\.(\w+\.)?/, '') + result[identifier] = stringifiedValue } } return result diff --git a/packages/browser-destinations/destinations/hubble-web/README.md b/packages/browser-destinations/destinations/hubble-web/README.md new file mode 100644 index 0000000000..b4884b277e --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-hubble-web + +The Hubble (actions) browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2024 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/hubble-web/package.json b/packages/browser-destinations/destinations/hubble-web/package.json new file mode 100644 index 0000000000..ba70f9c598 --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/package.json @@ -0,0 +1,24 @@ +{ + "name": "@segment/analytics-browser-hubble-web", + "version": "1.20.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/hubble-web/src/__tests__/__snapshots__/index.test.ts.snap b/packages/browser-destinations/destinations/hubble-web/src/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000..ec0642738e --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Hubble load Hubble SDK: + + 1`] = ` +NodeList [ + , +] +`; diff --git a/packages/browser-destinations/destinations/hubble-web/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/hubble-web/src/__tests__/index.test.ts new file mode 100644 index 0000000000..ebd9421846 --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/__tests__/index.test.ts @@ -0,0 +1,101 @@ +import { Subscription } from '@segment/browser-destination-runtime/types' +import { Analytics, Context } from '@segment/analytics-next' +import hubbleDestination, { destination } from '../index' + +const subscriptions: Subscription[] = [ + { + name: 'Identify user', + subscribe: 'type = "identify"', + partnerAction: 'identify', + enabled: true, + mapping: { + userId: { + type: 'string', + required: true, + label: 'User ID', + description: 'Unique user ID', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + type: 'string', + required: false, + description: 'Anonymous id of the user', + label: 'Anonymous ID', + default: { + '@path': '$.anonymousId' + } + }, + attributes: { + type: 'object', + required: false, + description: 'User traits used to enrich user identification', + label: 'Traits', + default: { + '@path': '$.traits' + } + } + } + }, + { + name: 'Track event', + subscribe: 'type = "track"', + partnerAction: 'track', + enabled: true, + mapping: { + event: { + description: 'Event to be tracked', + label: 'Event', + required: true, + type: 'string', + default: { + '@path': '$.event' + } + }, + attributes: { + description: 'Object containing the attributes (properties) of the event', + type: 'object', + required: false, + label: 'Event Attributes', + default: { + '@path': '$.properties' + } + } + } + } +] + +describe('Hubble', () => { + beforeAll(() => { + jest.mock('@segment/browser-destination-runtime/load-script', () => ({ + loadScript: (_src: any, _attributes: any) => {} + })) + jest.mock('@segment/browser-destination-runtime/resolve-when', () => ({ + resolveWhen: (_fn: any, _timeout: any) => {} + })) + }) + + const testID = 'testId' + + test('load Hubble SDK', async () => { + const [event] = await hubbleDestination({ + id: testID, + subscriptions + }) + + jest.spyOn(destination, 'initialize') + await event.load(Context.system(), {} as Analytics) + expect(destination.initialize).toHaveBeenCalled() + + const scripts = window.document.querySelectorAll('script') + expect(scripts).toMatchSnapshot(` + + `) + }) +}) diff --git a/packages/browser-destinations/destinations/hubble-web/src/generated-types.ts b/packages/browser-destinations/destinations/hubble-web/src/generated-types.ts new file mode 100644 index 0000000000..eef1f71582 --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Unique identifier for your team (given in Hubble app) + */ + id: string +} diff --git a/packages/browser-destinations/destinations/hubble-web/src/identify/__tests__/index.test.ts b/packages/browser-destinations/destinations/hubble-web/src/identify/__tests__/index.test.ts new file mode 100644 index 0000000000..f7cfa89e93 --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/identify/__tests__/index.test.ts @@ -0,0 +1,92 @@ +import { Analytics, Context } from '@segment/analytics-next' +import hubbleDestination, { destination } from '../../index' +import { Subscription } from '@segment/browser-destination-runtime/types' + +const subscriptions: Subscription[] = [ + { + partnerAction: 'identify', + name: 'Identify User', + enabled: true, + subscribe: 'type = "identify"', + mapping: { + anonymousId: { + '@path': '$.anonymousId' + }, + userId: { + '@path': '$.userId' + }, + attributes: { + '@path': '$.traits' + } + } + } +] + +describe('identify', () => { + beforeAll(() => { + jest.mock('@segment/browser-destination-runtime/load-script', () => ({ + loadScript: (_src: any, _attributes: any) => {} + })) + jest.mock('@segment/browser-destination-runtime/resolve-when', () => ({ + resolveWhen: (_fn: any, _timeout: any) => {} + })) + }) + + let identify: any + const mockIdentify: jest.Mock = jest.fn() + + beforeEach(async () => { + const [hubbleIdentify] = await hubbleDestination({ + id: 'testID', + subscriptions + }) + + identify = hubbleIdentify + + jest.spyOn(destination, 'initialize').mockImplementation(() => { + const mockedWithTrack = { + id: 'testID', + initialized: true, + emitter: { setSource: jest.fn() }, + track: mockIdentify, + identify: jest.fn(), + setSource: jest.fn() + } + return Promise.resolve(mockedWithTrack) + }) + await identify.load(Context.system(), {} as Analytics) + }) + + test('it maps event parameters correctly to identify function ', async () => { + jest.spyOn(destination.actions.identify, 'perform') + await identify.load(Context.system(), {} as Analytics) + + await identify.identify?.( + new Context({ + type: 'identify', + anonymousId: 'anon-123', + userId: 'some-user-123', + traits: { + someNumber: 123, + hello: 'world', + email: 'this_email@hubble.team' + } + }) + ) + + expect(destination.actions.identify.perform).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: { + anonymousId: 'anon-123', + userId: 'some-user-123', + attributes: { + someNumber: 123, + hello: 'world', + email: 'this_email@hubble.team' + } + } + }) + ) + }) +}) diff --git a/packages/browser-destinations/destinations/hubble-web/src/identify/generated-types.ts b/packages/browser-destinations/destinations/hubble-web/src/identify/generated-types.ts new file mode 100644 index 0000000000..a3a0d93825 --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/identify/generated-types.ts @@ -0,0 +1,18 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique identifer of the user + */ + userId?: string + /** + * Anonymous identifier of the user + */ + anonymousId?: string + /** + * User traits used to enrich user identification + */ + attributes?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/hubble-web/src/identify/index.ts b/packages/browser-destinations/destinations/hubble-web/src/identify/index.ts new file mode 100644 index 0000000000..8122a46a17 --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/identify/index.ts @@ -0,0 +1,49 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import { Hubble } from '../types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +// Change from unknown to the partner SDK types +const action: BrowserActionDefinition = { + title: 'Identify', + description: 'Set identifiers and attributes for a user', + platform: 'web', + defaultSubscription: 'type = "identify"', + fields: { + userId: { + description: 'Unique identifer of the user', + type: 'string', + required: false, + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + description: 'Anonymous identifier of the user', + type: 'string', + required: false, + label: 'Anonymous ID', + default: { + '@path': '$.anonymousId' + } + }, + attributes: { + description: 'User traits used to enrich user identification', + type: 'object', + required: false, + label: 'User Attributes (Traits)', + default: { + '@path': '$.traits' + } + } + }, + perform: (hubble, event) => { + const payload = event.payload + + hubble.identify && + hubble.identify({ userId: payload.userId, anonymousId: payload.anonymousId, attributes: payload.attributes }) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/hubble-web/src/index.ts b/packages/browser-destinations/destinations/hubble-web/src/index.ts new file mode 100644 index 0000000000..adef7740f4 --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/index.ts @@ -0,0 +1,64 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' + +import { Hubble } from './types' +import { defaultValues } from '@segment/actions-core' + +import track from './track' +import identify from './identify' + +declare global { + interface Window { + Hubble: Hubble + } +} + +export const destination: BrowserDestinationDefinition = { + name: 'Hubble (actions)', + slug: 'hubble-web', + mode: 'device', + description: + 'From design to production, monitor, measure and enhance your user experience with seamless integration with Segment', + presets: [ + { + name: 'Identify user', + subscribe: 'type = "identify"', + partnerAction: 'identify', + mapping: defaultValues(identify.fields), + type: 'automatic' + }, + { + name: 'Track event', + subscribe: 'type = "track"', + partnerAction: 'track', + mapping: defaultValues(track.fields), + type: 'automatic' + } + ], + + settings: { + id: { + description: 'Unique identifier for your team (given in Hubble app)', + label: 'id', + type: 'string', + required: true + } + }, + + initialize: async ({ settings }, deps) => { + await deps.loadScript(`https://sdk.hubble.team/api/sdk/${settings.id}`) + await deps.resolveWhen(() => window?.Hubble?.initialized, 250) + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + window?.Hubble?.setSource('__segment__') + return window.Hubble + }, + + actions: { + track, + identify + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/hubble-web/src/track/__tests__/index.test.ts b/packages/browser-destinations/destinations/hubble-web/src/track/__tests__/index.test.ts new file mode 100644 index 0000000000..b6f29b62ec --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/track/__tests__/index.test.ts @@ -0,0 +1,84 @@ +import { Analytics, Context } from '@segment/analytics-next' +import hubbleDestination, { destination } from '../../index' +import { Subscription } from '@segment/browser-destination-runtime/types' + +const subscriptions: Subscription[] = [ + { + partnerAction: 'track', + name: 'Track event', + enabled: true, + subscribe: 'type = "track"', + mapping: { + event: { + '@path': '$.event' + }, + attributes: { + '@path': '$.properties' + } + } + } +] + +describe('track', () => { + beforeAll(() => { + jest.mock('@segment/browser-destination-runtime/load-script', () => ({ + loadScript: (_src: any, _attributes: any) => {} + })) + jest.mock('@segment/browser-destination-runtime/resolve-when', () => ({ + resolveWhen: (_fn: any, _timeout: any) => {} + })) + }) + + let track: any + const mockTrack: jest.Mock = jest.fn() + + beforeEach(async () => { + const [hubbleTrack] = await hubbleDestination({ + id: 'testID', + subscriptions + }) + + track = hubbleTrack + + jest.spyOn(destination, 'initialize').mockImplementation(() => { + const mockedWithTrack = { + id: 'testID', + initialized: true, + emitter: { setSource: jest.fn() }, + track: mockTrack, + identify: jest.fn(), + setSource: jest.fn() + } + return Promise.resolve(mockedWithTrack) + }) + await track.load(Context.system(), {} as Analytics) + }) + + test('it maps event parameters correctly to track function', async () => { + jest.spyOn(destination.actions.track, 'perform') + + await track.track?.( + new Context({ + type: 'track', + event: 'event-test', + properties: { + prop1: 'something', + prop2: 'another-thing' + } + }) + ) + + expect(destination.actions.track.perform).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + payload: { + event: 'event-test', + attributes: { + prop1: 'something', + prop2: 'another-thing' + } + } + }) + ) + }) +}) diff --git a/packages/browser-destinations/destinations/hubble-web/src/track/generated-types.ts b/packages/browser-destinations/destinations/hubble-web/src/track/generated-types.ts new file mode 100644 index 0000000000..95b0d403de --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/track/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Event to be tracked + */ + event: string + /** + * Object containing the attributes (properties) of the event + */ + attributes?: { + [k: string]: unknown + } + /** + * Unique identifer of the user + */ + userId?: string + /** + * Anonymous identifier of the user + */ + anonymousId?: string +} diff --git a/packages/browser-destinations/destinations/hubble-web/src/track/index.ts b/packages/browser-destinations/destinations/hubble-web/src/track/index.ts new file mode 100644 index 0000000000..ffbf3fad2b --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/track/index.ts @@ -0,0 +1,63 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import { Hubble } from '../types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +// Change from unknown to the partner SDK types +const action: BrowserActionDefinition = { + title: 'Track', + description: 'Track events to trigger Hubble surveys', + platform: 'web', + defaultSubscription: 'type = "track"', + fields: { + event: { + description: 'Event to be tracked', + label: 'Event', + required: true, + type: 'string', + default: { + '@path': '$.event' + } + }, + attributes: { + description: 'Object containing the attributes (properties) of the event', + type: 'object', + required: false, + label: 'Event Attributes', + default: { + '@path': '$.properties' + } + }, + userId: { + description: 'Unique identifer of the user', + type: 'string', + required: false, + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + description: 'Anonymous identifier of the user', + type: 'string', + required: false, + label: 'Anonymous ID', + default: { + '@path': '$.anonymousId' + } + } + }, + perform: (hubble, event) => { + const payload = event.payload + + hubble.track && + hubble.track({ + event: payload.event, + attributes: payload.attributes, + userId: payload.userId, + anonymousId: payload.anonymousId + }) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/hubble-web/src/types.ts b/packages/browser-destinations/destinations/hubble-web/src/types.ts new file mode 100644 index 0000000000..5e172e079b --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/src/types.ts @@ -0,0 +1,10 @@ +export type Methods = { + track?: (...args: unknown[]) => unknown + identify?: (...args: unknown[]) => unknown +} + +export type Hubble = { + id: string + initialized: boolean + setSource: (source: string) => void +} & Methods diff --git a/packages/browser-destinations/destinations/hubble-web/tsconfig.json b/packages/browser-destinations/destinations/hubble-web/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/hubble-web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/hubspot-web/package.json b/packages/browser-destinations/destinations/hubspot-web/package.json index c8342cebb0..59948eb9fc 100644 --- a/packages/browser-destinations/destinations/hubspot-web/package.json +++ b/packages/browser-destinations/destinations/hubspot-web/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-hubspot", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/intercom/package.json b/packages/browser-destinations/destinations/intercom/package.json index b8420e4ff9..bd6574d77c 100644 --- a/packages/browser-destinations/destinations/intercom/package.json +++ b/packages/browser-destinations/destinations/intercom/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-intercom", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,9 +15,9 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/actions-shared": "^1.65.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/actions-shared": "^1.84.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/iterate/package.json b/packages/browser-destinations/destinations/iterate/package.json index fd2254f2dd..06970065a9 100644 --- a/packages/browser-destinations/destinations/iterate/package.json +++ b/packages/browser-destinations/destinations/iterate/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-iterate", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/jimo/README.md b/packages/browser-destinations/destinations/jimo/README.md new file mode 100644 index 0000000000..be894a1ed3 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-actions-jimo + +The Jimo browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2024 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/jimo/package.json b/packages/browser-destinations/destinations/jimo/package.json new file mode 100644 index 0000000000..fc162ed2c4 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/package.json @@ -0,0 +1,23 @@ +{ + "name": "@segment/analytics-browser-actions-jimo", + "version": "1.22.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/jimo/src/generated-types.ts b/packages/browser-destinations/destinations/jimo/src/generated-types.ts new file mode 100644 index 0000000000..89ab4c93fd --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Id of the Jimo project. You can find the Project Id here: https://i.usejimo.com/settings/install/portal + */ + projectId: string +} diff --git a/packages/browser-destinations/destinations/jimo/src/index.ts b/packages/browser-destinations/destinations/jimo/src/index.ts new file mode 100644 index 0000000000..fd06c0c8d5 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/index.ts @@ -0,0 +1,66 @@ +import { defaultValues } from '@segment/actions-core' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from './generated-types' +import { initScript } from './init-script' +import sendTrackEvent from './sendTrackEvent' +import sendUserData from './sendUserData' +import { JimoSDK } from './types' + +declare global { + interface Window { + jimo: JimoSDK | never[] + JIMO_PROJECT_ID: string + JIMO_MANUAL_INIT: boolean + } +} + +const ENDPOINT_UNDERCITY = 'https://undercity.usejimo.com/jimo-invader.js' + +export const destination: BrowserDestinationDefinition = { + name: 'Jimo (Actions)', + slug: 'actions-jimo', + mode: 'device', + description: 'Load Jimo SDK and send user profile data to Jimo', + + settings: { + projectId: { + description: + 'Id of the Jimo project. You can find the Project Id here: https://i.usejimo.com/settings/install/portal', + label: 'Id', + type: 'string', + required: true + } + }, + presets: [ + { + name: 'Send User Data', + subscribe: 'type = "identify"', + partnerAction: 'sendUserData', + mapping: defaultValues(sendUserData.fields), + type: 'automatic' + }, + { + name: 'Send Track Event', + subscribe: 'type = "track"', + partnerAction: 'sendTrackEvent', + mapping: defaultValues(sendTrackEvent.fields), + type: 'automatic' + } + ], + initialize: async ({ settings }, deps) => { + initScript(settings) + + await deps.loadScript(`${ENDPOINT_UNDERCITY}`) + + await deps.resolveWhen(() => Array.isArray(window.jimo) === false, 100) + + return window.jimo as JimoSDK + }, + actions: { + sendUserData, + sendTrackEvent + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/jimo/src/init-script.ts b/packages/browser-destinations/destinations/jimo/src/init-script.ts new file mode 100644 index 0000000000..a93d29eed8 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/init-script.ts @@ -0,0 +1,11 @@ +import { Settings } from './generated-types' +/* eslint-disable */ +// @ts-nocheck +export function initScript(settings: Settings) { + if (window.jimo) { + return + } + + window.jimo = [] + window['JIMO_PROJECT_ID'] = settings.projectId +} diff --git a/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/__tests__/index.test.ts b/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..7af2ff272b --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/__tests__/index.test.ts @@ -0,0 +1,51 @@ +import { Analytics, Context } from '@segment/analytics-next' +import sendTrackEvent from '..' +import { JimoSDK } from '../../types' +import { Payload } from '../generated-types' + +describe('Jimo - Send Track Event', () => { + test('do:segmentio:track is called', async () => { + const client = { + push: jest.fn() + } as any as JimoSDK + + const context = new Context({ + type: 'track' + }) + + await sendTrackEvent.perform(client as any as JimoSDK, { + settings: { projectId: 'unk' }, + analytics: jest.fn() as any as Analytics, + context: context, + payload: { + messageId: '42', + timestamp: 'timestamp-as-iso-string', + userId: 'u1', + anonymousId: 'a1', + event_name: 'foo', + properties: { + foo: 'bar' + } + } as Payload + }) + + expect(client.push).toHaveBeenCalled() + expect(client.push).toHaveBeenCalledWith([ + 'do', + 'segmentio:track', + [ + { + event: 'foo', + messageId: '42', + timestamp: 'timestamp-as-iso-string', + receivedAt: 'timestamp-as-iso-string', + userId: 'u1', + anonymousId: 'a1', + properties: { + foo: 'bar' + } + } + ] + ]) + }) +}) diff --git a/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/generated-types.ts b/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/generated-types.ts new file mode 100644 index 0000000000..b3f2dddd97 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/generated-types.ts @@ -0,0 +1,30 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The internal id of the message. + */ + messageId: string + /** + * The timestamp of the event. + */ + timestamp: string + /** + * The name of the event. + */ + event_name: string + /** + * A unique identifier for the user. + */ + userId?: string + /** + * An anonymous identifier for the user. + */ + anonymousId?: string + /** + * Information associated with the event + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/index.ts b/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/index.ts new file mode 100644 index 0000000000..64cf089125 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/sendTrackEvent/index.ts @@ -0,0 +1,80 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import { JimoSDK } from 'src/types' +import type { Settings } from '../generated-types' +import { Payload } from './generated-types' + +const action: BrowserActionDefinition = { + title: 'Send Track Event', + description: 'Submit an event to Jimo', + defaultSubscription: 'type = "track"', + platform: 'web', + fields: { + messageId: { + description: 'The internal id of the message.', + label: 'Message Id', + type: 'string', + required: true, + default: { + '@path': '$.messageId' + } + }, + timestamp: { + description: 'The timestamp of the event.', + label: 'Timestamp', + type: 'string', + required: true, + default: { + '@path': '$.timestamp' + } + }, + event_name: { + description: 'The name of the event.', + label: 'Event Name', + type: 'string', + required: true, + default: { + '@path': '$.event' + } + }, + userId: { + description: 'A unique identifier for the user.', + label: 'User ID', + type: 'string', + required: false, + default: { + '@path': '$.userId' + } + }, + anonymousId: { + description: 'An anonymous identifier for the user.', + label: 'Anonymous ID', + type: 'string', + required: false, + default: { + '@path': '$.anonymousId' + } + }, + properties: { + description: 'Information associated with the event', + label: 'Event Properties', + type: 'object', + required: false, + default: { + '@path': '$.properties' + } + } + }, + perform: (jimo, { payload }) => { + const { event_name, userId, anonymousId, timestamp, messageId, properties } = payload + const receivedAt = timestamp + + jimo.push([ + 'do', + 'segmentio:track', + [{ event: event_name, userId, anonymousId, messageId, timestamp, receivedAt, properties }] + ]) + window.dispatchEvent(new CustomEvent(`jimo-segmentio-track:${event_name}`)) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/jimo/src/sendUserData/__tests__/index.test.ts b/packages/browser-destinations/destinations/jimo/src/sendUserData/__tests__/index.test.ts new file mode 100644 index 0000000000..0e5d5c95ff --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/sendUserData/__tests__/index.test.ts @@ -0,0 +1,87 @@ +import { Analytics, Context } from '@segment/analytics-next' + +import sendUserData from '..' +import { JimoSDK } from '../../types' +import { Payload } from '../generated-types' + +describe('Jimo - Send User Data', () => { + test('user id', async () => { + const client = { + push: jest.fn() + } as any as JimoSDK + + const context = new Context({ + type: 'identify' + }) + + await sendUserData.perform(client as any as JimoSDK, { + settings: { projectId: 'unk' }, + analytics: jest.fn() as any as Analytics, + context: context, + payload: { + userId: 'u1' + } as Payload + }) + + expect(client.push).toHaveBeenCalled() + expect(client.push).toHaveBeenCalledWith(['set', 'user:id', ['u1']]) + }) + test('user email', async () => { + const client = { + push: jest.fn() + } as any as JimoSDK + + const context = new Context({ + type: 'identify' + }) + + await sendUserData.perform(client as any as JimoSDK, { + settings: { projectId: 'unk' }, + analytics: jest.fn() as any as Analytics, + context: context, + payload: { + email: 'foo@bar.com' + } as Payload + }) + + expect(client.push).toHaveBeenCalled() + expect(client.push).toHaveBeenCalledWith(['set', 'user:email', ['foo@bar.com']]) + }) + test('user traits', async () => { + const client = { + push: jest.fn() + } as any as JimoSDK + + const context = new Context({ + type: 'identify' + }) + + await sendUserData.perform(client as any as JimoSDK, { + settings: { projectId: 'unk' }, + analytics: jest.fn() as any as Analytics, + context: context, + payload: { + traits: { + trait1: true, + trait2: 'foo', + trait3: 1 + } + } as Payload + }) + + expect(client.push).toHaveBeenCalled() + expect(client.push).toHaveBeenCalledWith([ + 'set', + 'user:attributes', + [ + { + trait1: true, + trait2: 'foo', + trait3: 1 + }, + false, + true + ] + ]) + }) +}) diff --git a/packages/browser-destinations/destinations/jimo/src/sendUserData/generated-types.ts b/packages/browser-destinations/destinations/jimo/src/sendUserData/generated-types.ts new file mode 100644 index 0000000000..3105bc1871 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/sendUserData/generated-types.ts @@ -0,0 +1,18 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The unique user identifier + */ + userId?: string | null + /** + * The email of the user + */ + email?: string | null + /** + * A list of attributes coming from segment traits + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/jimo/src/sendUserData/index.ts b/packages/browser-destinations/destinations/jimo/src/sendUserData/index.ts new file mode 100644 index 0000000000..a6ee2c1a75 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/sendUserData/index.ts @@ -0,0 +1,57 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import { JimoSDK } from 'src/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: BrowserActionDefinition = { + title: 'Send User Data', + description: 'Send user ID and email to Jimo', + platform: 'web', + fields: { + userId: { + label: 'User ID', + description: 'The unique user identifier', + type: 'string', + allowNull: true, + required: false, + default: { + '@path': '$.userId' + } + }, + email: { + label: 'User email', + description: 'The email of the user', + type: 'string', + allowNull: true, + required: false, + default: { + '@path': '$.traits.email' + } + }, + traits: { + label: 'User Traits', + description: 'A list of attributes coming from segment traits', + type: 'object', + default: { + '@path': '$.traits' + } + } + }, + defaultSubscription: 'type = "identify"', + perform: (jimo, { payload }) => { + if (payload.userId != null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + jimo.push(['set', 'user:id', [payload.userId]]) + } + if (payload.email != null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + jimo.push(['set', 'user:email', [payload.email]]) + } + if (payload.traits != null) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + jimo.push(['set', 'user:attributes', [payload.traits, false, true]]) + } + } +} + +export default action diff --git a/packages/browser-destinations/destinations/jimo/src/types.ts b/packages/browser-destinations/destinations/jimo/src/types.ts new file mode 100644 index 0000000000..0bb96075b5 --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/src/types.ts @@ -0,0 +1,3 @@ +export interface JimoSDK { + push: (params: Array) => Promise +} diff --git a/packages/browser-destinations/destinations/jimo/tsconfig.json b/packages/browser-destinations/destinations/jimo/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/jimo/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/koala/package.json b/packages/browser-destinations/destinations/koala/package.json index 30ea2eaf20..8a0b40f8d0 100644 --- a/packages/browser-destinations/destinations/koala/package.json +++ b/packages/browser-destinations/destinations/koala/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-koala", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/koala/src/index.ts b/packages/browser-destinations/destinations/koala/src/index.ts index 1d164cb2a6..c991ac6db1 100644 --- a/packages/browser-destinations/destinations/koala/src/index.ts +++ b/packages/browser-destinations/destinations/koala/src/index.ts @@ -30,7 +30,7 @@ export const destination: BrowserDestinationDefinition = { initialize: async ({ settings, analytics }, deps) => { initScript() - await deps.loadScript(`https://cdn.koala.live/v1/${settings.project_slug}/umd.js`) + await deps.loadScript(`https://cdn.getkoala.com/v1/${settings.project_slug}/umd.js`) const ko = await window.KoalaSDK.load({ project: settings.project_slug, diff --git a/packages/browser-destinations/destinations/logrocket/package.json b/packages/browser-destinations/destinations/logrocket/package.json index 72b47c5061..b93ed7feb2 100644 --- a/packages/browser-destinations/destinations/logrocket/package.json +++ b/packages/browser-destinations/destinations/logrocket/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-logrocket", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0", + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0", "logrocket": "^3.0.1" }, "peerDependencies": { diff --git a/packages/browser-destinations/destinations/pendo-web-actions/README.md b/packages/browser-destinations/destinations/pendo-web-actions/README.md index dae01eb768..8856d5cb30 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/README.md +++ b/packages/browser-destinations/destinations/pendo-web-actions/README.md @@ -6,7 +6,7 @@ The Pendo Web (actions) browser action destination for use with @segment/analyti MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/browser-destinations/destinations/pendo-web-actions/package.json b/packages/browser-destinations/destinations/pendo-web-actions/package.json index 2a5bca0b2a..b82267250c 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/package.json +++ b/packages/browser-destinations/destinations/pendo-web-actions/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-pendo-web-actions", - "version": "1.3.0", + "version": "1.23.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,7 +15,7 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/generated-types.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/generated-types.ts index 5ba99e076d..4743b78a59 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/generated-types.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/generated-types.ts @@ -6,19 +6,19 @@ export interface Settings { */ apiKey: string /** - * Segment can set the Pendo Account ID upon page load. This can be overridden via the Account ID field in the Send Identify/Group Actions + * The region for your Pendo subscription. */ - accountId?: string + region: string /** - * Segment can set the Pendo Parent Account ID upon page load. This can be overridden via the Parent Account ID field in the Send Identify/Group Actions. Note: Contact Pendo to request enablement of Parent Account feature. + * If you are using Pendo's CNAME feature, this will update your Pendo install snippet with your content host. */ - parentAccountId?: string + cnameContentHost?: string /** - * The Pendo Region you'd like to send data to + * Override sending Segment's user traits on load. This will prevent Pendo from initializing with the user traits from Segment (analytics.user().traits()). Allowing you to adjust the mapping of visitor metadata in Segment's identify event. */ - region: string + disableUserTraitsOnLoad?: boolean /** - * Segment can set the Pendo Visitor ID upon page load to either the Segment userId or anonymousId. This can be overridden via the Visitor ID field in the Send Identify/Group Actions + * Override sending Segment's group id for Pendo's account id. This will prevent Pendo from initializing with the group id from Segment (analytics.group().id()). Allowing you to adjust the mapping of account id in Segment's group event. */ - setVisitorIdOnLoad: string + disableGroupIdAndTraitsOnLoad?: boolean } diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/group/__tests__/index.test.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/group/__tests__/index.test.ts index 988a9cd282..aed5c01894 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/group/__tests__/index.test.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/group/__tests__/index.test.ts @@ -18,6 +18,9 @@ const subscriptions: Subscription[] = [ }, accountData: { '@path': '$.traits' + }, + parentAccountData: { + '@path': '$.traits.parentAccount' } } } @@ -46,7 +49,8 @@ describe('Pendo.group', () => { initialize: jest.fn(), isReady: jest.fn(), track: jest.fn(), - identify: jest.fn() + identify: jest.fn(), + flushNow: jest.fn() } return Promise.resolve(mockPendo) }) @@ -69,4 +73,25 @@ describe('Pendo.group', () => { visitor: { id: 'testUserId' } }) }) + + test('parentAccountData is being deduped from accountData correctly', async () => { + const context = new Context({ + type: 'group', + userId: 'testUserId', + traits: { + company_name: 'Megacorp 2000', + parentAccount: { + id: 'some_id' + } + }, + groupId: 'company_id_1' + }) + await groupAction.group?.(context) + + expect(mockPendo.identify).toHaveBeenCalledWith({ + account: { id: 'company_id_1', company_name: 'Megacorp 2000' }, + visitor: { id: 'testUserId' }, + parentAccount: { id: 'some_id' } + }) + }) }) diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/group/generated-types.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/group/generated-types.ts index 508cd67b55..cd82232d85 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/group/generated-types.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/group/generated-types.ts @@ -2,11 +2,11 @@ export interface Payload { /** - * Pendo Visitor ID. Defaults to Segment userId + * Pendo Visitor ID. Maps to Segment userId */ visitorId: string /** - * Pendo Account ID. This overrides the Pendo Account ID setting + * Pendo Account ID. Maps to Segment groupId. Note: If you plan to change this, enable the setting "Use custom Segment group trait for Pendo account id" */ accountId: string /** @@ -15,14 +15,11 @@ export interface Payload { accountData?: { [k: string]: unknown } - /** - * Pendo Parent Account ID. This overrides the Pendo Parent Account ID setting. Note: Contact Pendo to request enablement of Parent Account feature. - */ - parentAccountId?: string /** * Additional Parent Account data to send. Note: Contact Pendo to request enablement of Parent Account feature. */ parentAccountData?: { + id: string [k: string]: unknown } } diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/group/index.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/group/index.ts index cae05fca82..900dbb44cd 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/group/index.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/group/index.ts @@ -1,7 +1,8 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import type { PendoSDK, identifyPayload } from '../types' +import type { PendoSDK, PendoOptions } from '../types' +import { removeNestedObject, AnyObject, getSubstringDifference } from '../utils' const action: BrowserActionDefinition = { title: 'Send Group Event', @@ -11,60 +12,82 @@ const action: BrowserActionDefinition = { fields: { visitorId: { label: 'Visitor ID', - description: 'Pendo Visitor ID. Defaults to Segment userId', + description: 'Pendo Visitor ID. Maps to Segment userId', type: 'string', required: true, default: { '@path': '$.userId' - } + }, + readOnly: true }, accountId: { label: 'Account ID', - description: 'Pendo Account ID. This overrides the Pendo Account ID setting', + description: + 'Pendo Account ID. Maps to Segment groupId. Note: If you plan to change this, enable the setting "Use custom Segment group trait for Pendo account id"', type: 'string', required: true, - default: { '@path': '$.groupId' } + default: { '@path': '$.groupId' }, + readOnly: false }, accountData: { label: 'Account Metadata', description: 'Additional Account data to send', type: 'object', - required: false - }, - parentAccountId: { - label: 'Parent Account ID', - description: - 'Pendo Parent Account ID. This overrides the Pendo Parent Account ID setting. Note: Contact Pendo to request enablement of Parent Account feature.', - type: 'string', - required: false + required: false, + default: { '@path': '$.traits' }, + readOnly: false }, parentAccountData: { label: 'Parent Account Metadata', description: 'Additional Parent Account data to send. Note: Contact Pendo to request enablement of Parent Account feature.', type: 'object', + properties: { + id: { + label: 'Parent Account ID', + type: 'string', + required: true + } + }, + additionalProperties: true, + default: { '@path': '$.traits.parentAccount' }, required: false } }, - perform: (pendo, event) => { - const payload: identifyPayload = { - visitor: { - id: event.payload.visitorId - } + perform: (pendo, { mapping, payload }) => { + // remove parentAccountData field data from the accountData if the paths overlap + + type pathMapping = { + '@path': string } - if (event.payload.accountId || event.settings.accountId) { - payload.account = { - id: event.payload.accountId ?? (event.settings.accountId as string), - ...event.payload.accountData - } + + const parentAccountDataMapping = mapping && (mapping.parentAccountData as pathMapping)?.['@path'] + const accountDataMapping = mapping && (mapping.accountData as pathMapping)?.['@path'] + + const difference: string | null = getSubstringDifference(parentAccountDataMapping, accountDataMapping) + + let accountData = undefined + if (difference !== null) { + accountData = removeNestedObject(payload.accountData as AnyObject, difference) + } else { + accountData = payload.accountData } - if (event.payload.parentAccountId || event.settings.parentAccountId) { - payload.parentAccount = { - id: (event.payload.parentAccountId as string) ?? (event.settings.parentAccountId as string), - ...event.payload.parentAccountData + + const pendoPayload: PendoOptions = { + visitor: { + id: payload.visitorId + }, + account: { + ...accountData, + id: payload.accountId } } - pendo.identify(payload) + + if (payload.parentAccountData) { + pendoPayload.parentAccount = payload.parentAccountData + } + + pendo.identify(pendoPayload) } } diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/identify/__tests__/index.test.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/identify/__tests__/index.test.ts index 7ab72648c7..b7fb53b742 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/identify/__tests__/index.test.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/identify/__tests__/index.test.ts @@ -15,9 +15,6 @@ const subscriptions: Subscription[] = [ }, visitorData: { '@path': '$.traits' - }, - accountId: { - '@path': '$.context.group_id' } } } @@ -26,7 +23,6 @@ const subscriptions: Subscription[] = [ describe('Pendo.identify', () => { const settings = { apiKey: 'abc123', - setVisitorIdOnLoad: 'disabled', region: 'io' } @@ -46,7 +42,8 @@ describe('Pendo.identify', () => { initialize: jest.fn(), isReady: jest.fn(), track: jest.fn(), - identify: jest.fn() + identify: jest.fn(), + flushNow: jest.fn() } return Promise.resolve(mockPendo) }) @@ -59,15 +56,11 @@ describe('Pendo.identify', () => { userId: 'testUserId', traits: { first_name: 'Jimbo' - }, - context: { - group_id: 'company_id_1' } }) await identifyAction.identify?.(context) expect(mockPendo.identify).toHaveBeenCalledWith({ - account: { id: 'company_id_1' }, visitor: { first_name: 'Jimbo', id: 'testUserId' } }) }) diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/identify/generated-types.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/identify/generated-types.ts index 1e87e6bc18..0be6efbb47 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/identify/generated-types.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/identify/generated-types.ts @@ -2,7 +2,7 @@ export interface Payload { /** - * Pendo Visitor ID. Defaults to Segment userId + * Pendo Visitor ID. Maps to Segment userId */ visitorId: string /** @@ -11,24 +11,4 @@ export interface Payload { visitorData?: { [k: string]: unknown } - /** - * Pendo Account ID. This overrides the Pendo Account ID setting - */ - accountId?: string - /** - * Additional Account data to send - */ - accountData?: { - [k: string]: unknown - } - /** - * Pendo Parent Account ID. This overrides the Pendo Parent Account ID setting. Note: Contact Pendo to request enablement of Parent Account feature. - */ - parentAccountId?: string - /** - * Additional Parent Account data to send. Note: Contact Pendo to request enablement of Parent Account feature. - */ - parentAccountData?: { - [k: string]: unknown - } } diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/identify/index.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/identify/index.ts index 7af0b7c945..221cac0c84 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/identify/index.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/identify/index.ts @@ -1,7 +1,7 @@ import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import type { PendoSDK, identifyPayload } from '../types' +import type { PendoSDK, PendoOptions } from '../types' const action: BrowserActionDefinition = { title: 'Send Identify Event', @@ -11,12 +11,13 @@ const action: BrowserActionDefinition = { fields: { visitorId: { label: 'Visitor ID', - description: 'Pendo Visitor ID. Defaults to Segment userId', + description: 'Pendo Visitor ID. Maps to Segment userId', type: 'string', required: true, default: { '@path': '$.userId' - } + }, + readOnly: true }, visitorData: { label: 'Visitor Metadata', @@ -24,61 +25,18 @@ const action: BrowserActionDefinition = { type: 'object', default: { '@path': '$.traits' - } - }, - accountId: { - label: 'Account ID', - description: 'Pendo Account ID. This overrides the Pendo Account ID setting', - type: 'string', - required: false, - default: { - '@if': { - exists: { '@path': '$.context.group_id' }, - then: { '@path': '$.context.group_id' }, - else: { '@path': '$.groupId' } - } - } - }, - accountData: { - label: 'Account Metadata', - description: 'Additional Account data to send', - type: 'object', - required: false - }, - parentAccountId: { - label: 'Parent Account ID', - description: - 'Pendo Parent Account ID. This overrides the Pendo Parent Account ID setting. Note: Contact Pendo to request enablement of Parent Account feature.', - type: 'string', - required: false - }, - parentAccountData: { - label: 'Parent Account Metadata', - description: - 'Additional Parent Account data to send. Note: Contact Pendo to request enablement of Parent Account feature.', - type: 'object', - required: false + }, + readOnly: false } }, perform: (pendo, event) => { - const payload: identifyPayload = { + const payload: PendoOptions = { visitor: { - id: event.payload.visitorId, - ...event.payload.visitorData - } - } - if (event.payload.accountId || event.settings.accountId) { - payload.account = { - id: (event.payload.accountId as string) ?? (event.settings.accountId as string), - ...event.payload.accountData - } - } - if (event.payload.parentAccountId || event.settings.parentAccountId) { - payload.parentAccount = { - id: (event.payload.parentAccountId as string) ?? (event.settings.parentAccountId as string), - ...event.payload.parentAccountData + ...event.payload.visitorData, + id: event.payload.visitorId } } + pendo.identify(payload) } } diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/index.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/index.ts index 124869a876..79e902ebbb 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/index.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/index.ts @@ -2,7 +2,9 @@ import type { Settings } from './generated-types' import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' import { browserDestination } from '@segment/browser-destination-runtime/shim' import { loadPendo } from './loadScript' -import { InitializeData, PendoSDK } from './types' +import { PendoOptions, PendoSDK } from './types' +import { ID } from '@segment/analytics-next' +import { defaultValues } from '@segment/actions-core' import identify from './identify' import track from './track' @@ -28,90 +30,84 @@ export const destination: BrowserDestinationDefinition = { type: 'string', required: true }, - accountId: { - label: 'Set Pendo Account ID on Load', - description: - 'Segment can set the Pendo Account ID upon page load. This can be overridden via the Account ID field in the Send Identify/Group Actions', - type: 'string', - required: false - }, - parentAccountId: { - label: 'Set Pendo Parent Account ID on Load', - description: - 'Segment can set the Pendo Parent Account ID upon page load. This can be overridden via the Parent Account ID field in the Send Identify/Group Actions. Note: Contact Pendo to request enablement of Parent Account feature.', - type: 'string', - required: false - }, region: { label: 'Region', type: 'string', - description: "The Pendo Region you'd like to send data to", + description: 'The region for your Pendo subscription.', required: true, - default: 'io', + default: 'https://cdn.pendo.io', choices: [ - { value: 'io', label: 'io' }, - { value: 'eu', label: 'eu' } + { value: 'https://cdn.pendo.io', label: 'US (default)' }, + { value: 'https://cdn.eu.pendo.io', label: 'EU' }, + { value: 'https://us1.cdn.pendo.io', label: 'US restricted' }, + { value: 'https://cdn.jpn.pendo.io', label: 'Japan' } ] }, - setVisitorIdOnLoad: { - label: 'Set Vistor ID on Load', + cnameContentHost: { + label: 'Optional CNAME content host', description: - 'Segment can set the Pendo Visitor ID upon page load to either the Segment userId or anonymousId. This can be overridden via the Visitor ID field in the Send Identify/Group Actions', + "If you are using Pendo's CNAME feature, this will update your Pendo install snippet with your content host.", type: 'string', - default: 'disabled', - choices: [ - { value: 'disabled', label: 'Do not set Visitor ID on load' }, - { value: 'userIdOnly', label: 'Set Visitor ID to userId on load' }, - { value: 'userIdOrAnonymousId', label: 'Set Visitor ID to userId or anonymousId on load' }, - { value: 'anonymousIdOnly', label: 'Set Visitor ID to anonymousId on load' } - ], - required: true + required: false + }, + disableUserTraitsOnLoad: { + label: "Disable passing Segment's user traits to Pendo on start up", + description: + "Override sending Segment's user traits on load. This will prevent Pendo from initializing with the user traits from Segment (analytics.user().traits()). Allowing you to adjust the mapping of visitor metadata in Segment's identify event.", + type: 'boolean', + required: false, + default: false + }, + disableGroupIdAndTraitsOnLoad: { + label: "Disable passing Segment's group id and group traits to Pendo on start up", + description: + "Override sending Segment's group id for Pendo's account id. This will prevent Pendo from initializing with the group id from Segment (analytics.group().id()). Allowing you to adjust the mapping of account id in Segment's group event.", + type: 'boolean', + required: false, + default: false } }, initialize: async ({ settings, analytics }, deps) => { - loadPendo(settings.apiKey, settings.region) - - await deps.resolveWhen(() => window.pendo != null, 100) + if (settings.cnameContentHost && !/^https?:/.exec(settings.cnameContentHost) && settings.cnameContentHost.length) { + settings.cnameContentHost = 'https://' + settings.cnameContentHost + } - const initialData: InitializeData = {} + loadPendo(settings.apiKey, settings.region, settings.cnameContentHost) - if (settings.setVisitorIdOnLoad) { - let vistorId: string | null = null + await deps.resolveWhen(() => window.pendo != null, 100) - switch (settings.setVisitorIdOnLoad) { - case 'disabled': - vistorId = null - break - case 'userIdOnly': - vistorId = analytics.user().id() ?? null - break - case 'userIdOrAnonymousId': - vistorId = analytics.user().id() ?? analytics.user().anonymousId() ?? null - break - case 'anonymousIdOnly': - vistorId = analytics.user().anonymousId() ?? null - break - } + let visitorId: ID = null + let accountId: ID = null - if (vistorId) { - initialData.visitor = { - id: vistorId - } - } + if (analytics.user().id()) { + visitorId = analytics.user().id() + } else if (analytics.user().anonymousId()) { + // Append Pendo anonymous visitor tag + // https://github.com/segmentio/analytics.js-integrations/blob/master/integrations/pendo/lib/index.js#L114 + visitorId = '_PENDO_T_' + analytics.user().anonymousId() } - if (settings.accountId) { - initialData.account = { - id: settings.accountId - } + + if (analytics.group().id()) { + accountId = analytics.group().id() } - if (settings.parentAccountId) { - initialData.parentAccount = { - id: settings.parentAccountId - } + + const options: PendoOptions = { + visitor: { + ...(!settings.disableUserTraitsOnLoad ? analytics.user().traits() : {}), + id: visitorId + }, + ...(accountId && !settings.disableGroupIdAndTraitsOnLoad + ? { + account: { + ...analytics.group().traits(), + id: accountId + } + } + : {}) } - window.pendo.initialize(initialData) + window.pendo.initialize(options) return window.pendo }, @@ -119,7 +115,30 @@ export const destination: BrowserDestinationDefinition = { track, identify, group - } + }, + presets: [ + { + name: 'Send Track Event', + subscribe: 'type = "track"', + partnerAction: 'track', + mapping: defaultValues(track.fields), + type: 'automatic' + }, + { + name: 'Send Identify Event', + subscribe: 'type = "identify"', + partnerAction: 'identify', + mapping: defaultValues(identify.fields), + type: 'automatic' + }, + { + name: 'Send Group Event', + subscribe: 'type = "group"', + partnerAction: 'group', + mapping: defaultValues(group.fields), + type: 'automatic' + } + ] } export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/loadScript.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/loadScript.ts index 21a14f7fba..97ca3820a7 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/loadScript.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/loadScript.ts @@ -1,6 +1,6 @@ /* eslint-disable */ // @ts-nocheck -export function loadPendo(apiKey, region) { +export function loadPendo(apiKey, region, cnameContentHost) { ;(function (p, e, n, d, o) { var v, w, x, y, z o = p[d] = p[d] || {} @@ -16,7 +16,7 @@ export function loadPendo(apiKey, region) { })(v[w]) y = e.createElement(n) y.async = !0 - y.src = `https://cdn.pendo.${region}/agent/static/` + apiKey + '/pendo.js' + y.src = `${cnameContentHost || region}/agent/static/${apiKey}/pendo.js` z = e.getElementsByTagName(n)[0] z.parentNode.insertBefore(y, z) })(window, document, 'script', 'pendo') diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/track/index.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/track/index.ts index bafe6bd850..2aed3ed065 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/track/index.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/track/index.ts @@ -3,7 +3,6 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import type { PendoSDK } from '../types' -// Change from unknown to the partner SDK types const action: BrowserActionDefinition = { title: 'Send Track Event', description: 'Send Segment track() events to Pendo', @@ -17,7 +16,8 @@ const action: BrowserActionDefinition = { required: true, default: { '@path': '$.event' - } + }, + readOnly: false }, metadata: { label: 'Metadata', @@ -25,12 +25,12 @@ const action: BrowserActionDefinition = { type: 'object', default: { '@path': '$.properties' - } + }, + readOnly: false } }, perform: (pendo, { payload }) => { pendo.track(payload.event, payload.metadata) - pendo.flushNow(true) } } diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/types.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/types.ts index d4408805b2..3d9ed6dc91 100644 --- a/packages/browser-destinations/destinations/pendo-web-actions/src/types.ts +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/types.ts @@ -1,27 +1,25 @@ +import { ID } from '@segment/analytics-next' + export type Visitor = { - id?: string | null | undefined + id: ID + [propName: string]: unknown } export type Account = { - id?: string | null | undefined + id: ID + [propName: string]: unknown } -export type InitializeData = { +export type PendoOptions = { visitor?: Visitor account?: Account parentAccount?: Account } -export type identifyPayload = { - visitor: { [key: string]: string } - account?: { [key: string]: string } - parentAccount?: { [key: string]: string } -} - export type PendoSDK = { - initialize: ({ visitor, account }: InitializeData) => void + initialize: ({ visitor, account }: PendoOptions) => void track: (eventName: string, metadata?: { [key: string]: unknown }) => void - identify: (data: identifyPayload) => void + identify: (data: PendoOptions) => void flushNow: (force: boolean) => void isReady: () => boolean } diff --git a/packages/browser-destinations/destinations/pendo-web-actions/src/utils.ts b/packages/browser-destinations/destinations/pendo-web-actions/src/utils.ts new file mode 100644 index 0000000000..5832639ebc --- /dev/null +++ b/packages/browser-destinations/destinations/pendo-web-actions/src/utils.ts @@ -0,0 +1,42 @@ +export interface AnyObject { + [key: string]: AnyObject | undefined +} + +export const removeNestedObject = function (obj: AnyObject, path: string): AnyObject { + const pathArray = path.split('.').filter(Boolean) + + const newObj: AnyObject = { ...obj } // Create a new object to avoid mutating the original + + let currentObj: AnyObject | undefined = newObj + + for (let i = 0; i < pathArray.length; i++) { + const key = pathArray[i] + + if ( + typeof currentObj === 'object' && + currentObj !== null && + Object.prototype.hasOwnProperty.call(currentObj, key) + ) { + if (i == pathArray.length - 1) { + delete currentObj[key] + } else { + currentObj[key] = { ...currentObj[key] } as AnyObject // Create a new object for nested properties + currentObj = currentObj[key] + } + } else { + return newObj + } + } + return newObj +} + +export const getSubstringDifference = ( + str1: string | undefined | null, + str2: string | undefined | null +): string | null => { + if (str1 === undefined || str1 === null || str2 === undefined || str2 === null) { + return null + } + + return str1.startsWith(str2) ? str1.substring(str2.length) : null +} diff --git a/packages/browser-destinations/destinations/playerzero-web/package.json b/packages/browser-destinations/destinations/playerzero-web/package.json index e9f55a9ebc..422b2cda18 100644 --- a/packages/browser-destinations/destinations/playerzero-web/package.json +++ b/packages/browser-destinations/destinations/playerzero-web/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-playerzero", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/replaybird/README.md b/packages/browser-destinations/destinations/replaybird/README.md new file mode 100644 index 0000000000..b89dd05b88 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-actions-replaybird + +The Replaybird browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2024 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/replaybird/package.json b/packages/browser-destinations/destinations/replaybird/package.json new file mode 100644 index 0000000000..8ab4073d03 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/package.json @@ -0,0 +1,23 @@ +{ + "name": "@segment/analytics-browser-actions-replaybird", + "version": "1.15.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/replaybird/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/replaybird/src/__tests__/index.test.ts new file mode 100644 index 0000000000..687767ddeb --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/__tests__/index.test.ts @@ -0,0 +1,21 @@ +import { Analytics, Context } from '@segment/analytics-next' +import replaybird, { destination } from '../index' +import { subscriptions, REPLAYBIRD_API_KEY, mockReplaybirdJsHttpRequest, createMockedReplaybirdJsSdk } from '../utils' + +describe('Replaybird', () => { + test('Load replaybird cdn script file', async () => { + jest.spyOn(destination, 'initialize') + + mockReplaybirdJsHttpRequest() + window.replaybird = createMockedReplaybirdJsSdk() + + const [event] = await replaybird({ + apiKey: REPLAYBIRD_API_KEY, + subscriptions: subscriptions + }) + + await event.load(Context.system(), {} as Analytics) + expect(destination.initialize).toHaveBeenCalled() + expect(window.replaybird.apiKey).toEqual(REPLAYBIRD_API_KEY) + }) +}) diff --git a/packages/browser-destinations/destinations/replaybird/src/generated-types.ts b/packages/browser-destinations/destinations/replaybird/src/generated-types.ts new file mode 100644 index 0000000000..8e9f33bd98 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The api key for replaybird + */ + apiKey: string +} diff --git a/packages/browser-destinations/destinations/replaybird/src/identifyUser/__tests__/index.test.ts b/packages/browser-destinations/destinations/replaybird/src/identifyUser/__tests__/index.test.ts new file mode 100644 index 0000000000..a43a0d6d39 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/identifyUser/__tests__/index.test.ts @@ -0,0 +1,66 @@ +import { Analytics, Context } from '@segment/analytics-next' +import replaybird, { destination } from '../../index' +import { + identifySubscription, + REPLAYBIRD_API_KEY, + createMockedReplaybirdJsSdk, + mockReplaybirdJsHttpRequest +} from '../../utils' + +describe('replaybird.identify', () => { + it('should not call identify if user id is not provided and anonymous user id is provided', async () => { + mockReplaybirdJsHttpRequest() + window.replaybird = createMockedReplaybirdJsSdk() + + const [identifyUser] = await replaybird({ + apiKey: REPLAYBIRD_API_KEY, + subscriptions: [identifySubscription] + }) + + await identifyUser.load(Context.system(), {} as Analytics) + const identifySpy = jest.spyOn(window.replaybird, 'identify') + + const traits = { + name: 'Mathew', + email: 'user@example.com' + } + + await identifyUser.identify?.( + new Context({ + type: 'identify', + traits + }) + ) + + expect(identifySpy).not.toHaveBeenCalled() + }) + + it('should call identify if user id is provided', async () => { + mockReplaybirdJsHttpRequest() + window.replaybird = createMockedReplaybirdJsSdk() + + const [identifyUser] = await replaybird({ + apiKey: REPLAYBIRD_API_KEY, + subscriptions: [identifySubscription] + }) + + jest.spyOn(destination.actions.identifyUser, 'perform') + await identifyUser.load(Context.system(), {} as Analytics) + const identifySpy = jest.spyOn(window.replaybird, 'identify') + + const userId = 'user_123' + const traits = { + name: 'Mathew', + email: 'user@example.com' + } + + await identifyUser.identify?.( + new Context({ + type: 'identify', + traits, + userId + }) + ) + expect(identifySpy).toHaveBeenCalledWith(userId, traits) + }) +}) diff --git a/packages/browser-destinations/destinations/replaybird/src/identifyUser/generated-types.ts b/packages/browser-destinations/destinations/replaybird/src/identifyUser/generated-types.ts new file mode 100644 index 0000000000..4b5078481c --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/identifyUser/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * A unique ID for a known user + */ + userId: string + /** + * The Segment traits to be forwarded to ReplayBird + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/replaybird/src/identifyUser/index.ts b/packages/browser-destinations/destinations/replaybird/src/identifyUser/index.ts new file mode 100644 index 0000000000..b5f13145b7 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/identifyUser/index.ts @@ -0,0 +1,40 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { ReplayBird } from '../types' + +// Change from unknown to the partner SDK types +const action: BrowserActionDefinition = { + title: 'Identify User', + description: 'Sets user identifier and user profile details', + platform: 'web', + defaultSubscription: 'type = "identify"', + fields: { + userId: { + type: 'string', + required: true, + description: 'A unique ID for a known user', + label: 'User Id', + default: { + '@path': '$.userId' + } + }, + traits: { + type: 'object', + required: false, + description: 'The Segment traits to be forwarded to ReplayBird', + label: 'User Traits', + default: { + '@path': '$.traits' + } + } + }, + perform: (replaybird, event) => { + const payload = event.payload || {} + if (payload.userId) { + replaybird.identify(payload.userId, payload.traits || {}) + } + } +} + +export default action diff --git a/packages/browser-destinations/destinations/replaybird/src/index.ts b/packages/browser-destinations/destinations/replaybird/src/index.ts new file mode 100644 index 0000000000..22b0f703da --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/index.ts @@ -0,0 +1,63 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import { ReplayBird } from './types' +import { defaultValues } from '@segment/actions-core' +import trackEvent from './trackEvent' +import identifyUser from './identifyUser' + +declare global { + interface Window { + replaybird: ReplayBird + } +} + +// Switch from unknown to the partner SDK client types +export const destination: BrowserDestinationDefinition = { + name: 'ReplayBird Web (Actions)', + slug: 'actions-replaybird-web', + mode: 'device', + presets: [ + { + name: 'Track Event', + subscribe: 'type = "track" or type = "page"', + partnerAction: 'trackEvent', + mapping: defaultValues(trackEvent.fields), + type: 'automatic' + }, + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identifyUser', + mapping: defaultValues(identifyUser.fields), + type: 'automatic' + } + ], + settings: { + apiKey: { + description: 'The api key for replaybird', + label: 'API Key', + type: 'string', + required: true + } + }, + + initialize: async ({ settings }, deps) => { + // initialize client code here + await deps.loadScript(`https://cdn.replaybird.com/agent/latest/replaybird.js`) + await deps.resolveWhen(() => Object.prototype.hasOwnProperty.call(window, 'replaybird'), 100) + + if (settings.apiKey) { + window.replaybird.init(settings.apiKey, {}) + window.replaybird.apiKey = settings.apiKey + } + return window.replaybird + }, + + actions: { + trackEvent, + identifyUser + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/replaybird/src/trackEvent/__tests__/index.test.ts b/packages/browser-destinations/destinations/replaybird/src/trackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..ff74128a51 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/trackEvent/__tests__/index.test.ts @@ -0,0 +1,39 @@ +import { Analytics, Context } from '@segment/analytics-next' +import replaybird from '../../index' +import { + trackSubscription, + REPLAYBIRD_API_KEY, + createMockedReplaybirdJsSdk, + mockReplaybirdJsHttpRequest +} from '../../utils' + +describe('replaybird.track', () => { + it('Should send events to replaybird', async () => { + mockReplaybirdJsHttpRequest() + window.replaybird = createMockedReplaybirdJsSdk() + + const [event] = await replaybird({ + apiKey: REPLAYBIRD_API_KEY, + subscriptions: [trackSubscription] + }) + + await event.load(Context.system(), {} as Analytics) + const trackSpy = jest.spyOn(window.replaybird, 'capture') + + const name = 'Signup' + const properties = { + email: 'user@example.com', + name: 'Mathew', + country: 'USA' + } + + await event.track?.( + new Context({ + type: 'track', + name, + properties + }) + ) + expect(trackSpy).toHaveBeenCalledWith(name, properties) + }) +}) diff --git a/packages/browser-destinations/destinations/replaybird/src/trackEvent/generated-types.ts b/packages/browser-destinations/destinations/replaybird/src/trackEvent/generated-types.ts new file mode 100644 index 0000000000..11c32b7e70 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/trackEvent/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The track() event name or page() name for the event. + */ + name: string + /** + * A JSON object containing additional information about the event that will be indexed by replaybird. + */ + properties?: { + [k: string]: unknown + } + /** + * A unique ID for a known user + */ + userId?: string + /** + * A unique ID for a anonymous user + */ + anonymousId?: string +} diff --git a/packages/browser-destinations/destinations/replaybird/src/trackEvent/index.ts b/packages/browser-destinations/destinations/replaybird/src/trackEvent/index.ts new file mode 100644 index 0000000000..fefd63d50b --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/trackEvent/index.ts @@ -0,0 +1,68 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { ReplayBird } from '../types' + +// Change from unknown to the partner SDK types +const action: BrowserActionDefinition = { + title: 'Send Track Events', + description: 'Send Segment track() events and / or Segment page() events to ReplayBird', + platform: 'web', + defaultSubscription: 'type = "track" or type = "page"', + fields: { + name: { + description: 'The track() event name or page() name for the event.', + label: 'Event Name', + required: true, + type: 'string', + default: { + '@if': { + exists: { '@path': '$.event' }, + then: { '@path': '$.event' }, + else: { '@path': '$.name' } + } + } + }, + properties: { + description: + 'A JSON object containing additional information about the event that will be indexed by replaybird.', + label: 'Properties', + required: false, + type: 'object', + default: { + '@path': '$.properties' + } + }, + userId: { + description: 'A unique ID for a known user', + label: 'User ID', + required: false, + type: 'string', + default: { + '@path': '$.userId' + } + }, + anonymousId: { + description: 'A unique ID for a anonymous user', + label: 'Anonymous ID', + required: false, + type: 'string', + default: { + '@path': '$.anonymousId' + } + } + }, + perform: (replaybird, event) => { + // Invoke Partner SDK here + const payload = event.payload + if (payload) { + replaybird.capture(payload.name, { + ...payload.properties, + userId: payload.userId, + anonymousId: payload.anonymousId + }) + } + } +} + +export default action diff --git a/packages/browser-destinations/destinations/replaybird/src/types.ts b/packages/browser-destinations/destinations/replaybird/src/types.ts new file mode 100644 index 0000000000..f1e13cc0e5 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/types.ts @@ -0,0 +1,14 @@ +type EventProperties = { + [key: string]: unknown +} + +type UserProperties = { + [k: string]: unknown +} + +export type ReplayBird = { + apiKey: string + capture: (eventName: string, eventProperties: EventProperties) => void + identify: (userId: string, traits: UserProperties) => void + init: (apiKey: string, options: object) => void +} diff --git a/packages/browser-destinations/destinations/replaybird/src/utils.ts b/packages/browser-destinations/destinations/replaybird/src/utils.ts new file mode 100644 index 0000000000..5b536c3bc2 --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/src/utils.ts @@ -0,0 +1,51 @@ +import { Subscription } from '@segment/browser-destination-runtime/types' +import nock from 'nock' +import { ReplayBird } from './types' + +export const trackSubscription: Subscription = { + partnerAction: 'trackEvent', + name: 'Track', + enabled: true, + subscribe: 'type = "track"', + mapping: { + name: { + '@path': '$.name' + }, + properties: { + '@path': '$.properties' + } + } +} + +export const identifySubscription: Subscription = { + partnerAction: 'identifyUser', + name: 'Identify', + enabled: true, + subscribe: 'type = "identify"', + mapping: { + userId: { + '@path': '$.userId' + }, + traits: { + '@path': '$.traits' + } + } +} + +export const REPLAYBIRD_API_KEY = 'secret' + +export const createMockedReplaybirdJsSdk = (): ReplayBird => { + return { + apiKey: REPLAYBIRD_API_KEY, + init: jest.fn(), + capture: jest.fn(), + identify: jest.fn() + } +} + +// https://cdn.replaybird.com/agent/latest/replaybird.js +export const mockReplaybirdJsHttpRequest = (): void => { + nock('https://cdn.replaybird.com').get(`/agent/latest/replaybird.js`).reply(200, {}) +} + +export const subscriptions = [trackSubscription, identifySubscription] diff --git a/packages/browser-destinations/destinations/replaybird/tsconfig.json b/packages/browser-destinations/destinations/replaybird/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/replaybird/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/ripe/package.json b/packages/browser-destinations/destinations/ripe/package.json index de56a05def..f9b14c2a03 100644 --- a/packages/browser-destinations/destinations/ripe/package.json +++ b/packages/browser-destinations/destinations/ripe/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-ripe", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/ripe/src/index.ts b/packages/browser-destinations/destinations/ripe/src/index.ts index 204cb69fb2..7f1c0f534f 100644 --- a/packages/browser-destinations/destinations/ripe/src/index.ts +++ b/packages/browser-destinations/destinations/ripe/src/index.ts @@ -1,6 +1,6 @@ -import type { Settings } from './generated-types' -import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' import { browserDestination } from '@segment/browser-destination-runtime/shim' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from './generated-types' import { RipeSDK } from './types' import group from './group' diff --git a/packages/browser-destinations/destinations/rupt/README.md b/packages/browser-destinations/destinations/rupt/README.md index cf045c87dc..f72bcdacb4 100644 --- a/packages/browser-destinations/destinations/rupt/README.md +++ b/packages/browser-destinations/destinations/rupt/README.md @@ -6,7 +6,7 @@ The Rupt browser action destination for use with @segment/analytics-next. MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/browser-destinations/destinations/rupt/package.json b/packages/browser-destinations/destinations/rupt/package.json index e4c2b59b53..c50916663f 100644 --- a/packages/browser-destinations/destinations/rupt/package.json +++ b/packages/browser-destinations/destinations/rupt/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-rupt", - "version": "1.4.0", + "version": "1.23.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/screeb/package.json b/packages/browser-destinations/destinations/screeb/package.json index 92787ef5ae..3c43a2bbef 100644 --- a/packages/browser-destinations/destinations/screeb/package.json +++ b/packages/browser-destinations/destinations/screeb/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-screeb", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/segment-utilities-web/package.json b/packages/browser-destinations/destinations/segment-utilities-web/package.json index 4aa8d99d71..5e52b1ff4b 100644 --- a/packages/browser-destinations/destinations/segment-utilities-web/package.json +++ b/packages/browser-destinations/destinations/segment-utilities-web/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-utils", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,7 +15,7 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/snap-plugins/README.md b/packages/browser-destinations/destinations/snap-plugins/README.md new file mode 100644 index 0000000000..251004a21e --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-actions-snap-plugins + +The Snap Browser Plugins browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2024 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/snap-plugins/package.json b/packages/browser-destinations/destinations/snap-plugins/package.json new file mode 100644 index 0000000000..287cae7ca9 --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/package.json @@ -0,0 +1,23 @@ +{ + "name": "@segment/analytics-browser-actions-snap-plugins", + "version": "1.15.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/snap-plugins/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/snap-plugins/src/__tests__/index.test.ts new file mode 100644 index 0000000000..255d3d6220 --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/src/__tests__/index.test.ts @@ -0,0 +1,81 @@ +import { Analytics, Context, Plugin } from '@segment/analytics-next' +import { Subscription } from '@segment/browser-destination-runtime/types' +import browserPluginsDestination from '../' +import { clickIdIntegrationFieldName, clickIdQuerystringName, scidCookieName, scidIntegrationFieldName } from '../utils' + +const example: Subscription[] = [ + { + partnerAction: 'snapPlugin', + name: 'Snap Browser Plugin', + enabled: true, + subscribe: 'type = "track"', + mapping: {} + } +] + +let browserActions: Plugin[] +let snapPlugin: Plugin +let ajs: Analytics + +beforeEach(async () => { + browserActions = await browserPluginsDestination({ subscriptions: example }) + snapPlugin = browserActions[0] + + ajs = new Analytics({ + writeKey: 'w_123' + }) + + Object.defineProperty(window, 'location', { + value: { + search: '' + }, + writable: true + }) + + document.cookie = `${scidCookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;` +}) + +describe('ajs-integration', () => { + test('updates the original event with a Snap clientId from the querystring', async () => { + Object.defineProperty(window, 'location', { + value: { + search: `?${clickIdQuerystringName}=dummyQuerystringValue` + }, + writable: true + }) + + await snapPlugin.load(Context.system(), ajs) + + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + + const updatedCtx = await snapPlugin.track?.(ctx) + + const snapIntegrationsObj = updatedCtx?.event?.integrations['Snap Conversions Api'] + expect(snapIntegrationsObj[clickIdIntegrationFieldName]).toEqual('dummyQuerystringValue') + }) + + test('updates the original event with a Snap cookie value', async () => { + document.cookie = `${scidCookieName}=dummyCookieValue` + + await snapPlugin.load(Context.system(), ajs) + + const ctx = new Context({ + type: 'track', + event: 'Test Event', + properties: { + greeting: 'Yo!' + } + }) + + const updatedCtx = await snapPlugin.track?.(ctx) + + const snapIntegrationsObj = updatedCtx?.event?.integrations['Snap Conversions Api'] + expect(snapIntegrationsObj[scidIntegrationFieldName]).toEqual('dummyCookieValue') + }) +}) diff --git a/packages/browser-destinations/destinations/snap-plugins/src/generated-types.ts b/packages/browser-destinations/destinations/snap-plugins/src/generated-types.ts new file mode 100644 index 0000000000..4ab2786ec6 --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/src/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings {} diff --git a/packages/browser-destinations/destinations/snap-plugins/src/index.ts b/packages/browser-destinations/destinations/snap-plugins/src/index.ts new file mode 100644 index 0000000000..5d80d6e814 --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/src/index.ts @@ -0,0 +1,42 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import { + storageSCIDCookieKey, + storageClickIdKey, + clickIdQuerystringName, + scidCookieName, + getCookieValue, + storageFallback +} from './utils' +import { UniversalStorage } from '@segment/analytics-next' +import snapPlugin from './snapPlugin' + +// Switch from unknown to the partner SDK client types +export const destination: BrowserDestinationDefinition = { + name: 'Snap Browser Plugins', + mode: 'device', + initialize: async ({ analytics }) => { + const storage = (analytics.storage as UniversalStorage>) ?? storageFallback + + const scid: string | null = getCookieValue(scidCookieName) + if (scid) { + storage.set(storageSCIDCookieKey, scid) + } + + const urlParams = new URLSearchParams(window.location.search) + const clickId: string | null = urlParams.get(clickIdQuerystringName) || null + + if (clickId) { + storage.set(storageClickIdKey, clickId) + } + + return {} + }, + settings: {}, + actions: { + snapPlugin + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/snap-plugins/src/snapPlugin/generated-types.ts b/packages/browser-destinations/destinations/snap-plugins/src/snapPlugin/generated-types.ts new file mode 100644 index 0000000000..944d22b085 --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/src/snapPlugin/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload {} diff --git a/packages/browser-destinations/destinations/snap-plugins/src/snapPlugin/index.ts b/packages/browser-destinations/destinations/snap-plugins/src/snapPlugin/index.ts new file mode 100644 index 0000000000..71650a7a42 --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/src/snapPlugin/index.ts @@ -0,0 +1,45 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { + storageSCIDCookieKey, + storageClickIdKey, + scidIntegrationFieldName, + clickIdIntegrationFieldName, + storageFallback +} from '../utils' +import { UniversalStorage } from '@segment/analytics-next' + +const action: BrowserActionDefinition = { + title: 'Snap Browser Plugin', + description: 'Enriches all Segment payloads with Snap click_id Querystring and _scid Cookie values', + platform: 'web', + hidden: false, + defaultSubscription: 'type = "track" or type = "identify" or type = "page" or type = "group" or type = "alias"', + fields: {}, + lifecycleHook: 'enrichment', + perform: (_, { context, analytics }) => { + const storage = (analytics.storage as UniversalStorage>) ?? storageFallback + + const scid: string | null = storage.get(storageSCIDCookieKey) + + const clickId: string | null = storage.get(storageClickIdKey) + + if (scid || clickId) { + const integrationsData: Record = {} + if (clickId) { + integrationsData[clickIdIntegrationFieldName] = clickId + } + if (scid) { + integrationsData[scidIntegrationFieldName] = scid + } + if (context.event.integrations?.All !== false || context.event.integrations['Snap Conversions Api']) { + context.updateEvent(`integrations.Snap Conversions Api`, integrationsData) + } + } + + return + } +} + +export default action diff --git a/packages/browser-destinations/destinations/snap-plugins/src/utils.ts b/packages/browser-destinations/destinations/snap-plugins/src/utils.ts new file mode 100644 index 0000000000..3662e89fd2 --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/src/utils.ts @@ -0,0 +1,41 @@ +// The name of the storage location where we'll cache the Snap click_id Querystring value +export const storageClickIdKey = 'analytics_snap_capi_click_id' + +// The name of the storage location where we'll cache the Snap scid cookie value +export const storageSCIDCookieKey = 'analytics_snap_capi_scid_cookie' + +// The name of the Snap click_id querystring to retrieve when the page loads +export const clickIdQuerystringName = 'ScCid' + +// The name of the Snap cookie to retrieve to retrieve when the page loads +export const scidCookieName = '_scid' + +// The field name to include for the Snap scid cookie in the context.integrations.snap_conversions_api +export const scidIntegrationFieldName = 'uuid_c1' + +// The field name to include for the Snap click_id Querystring in the context.integrations.snap_conversions_api +export const clickIdIntegrationFieldName = 'click_id' + +export const getCookieValue = (cookieName: string): string | null => { + const name = cookieName + '=' + const decodedCookie = decodeURIComponent(document.cookie) + const cookieArray = decodedCookie.split('; ') + + for (const cookie of cookieArray) { + if (cookie.startsWith(name)) { + return cookie.substring(name.length) + } + } + + return null +} + +export const storageFallback = { + get: (key: string) => { + const data = window.localStorage.getItem(key) + return data + }, + set: (key: string, value: string) => { + return window.localStorage.setItem(key, value) + } +} diff --git a/packages/browser-destinations/destinations/snap-plugins/tsconfig.json b/packages/browser-destinations/destinations/snap-plugins/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/snap-plugins/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/sprig-web/package.json b/packages/browser-destinations/destinations/sprig-web/package.json index 1a44035571..35ae27b462 100644 --- a/packages/browser-destinations/destinations/sprig-web/package.json +++ b/packages/browser-destinations/destinations/sprig-web/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-sprig", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/stackadapt/package.json b/packages/browser-destinations/destinations/stackadapt/package.json index 9c9932b17d..8dc098ed75 100644 --- a/packages/browser-destinations/destinations/stackadapt/package.json +++ b/packages/browser-destinations/destinations/stackadapt/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-stackadapt", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/survicate/README.md b/packages/browser-destinations/destinations/survicate/README.md new file mode 100644 index 0000000000..03ca772161 --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/README.md @@ -0,0 +1,31 @@ +# @segment/analytics-browser-actions-survicate + +The Survicate browser action destination for use with @segment/analytics-next. + +## License + +MIT License + +Copyright (c) 2023 Segment + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +## Contributing + +All third party contributors acknowledge that any contributions they provide will be made under the same open source license that the open source project is provided under. diff --git a/packages/browser-destinations/destinations/survicate/package.json b/packages/browser-destinations/destinations/survicate/package.json new file mode 100644 index 0000000000..cecd5a675f --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/package.json @@ -0,0 +1,23 @@ +{ + "name": "@segment/analytics-browser-actions-survicate", + "version": "1.10.0", + "license": "MIT", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "main": "./dist/cjs", + "module": "./dist/esm", + "scripts": { + "build": "yarn build:esm && yarn build:cjs", + "build:cjs": "tsc --module commonjs --outDir ./dist/cjs", + "build:esm": "tsc --outDir ./dist/esm" + }, + "typings": "./dist/esm", + "dependencies": { + "@segment/browser-destination-runtime": "^1.33.0" + }, + "peerDependencies": { + "@segment/analytics-next": ">=1.55.0" + } +} diff --git a/packages/browser-destinations/destinations/survicate/src/__tests__/index.test.ts b/packages/browser-destinations/destinations/survicate/src/__tests__/index.test.ts new file mode 100644 index 0000000000..95276927ef --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/__tests__/index.test.ts @@ -0,0 +1,110 @@ +import { Analytics, Context } from '@segment/analytics-next' +import survicate, { destination } from '../index' +import { Subscription } from '@segment/browser-destination-runtime/types' +import { Survicate } from '../types' + +const example: Subscription[] = [ + { + partnerAction: 'trackEvent', + name: 'Track Event', + enabled: true, + subscribe: 'type = "track"', + mapping: { + name: { + '@path': '$.name' + }, + properties: { + '@path': '$.properties' + } + } + }, + { + partnerAction: 'identifyUser', + name: 'Identify User', + enabled: true, + subscribe: 'type = "identify"', + mapping: { + traits: { + '@path': '$.traits' + } + } + } +] + +describe('Survicate', () => { + let mockSurvicate: Survicate + beforeEach(async () => { + jest.restoreAllMocks() + + const [trackEventPlugin] = await survicate({ + workspaceKey: 'xMIeFQrceKnfKOuoYXZOVgqbsLlqYMGD', + subscriptions: example + }) + + jest.spyOn(destination, 'initialize').mockImplementation(() => { + mockSurvicate = { + invokeEvent: jest.fn(), + setVisitorTraits: jest.fn() + } + window._sva = mockSurvicate + return Promise.resolve(mockSurvicate) + }) + await trackEventPlugin.load(Context.system(), {} as Analytics) + }) + + test('#load', async () => { + const [event] = await survicate({ + workspaceKey: 'xMIeFQrceKnfKOuoYXZOVgqbsLlqYMGD', + subscriptions: example + }) + + jest.spyOn(destination.actions.trackEvent, 'perform') + jest.spyOn(destination, 'initialize') + + await event.load(Context.system(), {} as Analytics) + expect(destination.initialize).toHaveBeenCalled() + expect(window).toHaveProperty('_sva') + }) + + it('#track', async () => { + const [event] = await survicate({ + workspaceKey: 'xMIeFQrceKnfKOuoYXZOVgqbsLlqYMGD', + subscriptions: example + }) + + await event.load(Context.system(), {} as Analytics) + const sva = jest.spyOn(window._sva, 'invokeEvent') + + await event.track?.( + new Context({ + type: 'track', + name: 'event', + properties: {} + }) + ) + + expect(sva).toHaveBeenCalledWith('segmentEvent-event', {}) + }) + + it('#identify', async () => { + const [_, identifyUser] = await survicate({ + workspaceKey: 'xMIeFQrceKnfKOuoYXZOVgqbsLlqYMGD', + subscriptions: example + }) + + await identifyUser.load(Context.system(), {} as Analytics) + const setVisitorTraits = jest.spyOn(window._sva, 'setVisitorTraits') + + await identifyUser.identify?.( + new Context({ + type: 'identify', + traits: { + date: '2024-01-01' + } + }) + ) + + expect(setVisitorTraits).toHaveBeenCalled() + expect(setVisitorTraits).toHaveBeenCalledWith({ date: '2024-01-01' }) + }) +}) diff --git a/packages/browser-destinations/destinations/survicate/src/generated-types.ts b/packages/browser-destinations/destinations/survicate/src/generated-types.ts new file mode 100644 index 0000000000..3fa65f86e2 --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The workspace key for your Survicate account. + */ + workspaceKey: string +} diff --git a/packages/browser-destinations/destinations/survicate/src/identifyGroup/generated-types.ts b/packages/browser-destinations/destinations/survicate/src/identifyGroup/generated-types.ts new file mode 100644 index 0000000000..0d2482a791 --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/identifyGroup/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Segment groupId to be forwarded to Survicate + */ + groupId: string + /** + * The Segment traits to be forwarded to Survicate + */ + traits: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/survicate/src/identifyGroup/index.ts b/packages/browser-destinations/destinations/survicate/src/identifyGroup/index.ts new file mode 100644 index 0000000000..8a957521f1 --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/identifyGroup/index.ts @@ -0,0 +1,39 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { Survicate } from 'src/types' + +const action: BrowserActionDefinition = { + title: 'Identify Group', + description: 'Send group traits to Survicate', + defaultSubscription: 'type = "group"', + platform: 'web', + fields: { + groupId: { + type: 'string', + required: true, + description: 'The Segment groupId to be forwarded to Survicate', + label: 'Group ID', + default: { + '@path': '$.groupId' + } + }, + traits: { + type: 'object', + required: true, + description: 'The Segment traits to be forwarded to Survicate', + label: 'Traits', + default: { + '@path': '$.traits' + } + } + }, + perform: (_, { payload }) => { + const groupTraits = Object.fromEntries( + Object.entries(payload.traits).map(([key, value]) => [`group_${key}`, value]) + ) + window._sva.setVisitorTraits({ groupId: payload.groupId, ...groupTraits }) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/survicate/src/identifyUser/generated-types.ts b/packages/browser-destinations/destinations/survicate/src/identifyUser/generated-types.ts new file mode 100644 index 0000000000..531b64a2c6 --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/identifyUser/generated-types.ts @@ -0,0 +1,10 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Segment traits to be forwarded to Survicate + */ + traits: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/survicate/src/identifyUser/index.ts b/packages/browser-destinations/destinations/survicate/src/identifyUser/index.ts new file mode 100644 index 0000000000..439f5e3d4f --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/identifyUser/index.ts @@ -0,0 +1,27 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { Survicate } from 'src/types' + +const action: BrowserActionDefinition = { + title: 'Identify User', + description: 'Set visitor traits with Segment Identify event', + defaultSubscription: 'type = "identify"', + platform: 'web', + fields: { + traits: { + type: 'object', + required: true, + description: 'The Segment traits to be forwarded to Survicate', + label: 'Traits', + default: { + '@path': '$.traits' + } + } + }, + perform: (_, { payload }) => { + window._sva.setVisitorTraits(payload.traits) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/survicate/src/index.ts b/packages/browser-destinations/destinations/survicate/src/index.ts new file mode 100644 index 0000000000..0db23f0542 --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/index.ts @@ -0,0 +1,72 @@ +import type { Settings } from './generated-types' +import type { BrowserDestinationDefinition } from '@segment/browser-destination-runtime/types' +import { browserDestination } from '@segment/browser-destination-runtime/shim' +import { defaultValues } from '@segment/actions-core' +import trackEvent from './trackEvent' +import identifyUser from './identifyUser' +import identifyGroup from './identifyGroup' +import { Survicate } from './types' + +declare global { + interface Window { + _sva: Survicate + } +} + +export const destination: BrowserDestinationDefinition = { + name: 'Survicate (Actions)', + slug: 'actions-survicate', + mode: 'device', + description: 'Send user traits to Survicate and trigger surveys with Segment events', + + presets: [ + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'trackEvent', + mapping: defaultValues(trackEvent.fields), + type: 'automatic' + }, + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identifyUser', + mapping: defaultValues(identifyUser.fields), + type: 'automatic' + }, + { + name: 'Identify Group', + subscribe: 'type = "group"', + partnerAction: 'identifyGroup', + mapping: defaultValues(identifyGroup.fields), + type: 'automatic' + } + ], + + settings: { + workspaceKey: { + description: 'The workspace key for your Survicate account.', + label: 'Workspace Key', + type: 'string', + required: true + } + }, + + initialize: async ({ settings }, deps) => { + try { + await deps.loadScript(`https://survey.survicate.com/workspaces/${settings.workspaceKey}/web_surveys.js`) + await deps.resolveWhen(() => window._sva != undefined, 100) + return window._sva + } catch (error) { + throw new Error('Failed to load Survicate. ' + error) + } + }, + + actions: { + trackEvent, + identifyUser, + identifyGroup + } +} + +export default browserDestination(destination) diff --git a/packages/browser-destinations/destinations/survicate/src/trackEvent/generated-types.ts b/packages/browser-destinations/destinations/survicate/src/trackEvent/generated-types.ts new file mode 100644 index 0000000000..95103600f7 --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/trackEvent/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The event name + */ + name: string + /** + * Object containing the properties of the event + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/browser-destinations/destinations/survicate/src/trackEvent/index.ts b/packages/browser-destinations/destinations/survicate/src/trackEvent/index.ts new file mode 100644 index 0000000000..641bf6626f --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/trackEvent/index.ts @@ -0,0 +1,37 @@ +import type { BrowserActionDefinition } from '@segment/browser-destination-runtime/types' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { Survicate } from 'src/types' + +const action: BrowserActionDefinition = { + title: 'Track Event', + description: 'Invoke survey with Segment Track event', + platform: 'web', + defaultSubscription: 'type = "track"', + fields: { + name: { + description: 'The event name', + label: 'Event name', + required: true, + type: 'string', + default: { + '@path': '$.event' + } + }, + properties: { + type: 'object', + required: false, + description: 'Object containing the properties of the event', + label: 'Event Properties', + default: { + '@path': '$.properties' + } + } + }, + perform: (_, { payload: { name, properties } }) => { + const segmentProperties = properties || {} + window._sva.invokeEvent(`segmentEvent-${name}`, segmentProperties) + } +} + +export default action diff --git a/packages/browser-destinations/destinations/survicate/src/types.ts b/packages/browser-destinations/destinations/survicate/src/types.ts new file mode 100644 index 0000000000..e74e6fa9ae --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/src/types.ts @@ -0,0 +1,4 @@ +export interface Survicate { + invokeEvent: (name: string, properties?: { [k: string]: unknown }) => void + setVisitorTraits: (traits: { [k: string]: unknown }) => void +} diff --git a/packages/browser-destinations/destinations/survicate/tsconfig.json b/packages/browser-destinations/destinations/survicate/tsconfig.json new file mode 100644 index 0000000000..c2a7897afd --- /dev/null +++ b/packages/browser-destinations/destinations/survicate/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "." + }, + "include": ["src"], + "exclude": ["dist", "**/__tests__"] +} diff --git a/packages/browser-destinations/destinations/tiktok-pixel/package.json b/packages/browser-destinations/destinations/tiktok-pixel/package.json index c33e2f7d1f..7edac1722b 100644 --- a/packages/browser-destinations/destinations/tiktok-pixel/package.json +++ b/packages/browser-destinations/destinations/tiktok-pixel/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-tiktok-pixel", - "version": "1.10.0", + "version": "1.31.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/tiktok-pixel/src/generated-types.ts b/packages/browser-destinations/destinations/tiktok-pixel/src/generated-types.ts index a410308693..608ad2bb52 100644 --- a/packages/browser-destinations/destinations/tiktok-pixel/src/generated-types.ts +++ b/packages/browser-destinations/destinations/tiktok-pixel/src/generated-types.ts @@ -6,7 +6,7 @@ export interface Settings { */ pixelCode: string /** - * Select "true" to use existing Pixel that is already installed on your website. + * Important! Changing this setting may block data collection to Segment if not done correctly. Select "true" to use an existing TikTok Pixel which is already installed on your website. The Pixel MUST be installed on your website when this is set to "true" or all data collection to Segment may fail. */ useExistingPixel?: boolean } diff --git a/packages/browser-destinations/destinations/tiktok-pixel/src/index.ts b/packages/browser-destinations/destinations/tiktok-pixel/src/index.ts index 90ef30ce3e..6c5c13a28b 100644 --- a/packages/browser-destinations/destinations/tiktok-pixel/src/index.ts +++ b/packages/browser-destinations/destinations/tiktok-pixel/src/index.ts @@ -139,7 +139,8 @@ export const destination: BrowserDestinationDefinition = useExistingPixel: { label: 'Use Existing Pixel', type: 'boolean', - description: 'Select "true" to use existing Pixel that is already installed on your website.' + description: + 'Important! Changing this setting may block data collection to Segment if not done correctly. Select "true" to use an existing TikTok Pixel which is already installed on your website. The Pixel MUST be installed on your website when this is set to "true" or all data collection to Segment may fail.' } }, initialize: async ({ settings }, deps) => { diff --git a/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/__tests__/index.test.ts b/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/__tests__/index.test.ts index 43c0567a68..a7acd528c6 100644 --- a/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/__tests__/index.test.ts +++ b/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/__tests__/index.test.ts @@ -89,6 +89,7 @@ describe('TikTokPixel.reportWebEvent', () => { messageId: 'ajs-71f386523ee5dfa90c7d0fda28b6b5c6', type: 'track', anonymousId: 'anonymousId', + userId: 'userId', event: 'Order Completed', properties: { products: [ @@ -125,7 +126,8 @@ describe('TikTokPixel.reportWebEvent', () => { expect(mockTtp.identify).toHaveBeenCalledWith({ email: 'aaa@aaa.com', - phone_number: '+12345678900' + phone_number: '+12345678900', + external_id: 'userId' }) expect(mockTtp.track).toHaveBeenCalledWith( 'PlaceAnOrder', @@ -209,6 +211,7 @@ describe('TikTokPixel.reportWebEvent', () => { messageId: 'ajs-71f386523ee5dfa90c7d0fda28b6b5c6', type: 'track', anonymousId: 'anonymousId', + userId: 'userId', event: 'Product Added', properties: { product_id: '123', @@ -235,7 +238,8 @@ describe('TikTokPixel.reportWebEvent', () => { expect(mockTtp.identify).toHaveBeenCalledWith({ email: 'aaa@aaa.com', - phone_number: '+12345678900' + phone_number: '+12345678900', + external_id: 'userId' }) expect(mockTtp.track).toHaveBeenCalledWith( 'AddToCart', @@ -316,6 +320,7 @@ describe('TikTokPixel.reportWebEvent', () => { messageId: 'ajs-71f386523ee5dfa90c7d0fda28b6b5c6', type: 'page', anonymousId: 'anonymousId', + userId: 'userId', properties: { product_id: '123', category: 'product', @@ -341,7 +346,8 @@ describe('TikTokPixel.reportWebEvent', () => { expect(mockTtp.identify).toHaveBeenCalledWith({ email: 'aaa@aaa.com', - phone_number: '+12345678900' + phone_number: '+12345678900', + external_id: 'userId' }) expect(mockTtp.track).toHaveBeenCalledWith( 'ViewContent', diff --git a/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/index.ts b/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/index.ts index 3c90c34f41..a6a5b404f5 100644 --- a/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/index.ts +++ b/packages/browser-destinations/destinations/tiktok-pixel/src/reportWebEvent/index.ts @@ -136,7 +136,8 @@ const action: BrowserActionDefinition = { if (payload.email || payload.phone_number) { ttq.identify({ email: payload.email, - phone_number: formatPhone(payload.phone_number) + phone_number: formatPhone(payload.phone_number), + external_id: payload.external_id }) } diff --git a/packages/browser-destinations/destinations/tiktok-pixel/src/types.ts b/packages/browser-destinations/destinations/tiktok-pixel/src/types.ts index 4c7ff44891..c1ceacf601 100644 --- a/packages/browser-destinations/destinations/tiktok-pixel/src/types.ts +++ b/packages/browser-destinations/destinations/tiktok-pixel/src/types.ts @@ -1,6 +1,14 @@ export interface TikTokPixel { page: () => void - identify: ({ email, phone_number }: { email: string | undefined; phone_number: string | undefined }) => void + identify: ({ + email, + phone_number, + external_id + }: { + email: string | undefined + phone_number: string | undefined + external_id: string | undefined + }) => void track: ( event: string, { diff --git a/packages/browser-destinations/destinations/upollo/package.json b/packages/browser-destinations/destinations/upollo/package.json index 8592d59b9e..57c5bd5efc 100644 --- a/packages/browser-destinations/destinations/upollo/package.json +++ b/packages/browser-destinations/destinations/upollo/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-upollo", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/userpilot/package.json b/packages/browser-destinations/destinations/userpilot/package.json index ba33314c80..e78d5cef82 100644 --- a/packages/browser-destinations/destinations/userpilot/package.json +++ b/packages/browser-destinations/destinations/userpilot/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-userpilot", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/userpilot/src/index.ts b/packages/browser-destinations/destinations/userpilot/src/index.ts index 61e893ce5e..1f4d9e71ef 100644 --- a/packages/browser-destinations/destinations/userpilot/src/index.ts +++ b/packages/browser-destinations/destinations/userpilot/src/index.ts @@ -31,6 +31,13 @@ export const destination: BrowserDestinationDefinition = { mapping: defaultValues(identifyUser.fields), type: 'automatic' }, + { + name: 'Identify Company', + subscribe: 'type = "group"', + partnerAction: 'identifyCompany', + mapping: defaultValues(identifyCompany.fields), + type: 'automatic' + }, { name: 'Track Event', subscribe: 'type = "track"', diff --git a/packages/browser-destinations/destinations/vwo/package.json b/packages/browser-destinations/destinations/vwo/package.json index d7914cffdd..408e916fe4 100644 --- a/packages/browser-destinations/destinations/vwo/package.json +++ b/packages/browser-destinations/destinations/vwo/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-vwo", - "version": "1.16.0", + "version": "1.35.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/destinations/wisepops/package.json b/packages/browser-destinations/destinations/wisepops/package.json index ff7ed4f1fc..a4432e9e8c 100644 --- a/packages/browser-destinations/destinations/wisepops/package.json +++ b/packages/browser-destinations/destinations/wisepops/package.json @@ -1,6 +1,6 @@ { "name": "@segment/analytics-browser-actions-wiseops", - "version": "1.15.0", + "version": "1.34.0", "license": "MIT", "publishConfig": { "access": "public", @@ -15,8 +15,8 @@ }, "typings": "./dist/esm", "dependencies": { - "@segment/actions-core": "^3.83.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/actions-core": "^3.103.0", + "@segment/browser-destination-runtime": "^1.33.0" }, "peerDependencies": { "@segment/analytics-next": ">=1.55.0" diff --git a/packages/browser-destinations/package.json b/packages/browser-destinations/package.json index 938160f6c1..69460a82e1 100644 --- a/packages/browser-destinations/package.json +++ b/packages/browser-destinations/package.json @@ -20,7 +20,8 @@ "prepublishOnly": "yarn build", "test": "jest", "typecheck": "tsc -p tsconfig.build.json --noEmit", - "dev": "NODE_ENV=development NODE_OPTIONS=--openssl-legacy-provider concurrently \"webpack serve\" \"webpack -c webpack.config.js --watch\"" + "dev": "NODE_ENV=development NODE_OPTIONS=--openssl-legacy-provider concurrently \"webpack serve\" \"webpack -c webpack.config.js --watch\"", + "size": "size-limit" }, "dependencies": { "tslib": "^2.3.1", @@ -32,14 +33,15 @@ "@babel/plugin-transform-modules-commonjs": "^7.13.8", "@babel/preset-env": "^7.13.10", "@babel/preset-typescript": "^7.13.0", - "@types/gtag.js": "^0.0.13", + "@size-limit/preset-big-lib": "^11.0.1", + "@types/gtag.js": "^0.0.19", "@types/jest": "^27.0.0", - "babel-jest": "^27.3.1", "compression-webpack-plugin": "^7.1.2", "concurrently": "^6.3.0", "globby": "^11.0.2", "jest": "^27.3.1", "serve": "^12.0.1", + "size-limit": "^11.0.1", "terser-webpack-plugin": "^5.1.1", "ts-loader": "^9.2.6", "webpack": "^5.82.0", @@ -63,8 +65,13 @@ "@segment/actions-shared": "/../actions-shared/src", "@segment/browser-destination-runtime/(.*)": "/../browser-destination-runtime/src/$1" }, + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, "transform": { - "^.+\\.[t|j]sx?$": "babel-jest" + "^.+\\.[t|j]sx?$": "ts-jest" }, "transformIgnorePatterns": [ "/node_modules/(?!(@segment/analytics-next|@braze/web-sdk/)).+\\.js$" @@ -73,5 +80,11 @@ "/test/setup-after-env.ts" ], "forceExit": true - } + }, + "size-limit": [ + { + "path": "dist/web/*/*.js", + "limit": "150 KB" + } + ] } diff --git a/packages/browser-destinations/tsconfig.build.json b/packages/browser-destinations/tsconfig.build.json index 8c38965829..907c54e8a2 100644 --- a/packages/browser-destinations/tsconfig.build.json +++ b/packages/browser-destinations/tsconfig.build.json @@ -10,13 +10,20 @@ "lib": ["es2020", "dom"], "baseUrl": ".", "paths": { - "@segment/actions-core/*": ["../core/src/*"] + "@segment/actions-core": ["../core/src"], + "@segment/actions-core/*": ["../core/src/*"], + "@segment/actions-shared": ["../actions-shared/src"], + "@segment/action-destinations": ["../destination-actions/src"], + "@segment/destination-subscriptions": ["../destination-subscriptions/src"], + "@segment/browser-destination-runtime": ["../browser-destination-runtime/src"], + "@segment/browser-destination-runtime/*": ["../browser-destination-runtime/src/*"] } }, "exclude": ["**/__tests__/**/*.ts"], "include": ["src"], "references": [ { "path": "../core/tsconfig.build.json" }, - { "path": "../destination-subscriptions/tsconfig.build.json" } + { "path": "../destination-subscriptions/tsconfig.build.json" }, + { "path": "../browser-destination-runtime/tsconfig.build.json" } ] } diff --git a/packages/browser-destinations/tsconfig.json b/packages/browser-destinations/tsconfig.json index af9bff8d07..2d4ba95c55 100644 --- a/packages/browser-destinations/tsconfig.json +++ b/packages/browser-destinations/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { + "allowJs": true, // remove ts-jest warning "module": "esnext", "removeComments": false, "baseUrl": ".", diff --git a/packages/cli-internal/package.json b/packages/cli-internal/package.json index 4721224e68..560b93b579 100644 --- a/packages/cli-internal/package.json +++ b/packages/cli-internal/package.json @@ -49,7 +49,7 @@ "rimraf": "^3.0.2" }, "dependencies": { - "@oclif/command": "^1.8.25", + "@oclif/command": "1.8.36", "@oclif/config": "^1.18.8", "@oclif/errors": "^1.3.6", "@oclif/plugin-help": "^3.3", @@ -90,6 +90,11 @@ }, "jest": { "preset": "ts-jest", + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, "testRegex": "((\\.|/)(test))\\.(tsx?|json)$", "modulePathIgnorePatterns": [ "/dist/" diff --git a/packages/cli/README.md b/packages/cli/README.md index e15ae4a488..99646ac445 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -154,7 +154,7 @@ _See code: [src/commands/serve.ts](https://github.com/segmentio/action-destinati MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/cli/package.json b/packages/cli/package.json index c2807198ed..b02ee69e03 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,7 +26,7 @@ "scripts": { "postpack": "rm -f oclif.manifest.json", "prepack": "yarn build && oclif-dev manifest && oclif-dev readme", - "build": "yarn clean && yarn tsc -b tsconfig.build.json", + "build": "yarn tsc -b tsconfig.build.json", "clean": "tsc -b tsconfig.build.json --clean", "postclean": "rm -rf dist", "create:destination": "./bin/run init", @@ -52,7 +52,7 @@ "rimraf": "^3.0.2" }, "dependencies": { - "@oclif/command": "^1.8.25", + "@oclif/command": "1.8.36", "@oclif/config": "^1.18.8", "@oclif/errors": "^1.3.6", "@oclif/plugin-help": "^3.3", diff --git a/packages/cli/src/__tests__/init.test.ts b/packages/cli/src/__tests__/init.test.ts index 2a47b109ce..05d21100b4 100644 --- a/packages/cli/src/__tests__/init.test.ts +++ b/packages/cli/src/__tests__/init.test.ts @@ -14,6 +14,8 @@ import * as path from 'path' import * as prompt from '../lib/prompt' import * as rimraf from 'rimraf' +jest.setTimeout(10000) + describe('cli init command', () => { const testDir = path.join('.', 'testResults') beforeAll(() => { diff --git a/packages/cli/src/commands/generate/action.ts b/packages/cli/src/commands/generate/action.ts index 78a2d11fe9..3b938e782f 100644 --- a/packages/cli/src/commands/generate/action.ts +++ b/packages/cli/src/commands/generate/action.ts @@ -21,7 +21,8 @@ export default class GenerateAction extends Command { `$ ./bin/run generate:action postToChannel server --directory=./destinations/slack` ] - static flags = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static flags: flags.Input = { help: flags.help({ char: 'h' }), force: flags.boolean({ char: 'f' }), title: flags.string({ char: 't', description: 'the display name of the action' }), @@ -44,7 +45,7 @@ export default class GenerateAction extends Command { return integrationDirs } - parseArgs() { + parseArgs(): flags.Output { return this.parse(GenerateAction) } diff --git a/packages/cli/src/commands/generate/types.ts b/packages/cli/src/commands/generate/types.ts index 407bf245f0..33ef5ce66e 100644 --- a/packages/cli/src/commands/generate/types.ts +++ b/packages/cli/src/commands/generate/types.ts @@ -11,7 +11,8 @@ import path from 'path' import prettier from 'prettier' import { loadDestination, hasOauthAuthentication } from '../../lib/destinations' import { RESERVED_FIELD_NAMES } from '../../constants' -import { AudienceDestinationDefinition } from '@segment/actions-core/destination-kit' +import { AudienceDestinationDefinition, ActionHookType } from '@segment/actions-core/destination-kit' +import { ActionHookDefinition, hookTypeStrings } from '@segment/actions-core/destination-kit' const pretterOptions = prettier.resolveConfig.sync(process.cwd()) @@ -131,7 +132,51 @@ export default class GenerateTypes extends Command { // TODO how to load directory structure consistently? for (const [slug, action] of Object.entries(destination.actions)) { - const types = await generateTypes(action.fields, 'Payload') + const fields = action.fields + + let types = await generateTypes(fields, 'Payload') + + if (action.hooks) { + const hooks: ActionHookDefinition = action.hooks + let hookBundle = '' + const hookFields: Record = {} + for (const [hookName, hook] of Object.entries(hooks)) { + if (!hookTypeStrings.includes(hookName as ActionHookType)) { + throw new Error(`Hook name ${hookName} is not a valid ActionHookType`) + } + + const inputs = hook.inputFields + const outputs = hook.outputTypes + if (!inputs && !outputs) { + continue + } + + const hookSchema = { + type: 'object', + required: true, + properties: { + inputs: { + label: `${hookName} hook inputs`, + type: 'object', + properties: inputs + }, + outputs: { + label: `${hookName} hook outputs`, + type: 'object', + properties: outputs + } + } + } + hookFields[hookName] = hookSchema + } + hookBundle = await generateTypes( + hookFields, + 'HookBundle', + `// Generated bundle for hooks. DO NOT MODIFY IT BY HAND.` + ) + types += hookBundle + } + if (fs.pathExistsSync(path.join(parentDir, `${slug}`))) { fs.writeFileSync(path.join(parentDir, slug, 'generated-types.ts'), types) } else { @@ -141,11 +186,11 @@ export default class GenerateTypes extends Command { } } -async function generateTypes(fields: Record = {}, name: string) { +async function generateTypes(fields: Record = {}, name: string, bannerComment?: string) { const schema = prepareSchema(fields) return compile(schema, name, { - bannerComment: '// Generated file. DO NOT MODIFY IT BY HAND.', + bannerComment: bannerComment ?? '// Generated file. DO NOT MODIFY IT BY HAND.', style: pretterOptions ?? undefined }) } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index cbdd7f48fb..d423ca0144 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -42,7 +42,7 @@ export default class Init extends Command { } ] - parseFlags() { + parseFlags(): flags.Output { return this.parse(Init) } diff --git a/packages/cli/src/commands/scaffold.ts b/packages/cli/src/commands/scaffold.ts index c3e2ff25a7..5b0a4c4ca2 100644 --- a/packages/cli/src/commands/scaffold.ts +++ b/packages/cli/src/commands/scaffold.ts @@ -58,7 +58,7 @@ export default class Init extends Command { return integrationDirs } - parseFlags() { + parseFlags(): flags.Output { return this.parse(Init) } diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 58f4c41c8e..9da645660d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -57,7 +57,7 @@ export default class Serve extends Command { }) const { selectedDestination } = await autoPrompt<{ selectedDestination: { name: string } }>(flags, { - type: 'select', + type: 'autocomplete', name: 'selectedDestination', message: 'Which destination?', choices: integrationDirs.map((integrationPath) => { diff --git a/packages/cli/src/lib/server.ts b/packages/cli/src/lib/server.ts index 7cc195ae96..8a68fa4aa9 100644 --- a/packages/cli/src/lib/server.ts +++ b/packages/cli/src/lib/server.ts @@ -17,7 +17,12 @@ import { import asyncHandler from './async-handler' import getExchanges from './summarize-http' import { AggregateAjvError } from '../../../ajv-human-errors/src/aggregate-ajv-error' -import { AudienceDestinationConfigurationWithCreateGet } from '@segment/actions-core/destination-kit' +import { + ActionHookType, + ActionHookResponse, + AudienceDestinationConfigurationWithCreateGet, + RequestFn +} from '@segment/actions-core/destination-kit' interface ResponseError extends Error { status?: number } @@ -291,12 +296,14 @@ function setupRoutes(def: DestinationDefinition | null): void { settings: req.body.settings || {}, audienceSettings: req.body.payload?.context?.personas?.audience_settings || {}, mapping: mapping || req.body.payload || {}, - auth: req.body.auth || {} + auth: req.body.auth || {}, + features: req.body.features || {} } if (Array.isArray(eventParams.data)) { // If no mapping or default mapping is provided, default to using the first payload across all events. eventParams.mapping = mapping || eventParams.data[0] || {} + eventParams.audienceSettings = req.body.payload[0]?.context?.personas?.audience_settings || {} await action.executeBatch(eventParams) } else { await action.execute(eventParams) @@ -343,6 +350,78 @@ function setupRoutes(def: DestinationDefinition | null): void { ) } } + + if (definition.hooks) { + for (const hookName in definition.hooks) { + router.post( + `/${actionSlug}/hooks/${hookName}`, + asyncHandler(async (req: express.Request, res: express.Response) => { + try { + const data = { + settings: req.body.settings || {}, + payload: req.body.payload || {}, + page: req.body.page || 1, + auth: req.body.auth || {}, + audienceSettings: req.body.audienceSettings || {}, + hookInputs: req.body.hookInputs || {}, + hookOutputs: req.body.hookOutputs || {} + } + + const action = destination.actions[actionSlug] + const result: ActionHookResponse = await action.executeHook(hookName as ActionHookType, data) + + if (result.error) { + throw result.error + } + + return res.status(200).json(result) + } catch (err) { + return res.status(500).json([err]) + } + }) + ) + + const inputFields = definition.hooks?.[hookName as ActionHookType]?.inputFields + const dynamicInputs: Record = {} + if (inputFields) { + for (const fieldKey in inputFields) { + const field = inputFields[fieldKey] + if (field.dynamic && typeof field.dynamic === 'function') { + dynamicInputs[fieldKey] = field.dynamic + } + } + } + + for (const fieldKey in dynamicInputs) { + router.post( + `/${actionSlug}/hooks/${hookName}/dynamic/${fieldKey}`, + asyncHandler(async (req: express.Request, res: express.Response) => { + try { + const data = { + settings: req.body.settings || {}, + payload: req.body.payload || {}, + page: req.body.page || 1, + auth: req.body.auth || {}, + audienceSettings: req.body.audienceSettings || {}, + hookInputs: req.body.hookInputs || {} + } + const action = destination.actions[actionSlug] + const dynamicFn = dynamicInputs[fieldKey] as RequestFn + const result = await action.executeDynamicField(fieldKey, data, dynamicFn) + + if (result.error) { + throw result.error + } + + return res.status(200).json(result) + } catch (err) { + return res.status(500).json([err]) + } + }) + ) + } + } + } } app.use(router) diff --git a/packages/cli/templates/destinations/browser/README.md b/packages/cli/templates/destinations/browser/README.md index f7df5cc2b9..08682bb533 100644 --- a/packages/cli/templates/destinations/browser/README.md +++ b/packages/cli/templates/destinations/browser/README.md @@ -6,7 +6,7 @@ The {{name}} browser action destination for use with @segment/analytics-next. MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/core/README.md b/packages/core/README.md index 1b09dd4b44..a62c077dbe 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,12 +1,12 @@ # @segment/actions-core -The core runtime engine for actions, including mapping-kit transforms. +The core runtime engine for actions, including mapping-kit transforms ## License MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/core/package.json b/packages/core/package.json index 9b6eccbbc5..f2feccaca2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@segment/actions-core", "description": "Core runtime for Destinations Actions.", - "version": "3.83.0", + "version": "3.103.0", "repository": { "type": "git", "url": "https://github.com/segmentio/fab-5-engine", @@ -80,9 +80,9 @@ }, "dependencies": { "@lukeed/uuid": "^2.0.0", - "@segment/action-emitters": "^1.1.2", - "@segment/ajv-human-errors": "^2.11.3", - "@segment/destination-subscriptions": "^3.28.3", + "@segment/action-emitters": "^1.3.6", + "@segment/ajv-human-errors": "^2.12.0", + "@segment/destination-subscriptions": "^3.33.0", "@types/node": "^18.11.15", "abort-controller": "^3.0.0", "aggregate-error": "^3.1.0", @@ -95,6 +95,11 @@ }, "jest": { "preset": "ts-jest", + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, "testEnvironment": "node", "modulePathIgnorePatterns": [ "/dist/" diff --git a/packages/core/src/__tests__/batching.test.ts b/packages/core/src/__tests__/batching.test.ts index 247da06bf5..bd44e4f6cf 100644 --- a/packages/core/src/__tests__/batching.test.ts +++ b/packages/core/src/__tests__/batching.test.ts @@ -75,7 +75,9 @@ describe('Batching', () => { test('basic happy path', async () => { const destination = new Destination(basicBatch) const res = await destination.onBatch(events, basicBatchSettings) - expect(res).toEqual(expect.arrayContaining([{ output: 'successfully processed batch of events' }])) + expect(res[0]).toMatchObject({ + output: 'Action Executed' + }) }) test('transforms all the payloads based on the subscription mapping', async () => { @@ -221,7 +223,7 @@ describe('Batching', () => { await expect(promise).resolves.toMatchInlineSnapshot(` Array [ Object { - "output": "successfully processed batch of events", + "output": "Action Executed", }, ] `) diff --git a/packages/core/src/__tests__/schema-validation.test.ts b/packages/core/src/__tests__/schema-validation.test.ts index d5a691c4e2..0e6f76841e 100644 --- a/packages/core/src/__tests__/schema-validation.test.ts +++ b/packages/core/src/__tests__/schema-validation.test.ts @@ -111,11 +111,12 @@ describe('validateSchema', () => { expect(payload).toMatchInlineSnapshot(`Object {}`) }) - it('should not throw when type = hidden', () => { + it('should not throw when hidden = true', () => { const hiddenSchema = fieldsToJsonSchema({ h: { label: 'h', - type: 'hidden' + type: 'string', + unsafe_hidden: true } }) diff --git a/packages/core/src/create-test-integration.ts b/packages/core/src/create-test-integration.ts index 4ec27cc4dd..c47afeea69 100644 --- a/packages/core/src/create-test-integration.ts +++ b/packages/core/src/create-test-integration.ts @@ -1,13 +1,13 @@ import { createTestEvent } from './create-test-event' import { StateContext, Destination, TransactionContext } from './destination-kit' import { mapValues } from './map-values' -import type { DestinationDefinition, StatsContext, Logger } from './destination-kit' +import type { DestinationDefinition, StatsContext, Logger, DataFeedCache, RequestFn } from './destination-kit' import type { JSONObject } from './json-object' import type { SegmentEvent } from './segment-event' import { AuthTokens } from './destination-kit/parse-settings' import { Features } from './mapping-kit' import { ExecuteDynamicFieldInput } from './destination-kit/action' -import { Result } from './destination-kit/types' +import { DynamicFieldResponse, Result } from './destination-kit/types' // eslint-disable-next-line @typescript-eslint/no-empty-function const noop = () => {} @@ -38,11 +38,12 @@ interface InputData { auth?: AuthTokens /** * The features available in the request based on the customer's sourceID; - * `features`, `stats`, `logger` , `transactionContext` and `stateContext` are for internal Twilio/Segment use only. + * `features`, `stats`, `logger`, `dataFeedCache`, and `transactionContext` and `stateContext` are for internal Twilio/Segment use only. */ features?: Features statsContext?: StatsContext logger?: Logger + dataFeedCache?: DataFeedCache transactionContext?: TransactionContext stateContext?: StateContext } @@ -55,8 +56,13 @@ class TestDestination extends Destination) { - return await super.executeDynamicField(action, fieldKey, data) + async testDynamicField( + action: string, + fieldKey: string, + data: ExecuteDynamicFieldInput, + dynamicFn?: RequestFn + ) { + return await super.executeDynamicField(action, fieldKey, data, dynamicFn) } /** Testing method that runs an action e2e while allowing slightly more flexible inputs */ @@ -71,6 +77,7 @@ class TestDestination extends Destination @@ -92,6 +99,7 @@ class TestDestination extends Destination extends Destination, 'event'> & { events?: SegmentEvent[] } @@ -138,6 +147,7 @@ class TestDestination extends Destination = T | Promise type RequestClient = ReturnType // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RequestFn = ( +export type RequestFn = ( request: RequestClient, - data: ExecuteInput + data: ExecuteInput ) => MaybePromise // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -49,8 +49,23 @@ export interface BaseActionDefinition { fields: Record } +type HookValueTypes = string | boolean | number | Array +type GenericActionHookValues = Record + +type GenericActionHookBundle = { + [K in ActionHookType]: { + inputs?: GenericActionHookValues + outputs?: GenericActionHookValues + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export interface ActionDefinition extends BaseActionDefinition { +export interface ActionDefinition< + Settings, + Payload = any, + AudienceSettings = any, + GeneratedActionHookBundle extends GenericActionHookBundle = any +> extends BaseActionDefinition { /** * A way to "register" dynamic fields. * This is likely going to change as we productionalize the data model and definition object @@ -64,6 +79,68 @@ export interface ActionDefinition + + /** Hooks are triggered at some point in a mappings lifecycle. They may perform a request with the + * destination using the provided inputs and return a response. The response may then optionally be stored + * in the mapping for later use in the action. + */ + hooks?: { + [K in ActionHookType]: ActionHookDefinition< + Settings, + Payload, + AudienceSettings, + GeneratedActionHookBundle[K]['outputs'], + GeneratedActionHookBundle[K]['inputs'] + > + } +} + +export const hookTypeStrings = ['onMappingSave'] as const +/** + * The supported actions hooks. + * on-mapping-save: Called when a mapping is saved by the user. The return from this method is then stored in the mapping. + */ +export type ActionHookType = typeof hookTypeStrings[number] +export interface ActionHookResponse { + /** A user-friendly message to be shown when the hook is successfully executed. */ + successMessage?: string + /** After successfully executing a hook, savedData will be persisted for later use in the action. */ + savedData?: GeneratedActionHookOutputs + error?: { + /** A user-friendly message to be shown when the hook errors. */ + message: string + code: string + } +} + +export interface ActionHookDefinition< + Settings, + Payload, + AudienceSettings, + GeneratedActionHookOutputs, + GeneratedActionHookTypesInputs +> { + /** The display title for this hook. */ + label: string + /** A description of what this hook does. */ + description: string + /** The configuration fields that are used when executing the hook. The values will be provided by users in the app. */ + inputFields?: Record< + string, + Omit & { + dynamic?: RequestFn + } + > + /** The shape of the return from performHook. These values will be available in the generated-types: Payload for use in perform() */ + outputTypes?: Record + /** The operation to perform when this hook is triggered. */ + performHook: RequestFn< + Settings, + Payload, + ActionHookResponse, + AudienceSettings, + GeneratedActionHookTypesInputs + > } export interface ExecuteDynamicFieldInput { @@ -72,18 +149,24 @@ export interface ExecuteDynamicFieldInput { +interface ExecuteBundle { data: Data settings: T audienceSettings?: AudienceSettings mapping: JSONObject auth: AuthTokens | undefined + hookOutputs?: Record /** For internal Segment/Twilio use only. */ features?: Features | undefined statsContext?: StatsContext | undefined logger?: Logger | undefined + dataFeedCache?: DataFeedCache | undefined transactionContext?: TransactionContext stateContext?: StateContext } @@ -96,7 +179,9 @@ export class Action readonly destinationName: string readonly schema?: JSONSchema4 + readonly hookSchemas?: Record readonly hasBatchSupport: boolean + readonly hasHookSupport: boolean // Payloads may be any type so we use `any` explicitly here. // eslint-disable-next-line @typescript-eslint/no-explicit-any private extendRequest: RequestExtension | undefined @@ -113,11 +198,41 @@ export class Action = {} + for (const key in hook.inputFields) { + const field = hook.inputFields[key] + + if (field.dynamic) { + castedInputFields[key] = { + ...field, + dynamic: true + } + } else { + castedInputFields[key] = { + ...field, + dynamic: false + } + } + } + + this.hookSchemas[hookName] = fieldsToJsonSchema(castedInputFields) + } + } + } } async execute(bundle: ExecuteBundle): Promise { @@ -138,6 +253,17 @@ export class Action): Promise { + async executeBatch(bundle: ExecuteBundle): Promise { + const results: Result[] = [{ output: 'Action Executed' }] + if (!this.hasBatchSupport) { throw new IntegrationError('This action does not support batched requests.', 'NotImplemented', 501) } @@ -184,7 +314,7 @@ export class Action + data: ExecuteDynamicFieldInput, + /** + * The dynamicFn argument is optional since it is only used by dynamic hook input fields. (For now) + */ + dynamicFn?: RequestFn ): Promise { - const fn = this.definition.dynamicFields?.[field] + let fn + if (dynamicFn && typeof dynamicFn === 'function') { + fn = dynamicFn + } else { + fn = this.definition.dynamicFields?.[field] + } + if (typeof fn !== 'function') { return Promise.resolve({ choices: [], @@ -225,6 +371,27 @@ export class Action + ): Promise> { + if (!this.hasHookSupport) { + throw new IntegrationError('This action does not support any hooks.', 'NotImplemented', 501) + } + const hookFn = this.definition.hooks?.[hookType]?.performHook + + if (!hookFn) { + throw new IntegrationError(`Missing implementation for hook: ${hookType}.`, 'NotImplemented', 501) + } + + if (this.hookSchemas?.[hookType]) { + const schema = this.hookSchemas[hookType] + validateSchema(data.hookInputs, schema) + } + + return (await this.performRequest(hookFn, data)) as ActionHookResponse + } + /** * Perform a request using the definition's request client * the given request function diff --git a/packages/core/src/destination-kit/fields-to-jsonschema.ts b/packages/core/src/destination-kit/fields-to-jsonschema.ts index 80a537f10a..38948485e3 100644 --- a/packages/core/src/destination-kit/fields-to-jsonschema.ts +++ b/packages/core/src/destination-kit/fields-to-jsonschema.ts @@ -6,7 +6,6 @@ function toJsonSchemaType(type: FieldTypeName): JSONSchema4TypeName | JSONSchema case 'string': case 'text': case 'password': - case 'hidden': return 'string' case 'datetime': return ['string', 'number'] diff --git a/packages/core/src/destination-kit/index.ts b/packages/core/src/destination-kit/index.ts index d5b13ac879..f794968524 100644 --- a/packages/core/src/destination-kit/index.ts +++ b/packages/core/src/destination-kit/index.ts @@ -1,7 +1,17 @@ import { validate, parseFql, ErrorCondition } from '@segment/destination-subscriptions' import { EventEmitterSlug } from '@segment/action-emitters' import type { JSONSchema4 } from 'json-schema' -import { Action, ActionDefinition, BaseActionDefinition, RequestFn, ExecuteDynamicFieldInput } from './action' +import { + Action, + ActionDefinition, + ActionHookDefinition, + ActionHookType, + hookTypeStrings, + ActionHookResponse, + BaseActionDefinition, + RequestFn, + ExecuteDynamicFieldInput +} from './action' import { time, duration } from '../time' import { JSONLikeObject, JSONObject, JSONValue } from '../json-object' import { SegmentEvent } from '../segment-event' @@ -9,7 +19,15 @@ import { fieldsToJsonSchema, MinimalInputField } from './fields-to-jsonschema' import createRequestClient, { RequestClient, ResponseError } from '../create-request-client' import { validateSchema } from '../schema-validation' import type { ModifiedResponse } from '../types' -import type { GlobalSetting, RequestExtension, ExecuteInput, Result, Deletion, DeletionPayload } from './types' +import type { + GlobalSetting, + RequestExtension, + ExecuteInput, + Result, + Deletion, + DeletionPayload, + DynamicFieldResponse +} from './types' import type { AllRequestOptions } from '../request-client' import { ErrorCodes, IntegrationError, InvalidAuthenticationError } from '../errors' import { AuthTokens, getAuthData, getOAuth2Data, updateOAuthSettings } from './parse-settings' @@ -17,7 +35,16 @@ import { InputData, Features } from '../mapping-kit' import { retry } from '../retry' import { HTTPError } from '..' -export type { BaseActionDefinition, ActionDefinition, ExecuteInput, RequestFn } +export type { + BaseActionDefinition, + ActionDefinition, + ActionHookDefinition, + ActionHookResponse, + ActionHookType, + ExecuteInput, + RequestFn +} +export { hookTypeStrings } export type { MinimalInputField } export { fieldsToJsonSchema } @@ -252,6 +279,7 @@ interface EventInput { readonly features?: Features readonly statsContext?: StatsContext readonly logger?: Logger + readonly dataFeedCache?: DataFeedCache readonly transactionContext?: TransactionContext readonly stateContext?: StateContext } @@ -266,6 +294,7 @@ interface BatchEventInput { readonly features?: Features readonly statsContext?: StatsContext readonly logger?: Logger + readonly dataFeedCache?: DataFeedCache readonly transactionContext?: TransactionContext readonly stateContext?: StateContext } @@ -281,6 +310,7 @@ interface OnEventOptions { features?: Features statsContext?: StatsContext logger?: Logger + readonly dataFeedCache?: DataFeedCache transactionContext?: TransactionContext stateContext?: StateContext /** Handler to perform synchronization. If set, the refresh access token method will be synchronized across @@ -334,6 +364,13 @@ export interface Logger { withTags(extraTags: any): void } +export interface DataFeedCache { + setRequestResponse(requestId: string, response: string, expiryInSeconds: number): Promise + getRequestResponse(requestId: string): Promise + maxResponseSizeBytes: number + maxExpirySeconds: number +} + export class Destination { readonly definition: DestinationDefinition readonly name: string @@ -510,6 +547,7 @@ export class Destination { features, statsContext, logger, + dataFeedCache, transactionContext, stateContext }: EventInput @@ -533,6 +571,7 @@ export class Destination { features, statsContext, logger, + dataFeedCache, transactionContext, stateContext }) @@ -548,6 +587,7 @@ export class Destination { features, statsContext, logger, + dataFeedCache, transactionContext, stateContext }: BatchEventInput @@ -563,7 +603,7 @@ export class Destination { audienceSettings = events[0].context?.personas?.audience_settings as AudienceSettings } - await action.executeBatch({ + return action.executeBatch({ mapping, data: events as unknown as InputData[], settings, @@ -572,24 +612,27 @@ export class Destination { features, statsContext, logger, + dataFeedCache, transactionContext, stateContext }) - - return [{ output: 'successfully processed batch of events' }] } public async executeDynamicField( actionSlug: string, fieldKey: string, - data: ExecuteDynamicFieldInput + data: ExecuteDynamicFieldInput, + /** + * The dynamicFn argument is optional since it is only used by dynamic hook input fields. (For now) + */ + dynamicFn?: RequestFn ) { const action = this.actions[actionSlug] if (!action) { return [] } - return action.executeDynamicField(fieldKey, data) + return action.executeDynamicField(fieldKey, data, dynamicFn) } private async onSubscription( @@ -608,6 +651,7 @@ export class Destination { features: options?.features || {}, statsContext: options?.statsContext || ({} as StatsContext), logger: options?.logger, + dataFeedCache: options?.dataFeedCache, transactionContext: options?.transactionContext, stateContext: options?.stateContext } diff --git a/packages/core/src/destination-kit/types.ts b/packages/core/src/destination-kit/types.ts index 5c30d8ee03..32215b5ab7 100644 --- a/packages/core/src/destination-kit/types.ts +++ b/packages/core/src/destination-kit/types.ts @@ -1,4 +1,4 @@ -import { StateContext, Logger, StatsContext, TransactionContext } from './index' +import { StateContext, Logger, StatsContext, TransactionContext, DataFeedCache, ActionHookType } from './index' import type { RequestOptions } from '../request-client' import type { JSONObject } from '../json-object' import { AuthTokens } from './parse-settings' @@ -16,7 +16,13 @@ export interface Result { data?: JSONObject | null } -export interface ExecuteInput { +export interface ExecuteInput< + Settings, + Payload, + AudienceSettings = unknown, + ActionHookInputs = any, + ActionHookOutputs = any +> { /** The subscription mapping definition */ readonly mapping?: JSONObject /** The global destination settings */ @@ -25,6 +31,10 @@ export interface ExecuteInput { readonly audienceSettings?: AudienceSettings /** The transformed input data, based on `mapping` + `event` (or `events` if batched) */ payload: Payload + /** Inputs into an actions hook performHook method */ + hookInputs?: ActionHookInputs + /** Stored outputs from an invokation of an actions hook */ + hookOutputs?: Partial> /** The page used in dynamic field requests */ page?: string /** The data needed in OAuth requests */ @@ -36,6 +46,7 @@ export interface ExecuteInput { readonly features?: Features readonly statsContext?: StatsContext readonly logger?: Logger + readonly dataFeedCache?: DataFeedCache readonly transactionContext?: TransactionContext readonly stateContext?: StateContext } @@ -83,19 +94,10 @@ export interface GlobalSetting { } /** The supported field type names */ -export type FieldTypeName = - | 'string' - | 'text' - | 'number' - | 'integer' - | 'datetime' - | 'boolean' - | 'password' - | 'object' - | 'hidden' - -/** The shape of an input field definition */ -export interface InputField { +export type FieldTypeName = 'string' | 'text' | 'number' | 'integer' | 'datetime' | 'boolean' | 'password' | 'object' + +/** Properties of an InputField which are involved in creating the generated-types.ts file */ +export interface InputFieldJSONSchema { /** A short, human-friendly label for the field */ label: string /** A human-friendly description of the field */ @@ -110,10 +112,6 @@ export interface InputField { additionalProperties?: boolean /** An optional default value for the field */ default?: FieldValue - /** A placeholder display value that suggests what to input */ - placeholder?: string - /** Whether or not the field supports dynamically fetching options */ - dynamic?: boolean /** * A predefined set of options for the setting. * Only relevant for `type: 'string'` or `type: 'number'`. @@ -154,7 +152,13 @@ export interface InputField { | 'uuid' // Universally Unique IDentifier according to RFC4122. | 'password' // hint to the UI to hide/obfuscate input strings | 'text' // longer strings +} +export interface InputField extends InputFieldJSONSchema { + /** A placeholder display value that suggests what to input */ + placeholder?: string + /** Whether or not the field supports dynamically fetching options */ + dynamic?: boolean /** * Determines the UI representation of the object field. Only applies to object types. * Key Value Editor: Users can specify individual object keys and their mappings, ideal for custom objects. @@ -178,6 +182,34 @@ export interface InputField { * locked out from editing an empty field. */ readOnly?: boolean + + /** + * Determines whether this field will be shown in the UI. This is useful for when some field becomes irrelevant based on + * the value of another field. + */ + depends_on?: DependsOnConditions +} + +/** + * A single condition defining whether a field should be shown. + * fieldKey: The field key in the fields object to look at + * operator: The operator to use when comparing the field value + * value: The value we expect that field to have, if undefined, we will match based on whether the field contains a value or not + */ +export interface Condition { + fieldKey: string + operator: 'is' | 'is_not' + value: Omit | Array> | undefined +} + +/** + * If match is not set, it will default to 'all' + * If match = 'any', then meeting any of the conditions defined will result in the field being shown. + * If match = 'all', then meeting all of the conditions defined will result in the field being shown. + */ +export interface DependsOnConditions { + match?: 'any' | 'all' + conditions: Condition[] } export type FieldValue = string | number | boolean | object | Directive diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 07638a9c16..b9ea1b8c93 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -13,7 +13,7 @@ export class IntegrationError extends CustomError { /** * @param message - a human-friendly message to display to users * @param code - error code/reason - * @param status - http status code (e.g. 400). + * @param status - http status code (e.g. 400) * - 4xx errors are not automatically retried, except for 408, 423, 429 * - 5xx are automatically retried, except for 501 */ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc08ad1ea0..2e23dfe8be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,31 @@ export { Destination, fieldsToJsonSchema } from './destination-kit' export { getAuthData } from './destination-kit/parse-settings' export { transform } from './mapping-kit' +export { + ArrayPathDirective, + CaseDirective, + Directive, + DirectiveMetadata, + FieldValue, + IfDirective, + LiteralDirective, + PathDirective, + PrimitiveValue, + ReplaceDirective, + TemplateDirective, + JSONDirective, + getFieldValue, + getFieldValueKeys, + isArrayPathDirective, + isCaseDirective, + isDirective, + isIfDirective, + isLiteralDirective, + isPathDirective, + isReplaceDirective, + isTemplateDirective, + isJSONDirective +} from './mapping-kit/value-keys' export { createTestEvent } from './create-test-event' export { createTestIntegration } from './create-test-integration' export { default as createInstance } from './request-client' @@ -29,6 +54,7 @@ export { default as fetch, Request, Response, Headers } from './fetch' export type { BaseActionDefinition, ActionDefinition, + ActionHookResponse, BaseDefinition, DestinationDefinition, AudienceDestinationDefinition, diff --git a/packages/core/src/mapping-kit/__tests__/flatten.test.ts b/packages/core/src/mapping-kit/__tests__/flatten.test.ts new file mode 100644 index 0000000000..b8307d7c64 --- /dev/null +++ b/packages/core/src/mapping-kit/__tests__/flatten.test.ts @@ -0,0 +1,134 @@ +import { flattenObject } from '../flatten' + +describe('flatten', () => { + it('flattens an object', () => { + const obj = { + a: { + b: { + c: 1 + }, + d: 2 + } + } + expect(flattenObject(obj)).toEqual({ + 'a.b.c': 1, + 'a.d': 2 + }) + }) + it('flattens an object with a custom separator', () => { + const obj = { + a: { + b: { + c: 1 + }, + d: 2 + } + } + expect(flattenObject(obj, '', '/')).toEqual({ + 'a/b/c': 1, + 'a/d': 2 + }) + }) + it('flattens an array', () => { + const obj = { + a: [ + { + b: 1 + }, + { + c: 2 + } + ] + } + expect(flattenObject(obj)).toEqual({ + 'a.0.b': 1, + 'a.1.c': 2 + }) + }) + it('flattens a deep nested structure', () => { + const obj = { + a: { + b: { + c: { + d: { + e: { + f: { + g: { + h: { + i: 1 + } + } + } + } + } + } + } + } + } + expect(flattenObject(obj)).toEqual({ + 'a.b.c.d.e.f.g.h.i': 1 + }) + }) + it('flattens a deep nested structure with deep nested arrays', () => { + const obj = { + a: { + b: { + c: [ + { + d: [ + { + e: [ + { + f: [ + { + g: [ + { + h: [ + { + i: 1 + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + } + expect(flattenObject(obj)).toEqual({ + 'a.b.c.0.d.0.e.0.f.0.g.0.h.0.i': 1 + }) + }) + it('flattens a structure starting with arrays of objects', () => { + const obj = [ + { + a: [ + { + b: [ + { + c: 1 + } + ] + }, + { + d: [ + { + e: 2 + } + ] + } + ] + } + ] + expect(flattenObject(obj)).toEqual({ + '0.a.0.b.0.c': 1, + '0.a.1.d.0.e': 2 + }) + }) +}) diff --git a/packages/core/src/mapping-kit/__tests__/index.iso.test.ts b/packages/core/src/mapping-kit/__tests__/index.iso.test.ts index 67f9d24866..44e42dc031 100644 --- a/packages/core/src/mapping-kit/__tests__/index.iso.test.ts +++ b/packages/core/src/mapping-kit/__tests__/index.iso.test.ts @@ -473,6 +473,81 @@ describe('@arrayPath', () => { }) }) +describe('@json', () => { + test('encode', () => { + const output = transform({ neat: { '@json': { mode: 'encode', value: { '@path': '$.foo' } } } }, { foo: 'bar' }) + expect(output).toStrictEqual({ neat: '"bar"' }) + }) + + test('encode_object', () => { + const output = transform( + { neat: { '@json': { mode: 'encode', value: { '@path': '$.foo' } } } }, + { foo: { bar: 'baz' } } + ) + expect(output).toStrictEqual({ neat: '{"bar":"baz"}' }) + }) + + test('encode_array', () => { + const output = transform( + { neat: { '@json': { mode: 'encode', value: { '@path': '$.foo' } } } }, + { foo: ['bar', 'baz'] } + ) + expect(output).toStrictEqual({ neat: '["bar","baz"]' }) + }) + + test('decode', () => { + const output = transform({ neat: { '@json': { mode: 'decode', value: { '@path': '$.foo' } } } }, { foo: '"bar"' }) + expect(output).toStrictEqual({ neat: 'bar' }) + }) + + test('decode_object', () => { + const output = transform( + { neat: { '@json': { mode: 'decode', value: { '@path': '$.foo' } } } }, + { foo: '{"bar":"baz"}' } + ) + expect(output).toStrictEqual({ neat: { bar: 'baz' } }) + }) + + test('decode_array', () => { + const output = transform( + { neat: { '@json': { mode: 'decode', value: { '@path': '$.foo' } } } }, + { foo: '["bar","baz"]' } + ) + expect(output).toStrictEqual({ neat: ['bar', 'baz'] }) + }) + + test('invalid mode', () => { + expect(() => { + transform({ neat: { '@json': { mode: 'oops', value: { '@path': '$.foo' } } } }, { foo: 'bar' }) + }).toThrowError() + }) + + test('invalid value', () => { + const output = transform( + { neat: { '@json': { mode: 'encode', value: { '@path': '$.bad' } } } }, + { foo: { bar: 'baz' } } + ) + expect(output).toStrictEqual({}) + }) +}) + +describe('@flatten', () => { + test('simple', () => { + const output = transform( + { neat: { '@flatten': { value: { '@path': '$.foo' }, separator: '.' } } }, + { foo: { bar: 'baz', aces: { a: 1, b: 2 } } } + ) + expect(output).toStrictEqual({ neat: { bar: 'baz', 'aces.a': 1, 'aces.b': 2 } }) + }) + test('array value first', () => { + const output = transform( + { result: { '@flatten': { value: { '@path': '$.foo' }, separator: '.' } } }, + { foo: [{ fazz: 'bar', fizz: 'baz' }] } + ) + expect(output).toStrictEqual({ result: { '0.fazz': 'bar', '0.fizz': 'baz' } }) + }) +}) + describe('@path', () => { test('simple', () => { const output = transform({ neat: { '@path': '$.foo' } }, { foo: 'bar' }) @@ -713,6 +788,74 @@ describe('@replace', () => { ) expect(output).toStrictEqual('different+things') }) + test('replace boolean', () => { + const payload = { + a: true + } + const output = transform( + { + '@replace': { + pattern: 'true', + replacement: 'granted', + value: { '@path': '$.a' } + } + }, + payload + ) + expect(output).toStrictEqual('granted') + }) + test('replace number', () => { + const payload = { + a: 1 + } + const output = transform( + { + '@replace': { + pattern: '1', + replacement: 'granted', + value: { '@path': '$.a' } + } + }, + payload + ) + expect(output).toStrictEqual('granted') + }) + test('replace 2 values', () => { + const payload = { + a: 'something-great!' + } + const output = transform( + { + '@replace': { + pattern: '-', + replacement: ' ', + pattern2: 'great', + replacement2: 'awesome', + value: { '@path': '$.a' } + } + }, + payload + ) + expect(output).toStrictEqual('something awesome!') + }) + test('replace with 2 values but only second one exists', () => { + const payload = { + a: false + } + const output = transform( + { + '@replace': { + pattern: 'true', + replacement: 'granted', + pattern2: 'false', + replacement2: 'denied', + value: { '@path': '$.a' } + } + }, + payload + ) + expect(output).toStrictEqual('denied') + }) }) describe('remove undefined values in objects', () => { diff --git a/packages/core/src/mapping-kit/__tests__/value-keys.test.ts b/packages/core/src/mapping-kit/__tests__/value-keys.test.ts new file mode 100644 index 0000000000..937ff09964 --- /dev/null +++ b/packages/core/src/mapping-kit/__tests__/value-keys.test.ts @@ -0,0 +1,102 @@ +import { getFieldValueKeys } from '../value-keys' + +describe('getFieldValueKeys', () => { + it('should return empty [] for strings', () => { + const value = 'https://webhook.site/very-legit' + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual([]) + }) + + it('should return empty [] for booleans', () => { + const value = false + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual([]) + }) + + it('should return empty [] for empty objects', () => { + const value = {} + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual([]) + }) + + it('should return correct keys for single @path', () => { + const value = { + '@path': '$.properties.string' + } + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual(['$.properties.string']) + }) + + it('should return correct keys for single @templates', () => { + const value = { + value: { + '@template': '{{__segment_entities.log-test-1.ENTITIES_TEST.PRODUCTS.NAME}}' + } + } + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual(['__segment_entities.log-test-1.ENTITIES_TEST.PRODUCTS.NAME']) + }) + + it('should return correct keys for multiple @templates', () => { + const value = { + value: { + '@template': '{{__segment_entities.log-test-1.ENTITIES_TEST.PRODUCTS.NAME}}-{{test}}' + } + } + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual(['__segment_entities.log-test-1.ENTITIES_TEST.PRODUCTS.NAME', 'test']) + }) + + it('should return correct keys for objects', () => { + const value = { + Category: { + '@template': '{{__segment_entities.log-test-1.ENTITIES_TEST.PRODUCTS.CATEGORY}}' + }, + Enriched_ID: 'test', + Name: { + '@path': '$.properties.string' + } + } + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual(['__segment_entities.log-test-1.ENTITIES_TEST.PRODUCTS.CATEGORY', '$.properties.string']) + }) + + it('should return correct keys for @arrayPath (not yet supported for enrichments)', () => { + const value = { + '@arrayPath': [{ '@template': '{{properties.products}}' }, { productId: { '@template': '{{productId}}' } }] + } + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual(['properties.products', 'productId']) + }) + + it('should return correct keys for @json', () => { + const value = { + '@json': { + mode: 'encode', + value: { + '@template': '{{properties.products}}' + } + } + } + + const keys = getFieldValueKeys(value) + + expect(keys).toEqual(['properties.products']) + }) +}) diff --git a/packages/core/src/mapping-kit/flatten.ts b/packages/core/src/mapping-kit/flatten.ts new file mode 100644 index 0000000000..fb8a06ed6c --- /dev/null +++ b/packages/core/src/mapping-kit/flatten.ts @@ -0,0 +1,16 @@ +import { JSONLike, JSONLikeObject } from '../json-object' +import { isArray, isObject } from '../real-type-of' + +export const flattenObject = (input: JSONLike, prefix = '', separator = '.'): JSONLikeObject => { + //input may be a primitive value, object, or array + if (isObject(input) || isArray(input)) { + return Object.entries(input).reduce((acc, [key, value]) => { + const newKey = prefix ? `${prefix}${separator}${key}` : key + return { + ...acc, + ...flattenObject(value, newKey, separator) + } + }, {}) + } + return { [prefix]: input } +} diff --git a/packages/core/src/mapping-kit/index.ts b/packages/core/src/mapping-kit/index.ts index ff9f52460e..b4209f0fc0 100644 --- a/packages/core/src/mapping-kit/index.ts +++ b/packages/core/src/mapping-kit/index.ts @@ -6,6 +6,7 @@ import { realTypeOf, isObject, isArray } from '../real-type-of' import { removeUndefined } from '../remove-undefined' import validate from './validate' import { arrify } from '../arrify' +import { flattenObject } from './flatten' export type InputData = { [key: string]: unknown } export type Features = { [key: string]: boolean } @@ -102,6 +103,23 @@ registerDirective('@case', (opts, payload) => { export const MAX_PATTERN_LENGTH = 10 export const MAX_REPLACEMENT_LENGTH = 10 + +function performReplace(value: string, pattern: string, replacement: string, flags: string) { + if (pattern.length > MAX_PATTERN_LENGTH) { + throw new Error(`@replace requires a "pattern" less than ${MAX_PATTERN_LENGTH} characters`) + } + + if (replacement.length > MAX_REPLACEMENT_LENGTH) { + throw new Error(`@replace requires a "replacement" less than ${MAX_REPLACEMENT_LENGTH} characters`) + } + + // We don't want users providing regular expressions for the pattern (for now) + // https://stackoverflow.com/questions/F3115150/how-to-escape-regular-expression-special-characters-using-javascript + pattern = pattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + + return value.replace(new RegExp(pattern, flags), replacement) +} + registerDirective('@replace', (opts, payload) => { if (!isObject(opts)) { throw new Error('@replace requires an object with a "pattern" key') @@ -117,6 +135,12 @@ registerDirective('@replace', (opts, payload) => { opts.replacement = '' } + // Assume null/missing replacement means empty for pattern2 if exists + if (opts.pattern2 && opts.replacement2 == null) { + // Empty replacement string is ok + opts.replacement2 = '' + } + // case sensitive by default if this key is missing if (opts.ignorecase == null) { opts.ignorecase = false @@ -127,12 +151,19 @@ registerDirective('@replace', (opts, payload) => { opts.global = true } - let pattern = opts.pattern + const pattern = opts.pattern const replacement = opts.replacement const ignorecase = opts.ignorecase const isGlobal = opts.global if (opts.value) { - const value = resolve(opts.value, payload) + let value = resolve(opts.value, payload) + let new_value = '' + + // We want to be able to replace values that are boolean or numbers + if (typeof value === 'boolean' || typeof value === 'number') { + value = String(value) + } + if ( typeof value === 'string' && typeof pattern === 'string' && @@ -140,17 +171,6 @@ registerDirective('@replace', (opts, payload) => { typeof ignorecase === 'boolean' && typeof isGlobal === 'boolean' ) { - if (pattern.length > MAX_PATTERN_LENGTH) { - throw new Error(`@replace requires a "pattern" less than ${MAX_PATTERN_LENGTH} characters`) - } - - if (replacement.length > MAX_REPLACEMENT_LENGTH) { - throw new Error(`@replace requires a "replacement" less than ${MAX_REPLACEMENT_LENGTH} characters`) - } - - // We don't want users providing regular expressions for the pattern (for now) - // https://stackoverflow.com/questions/F3115150/how-to-escape-regular-expression-special-characters-using-javascript - pattern = pattern.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') let flags = '' if (isGlobal) { flags += 'g' @@ -158,8 +178,16 @@ registerDirective('@replace', (opts, payload) => { if (ignorecase) { flags += 'i' } - return value.replace(new RegExp(pattern, flags), replacement) + + new_value = performReplace(value, pattern, replacement, flags) + + // If pattern2 exists, replace the new_value with replacement2 + if (opts.pattern2 && typeof opts.pattern2 === 'string' && typeof opts.replacement2 === 'string') { + new_value = performReplace(new_value, opts.pattern2, opts.replacement2, flags) + } } + + return new_value } }) @@ -196,6 +224,49 @@ registerDirective('@literal', (value, payload) => { return resolve(value, payload) }) +registerDirective('@flatten', (opts, payload) => { + if (!isObject(opts)) { + throw new Error('@flatten requires an object with a "separator" key') + } + + if (!opts.separator) { + throw new Error('@flatten requires a "separator" key') + } + + const separator = resolve(opts.separator, payload) + if (typeof separator !== 'string') { + throw new Error('@flatten requires a string separator') + } + + const value = resolve(opts.value, payload) + + return flattenObject(value, '', separator) +}) + +registerDirective('@json', (opts, payload) => { + if (!isObject(opts)) { + throw new Error('@json requires an object with a "value" key') + } + + if (!opts.mode) { + throw new Error('@json requires a "mode" key') + } + + if (!opts.value) { + throw new Error('@json requires a "value" key') + } + + const value = resolve(opts.value, payload) + if (opts.mode === 'encode') { + return JSON.stringify(value) + } else if (opts.mode === 'decode') { + if (typeof value === 'string') { + return JSON.parse(value) + } + return value + } +}) + /** * Resolves a mapping value/object by applying the input payload based on directives * @param mapping - the mapping directives or raw values to resolve @@ -230,7 +301,7 @@ function resolve(mapping: JSONLike, payload: JSONObject): JSONLike { * @param mapping - the directives and raw values * @param data - the input data to apply to directives */ -export function transform(mapping: JSONLikeObject, data: InputData | undefined = {}): JSONObject { +function transform(mapping: JSONLikeObject, data: InputData | undefined = {}): JSONObject { const realType = realTypeOf(data) if (realType !== 'object') { throw new Error(`data must be an object, got ${realType}`) @@ -251,7 +322,7 @@ export function transform(mapping: JSONLikeObject, data: InputData | undefined = * @param mapping - the directives and raw values * @param data - the array input data to apply to directives */ -export function transformBatch(mapping: JSONLikeObject, data: Array | undefined = []): JSONObject[] { +function transformBatch(mapping: JSONLikeObject, data: Array | undefined = []): JSONObject[] { const realType = realTypeOf(data) if (!isArray(data)) { throw new Error(`data must be an array, got ${realType}`) @@ -265,3 +336,5 @@ export function transformBatch(mapping: JSONLikeObject, data: Array | // Cast because we know there are no `undefined` values after `removeUndefined` return removeUndefined(resolved) as JSONObject[] } + +export { transform, transformBatch } diff --git a/packages/core/src/mapping-kit/validate.ts b/packages/core/src/mapping-kit/validate.ts index 26f96aa70a..71ddcc4af5 100644 --- a/packages/core/src/mapping-kit/validate.ts +++ b/packages/core/src/mapping-kit/validate.ts @@ -136,6 +136,16 @@ function validateString(v: unknown, stack: string[] = []) { return } +function validateAllowedStrings(...allowed: string[]) { + return (v: unknown, stack: string[] = []) => { + validateString(v, stack) + const str = v as string + if (!allowed.includes(str.toLowerCase())) { + throw new ValidationError(`should be one of ${allowed.join(', ')} but it is ${JSON.stringify(str)}`, stack) + } + } +} + function validateBoolean(v: unknown, stack: string[] = []) { const type = realTypeOrDirective(v) if (type !== 'boolean') { @@ -166,7 +176,7 @@ function validateObject(value: unknown, stack: string[] = []) { try { validate(obj[k], [...stack, k]) } catch (e) { - errors.push(e) + errors.push(e as Error) } }) @@ -201,7 +211,7 @@ function validateObjectWithFields(input: unknown, fields: ValidateFields, stack: } } } catch (error) { - errors.push(error) + errors.push(error as Error) } }) @@ -227,11 +237,12 @@ function directive(names: string[] | string, fn: DirectiveValidator): void { try { fn(v, [...stack, name]) } catch (e) { + const err: Error = e as Error if (e instanceof ValidationError || e instanceof AggregateError) { throw e } - throw new ValidationError(e.message, stack) + throw new ValidationError(err.message, stack) } } }) @@ -285,6 +296,28 @@ directive('@path', (v, stack) => { validateDirectiveOrString(v, stack) }) +directive('@json', (v, stack) => { + validateObjectWithFields( + v, + { + value: { required: validateDirectiveOrRaw }, + mode: { required: validateAllowedStrings('encode', 'decode') } + }, + stack + ) +}) + +directive('@flatten', (v, stack) => { + validateObjectWithFields( + v, + { + separator: { optional: validateString }, + value: { required: validateDirectiveOrRaw } + }, + stack + ) +}) + directive('@template', (v, stack) => { validateDirectiveOrString(v, stack) }) diff --git a/packages/core/src/mapping-kit/value-keys.ts b/packages/core/src/mapping-kit/value-keys.ts new file mode 100644 index 0000000000..35d6895393 --- /dev/null +++ b/packages/core/src/mapping-kit/value-keys.ts @@ -0,0 +1,268 @@ +type ValueType = 'enrichment' | 'function' | 'literal' | 'variable' + +function isObject(value: any): value is object { + return value !== null && typeof value === 'object' +} + +export interface DirectiveMetadata { + _metadata?: { + label?: string + type: ValueType + } +} + +export function isDirective(value: FieldValue): value is Directive { + return ( + value !== null && + typeof value === 'object' && + Object.keys(value).some((key) => + ['@if', '@path', '@template', '@literal', '@arrayPath', '@case', '@replace', '@json', '@flatten'].includes(key) + ) + ) +} + +export interface LiteralDirective extends DirectiveMetadata { + '@literal': PrimitiveValue | Record +} + +export function isLiteralDirective(value: FieldValue): value is LiteralDirective { + return isDirective(value) && '@literal' in value +} +export interface TemplateDirective extends DirectiveMetadata { + '@template': string +} + +export function isTemplateDirective(value: FieldValue): value is TemplateDirective { + return isDirective(value) && '@template' in value +} + +export function getFieldValue(value: FieldValue): unknown { + if (isTemplateDirective(value)) { + return value['@template'] + } + + return value +} + +export interface PathDirective extends DirectiveMetadata { + '@path': string +} + +export function isPathDirective(value: FieldValue): value is PathDirective { + return isDirective(value) && '@path' in value +} + +type RequireOnlyOne = { + [K in Keys]-?: Partial, undefined>> & Required> +}[Keys] & + Pick> + +export interface IfDirective extends DirectiveMetadata { + '@if': RequireOnlyOne< + { + blank?: FieldValue + else?: FieldValue + exists?: FieldValue + then: FieldValue + }, + 'blank' | 'exists' + > +} + +export function isIfDirective(value: FieldValue): value is IfDirective { + return ( + isDirective(value) && + '@if' in value && + value['@if'] !== null && + typeof value['@if'] === 'object' && + ('exists' in value['@if'] || 'blank' in value['@if']) + ) +} + +export interface ArrayPathDirective extends DirectiveMetadata { + '@arrayPath': [Directive | string, { [key: string]: FieldValue } | undefined] | [Directive | string] +} + +export function isArrayPathDirective(value: FieldValue): value is ArrayPathDirective { + return isDirective(value) && '@arrayPath' in value && Array.isArray(value['@arrayPath']) +} + +export interface CaseDirective extends DirectiveMetadata { + '@case': { + operator: string + value?: FieldValue + } +} + +export function isCaseDirective(value: FieldValue): value is CaseDirective { + return ( + isDirective(value) && + '@case' in value && + value['@case'] !== null && + typeof value['@case'] === 'object' && + 'operator' in value['@case'] + ) +} + +export interface ReplaceDirective extends DirectiveMetadata { + '@replace': { + global: PrimitiveValue + ignorecase: PrimitiveValue + pattern: string + replacement: string + pattern2: string + replacement2: string + value?: FieldValue + } +} + +export function isReplaceDirective(value: FieldValue): value is ReplaceDirective { + return ( + isDirective(value) && + '@replace' in value && + value['@replace'] !== null && + typeof value['@replace'] === 'object' && + 'pattern' in value['@replace'] + ) +} + +export interface JSONDirective extends DirectiveMetadata { + '@json': { + value: FieldValue + mode: PrimitiveValue + } +} + +export function isJSONDirective(value: FieldValue): value is JSONDirective { + return ( + isDirective(value) && + '@json' in value && + value['@json'] !== null && + typeof value['@json'] === 'object' && + 'value' in value['@json'] + ) +} + +export interface FlattenDirective extends DirectiveMetadata { + '@flatten': { + value: FieldValue + separator: string + } +} + +export function isFlattenDirective(value: FieldValue): value is FlattenDirective { + return ( + isDirective(value) && + '@flatten' in value && + value['@flatten'] !== null && + typeof value['@flatten'] === 'object' && + 'value' in value['@flatten'] + ) +} + +type DirectiveKeysToType = { + ['@arrayPath']: (input: ArrayPathDirective) => T + ['@case']: (input: CaseDirective) => T + ['@if']: (input: IfDirective) => T + ['@literal']: (input: LiteralDirective) => T + ['@path']: (input: PathDirective) => T + ['@replace']: (input: ReplaceDirective) => T + ['@template']: (input: TemplateDirective) => T + ['@json']: (input: JSONDirective) => T + ['@flatten']: (input: FlattenDirective) => T +} + +function directiveType(directive: Directive, checker: DirectiveKeysToType): T | null { + if (isArrayPathDirective(directive)) { + return checker['@arrayPath'](directive) + } + if (isCaseDirective(directive)) { + return checker['@case'](directive) + } + if (isIfDirective(directive)) { + return checker['@if'](directive) + } + if (isLiteralDirective(directive)) { + return checker['@literal'](directive) + } + if (isPathDirective(directive)) { + return checker['@path'](directive) + } + if (isReplaceDirective(directive)) { + return checker['@replace'](directive) + } + if (isTemplateDirective(directive)) { + return checker['@template'](directive) + } + if (isJSONDirective(directive)) { + return checker['@json'](directive) + } + if (isFlattenDirective(directive)) { + return checker['@flatten'](directive) + } + return null +} + +export type Directive = + | ArrayPathDirective + | CaseDirective + | IfDirective + | LiteralDirective + | PathDirective + | ReplaceDirective + | TemplateDirective + | JSONDirective + | FlattenDirective + +export type PrimitiveValue = boolean | number | string | null +export type FieldValue = Directive | PrimitiveValue | { [key: string]: FieldValue } | FieldValue[] | undefined + +/** + * @param value + * @returns an array containing all keys of nested @directives + */ +export function getFieldValueKeys(value: FieldValue): string[] { + if (isDirective(value)) { + return ( + directiveType(value, { + '@arrayPath': (input: ArrayPathDirective) => input['@arrayPath'].flatMap(getRawKeys), + '@case': (input: CaseDirective) => getRawKeys(input['@case'].value), + '@if': (input: IfDirective) => [ + ...getRawKeys(input['@if'].blank), + ...getRawKeys(input['@if'].exists), + ...getRawKeys(input['@if'].then), + ...getRawKeys(input['@if'].else) + ], + '@literal': (_: LiteralDirective) => [''], + '@path': (input: PathDirective) => [input['@path']], + '@replace': (input: ReplaceDirective) => getRawKeys(input['@replace'].value), + '@template': (input: TemplateDirective) => getTemplateKeys(input['@template']), + '@json': (input: JSONDirective) => getRawKeys(input['@json'].value), + '@flatten': (input: FlattenDirective) => getRawKeys(input['@flatten'].value) + })?.filter((k) => k) ?? [] + ) + } else if (isObject(value)) { + return Object.values(value).flatMap(getFieldValueKeys) + } + return [] +} + +/** + * Function to get raw keys from a FieldValue + */ +export function getRawKeys(input: FieldValue): string[] { + if (isDirective(input)) { + return getFieldValueKeys(input) + } else if (isObject(input)) { + return Object.values(input).flatMap(getFieldValueKeys) + } + return [] +} + +/** + * Function to grab all values between any set of {{}} in a string + */ +export function getTemplateKeys(input: string): string[] { + const regex = /{{(.*?)}}/g + return Array.from(input.matchAll(regex), (m) => m[1]) +} diff --git a/packages/destination-actions/README.md b/packages/destination-actions/README.md index 070bac1644..5e689b2887 100644 --- a/packages/destination-actions/README.md +++ b/packages/destination-actions/README.md @@ -6,7 +6,7 @@ Destination definitions and their actions MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/packages/destination-actions/package.json b/packages/destination-actions/package.json index 39d2b461ec..f891d2c724 100644 --- a/packages/destination-actions/package.json +++ b/packages/destination-actions/package.json @@ -1,7 +1,7 @@ { "name": "@segment/action-destinations", "description": "Destination Actions engine and definitions.", - "version": "3.218.0", + "version": "3.254.0", "repository": { "type": "git", "url": "https://github.com/segmentio/action-destinations", @@ -23,11 +23,11 @@ "registry": "https://registry.npmjs.org" }, "scripts": { - "build": "yarn clean && yarn tsc -b tsconfig.build.json", + "build": "yarn tsc -b tsconfig.build.json", "clean": "tsc -b tsconfig.build.json --clean", "postclean": "rm -rf dist", "prepublishOnly": "yarn build", - "test": "jest --coverage", + "test": "jest", "typecheck": "tsc -p tsconfig.build.json --noEmit" }, "devDependencies": { @@ -39,9 +39,12 @@ }, "dependencies": { "@amplitude/ua-parser-js": "^0.7.25", + "@bufbuild/buf": "^1.28.0", + "@bufbuild/protobuf": "^1.4.2", + "@bufbuild/protoc-gen-es": "^1.4.2", "@segment/a1-notation": "^2.1.4", - "@segment/actions-core": "^3.83.0", - "@segment/actions-shared": "^1.65.0", + "@segment/actions-core": "^3.103.0", + "@segment/actions-shared": "^1.84.0", "@types/node": "^18.11.15", "ajv-formats": "^2.1.1", "aws4": "^1.12.0", @@ -49,12 +52,18 @@ "dayjs": "^1.10.7", "escape-goat": "^3", "google-libphonenumber": "^3.2.31", + "kafkajs": "^2.2.4", "liquidjs": "^9.37.0", "lodash": "^4.17.21", "ssh2-sftp-client": "^9.1.0" }, "jest": { "preset": "ts-jest", + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, "testEnvironment": "node", "modulePathIgnorePatterns": [ "/dist/" diff --git a/packages/destination-actions/src/destinations/absmartly/__tests__/exposure.test.ts b/packages/destination-actions/src/destinations/absmartly/__tests__/exposure.test.ts index 121fa89cec..a900a07b5d 100644 --- a/packages/destination-actions/src/destinations/absmartly/__tests__/exposure.test.ts +++ b/packages/destination-actions/src/destinations/absmartly/__tests__/exposure.test.ts @@ -8,14 +8,14 @@ jest.mock('../event') describe('sendExposure()', () => { const settings = { collectorEndpoint: 'http://test.com', environment: 'dev', apiKey: 'testkey' } const payload: ExposurePayload = { - publishedAt: '2023-01-01T00:00:00.3Z', application: 'testapp', agent: 'test-sdk', exposure: { + publishedAt: 1672531200900, units: [{ type: 'anonymousId', value: 'testid' }], - exposures: [{ experiment: 'testexp', variant: 'testvar' }], + exposures: [{ experiment: 'testexp', variant: 'testvar', exposedAt: 1672531200300 }], goals: [], - attributes: [{ name: 'testattr', value: 'testval', setAt: 1238128318 }] + attributes: [{ name: 'testattr', value: 'testval', setAt: 1672531200200 }] } } @@ -25,6 +25,7 @@ describe('sendExposure()', () => { expect(() => sendExposure( request, + 1672531300000, { ...payload, exposure: { ...payload.exposure, units: null } @@ -35,6 +36,7 @@ describe('sendExposure()', () => { expect(() => sendExposure( request, + 1672531300000, { ...payload, exposure: { ...payload.exposure, units: [] } @@ -50,6 +52,7 @@ describe('sendExposure()', () => { expect(() => sendExposure( request, + 1672531300000, { ...payload, exposure: { ...payload.exposure, exposures: null } @@ -60,6 +63,7 @@ describe('sendExposure()', () => { expect(() => sendExposure( request, + 1672531300000, { ...payload, exposure: { ...payload.exposure, exposures: [] } @@ -75,6 +79,7 @@ describe('sendExposure()', () => { expect(() => sendExposure( request, + 1672531300000, { ...payload, exposure: { ...payload.exposure, goals: [{}] } @@ -84,31 +89,21 @@ describe('sendExposure()', () => { ).toThrowError(PayloadValidationError) }) - it('should throw on invalid publishedAt', async () => { + it('should pass-through the exposure payload with adjusted timestamps', async () => { const request = jest.fn() - expect(() => sendExposure(request, { ...payload, publishedAt: 0 }, settings)).toThrowError(PayloadValidationError) - expect(() => - sendExposure( - request, - { - ...payload, - publishedAt: 'invalid date' - }, - settings - ) - ).toThrowError(PayloadValidationError) - }) - - it('should pass-through the exposure payload with adjusted publishedAt', async () => { - const request = jest.fn() - - await sendExposure(request, payload, settings) + await sendExposure(request, 1672531300000, payload, settings) expect(sendEvent).toHaveBeenCalledWith( request, settings, - { ...payload.exposure, publishedAt: 1672531200300 }, + { + ...payload.exposure, + historic: true, + publishedAt: 1672531300000, + exposures: [{ ...payload.exposure.exposures[0], exposedAt: 1672531299400 }], + attributes: [{ ...payload.exposure.attributes[0], setAt: 1672531299300 }] + }, payload.agent, payload.application ) diff --git a/packages/destination-actions/src/destinations/absmartly/__tests__/goal.test.ts b/packages/destination-actions/src/destinations/absmartly/__tests__/goal.test.ts index 8eff72495d..e41618247c 100644 --- a/packages/destination-actions/src/destinations/absmartly/__tests__/goal.test.ts +++ b/packages/destination-actions/src/destinations/absmartly/__tests__/goal.test.ts @@ -12,8 +12,6 @@ describe('sendGoal()', () => { anonymousId: 'testid' }, name: 'testgoal', - publishedAt: '2023-01-01T00:00:00.3Z', - achievedAt: '2023-01-01T00:00:00.000000Z', application: 'testapp', agent: 'test-sdk', properties: { @@ -24,10 +22,13 @@ describe('sendGoal()', () => { it('should throw on missing name', async () => { const request = jest.fn() - expect(() => sendGoal(request, { ...payload, name: '' }, settings)).toThrowError(PayloadValidationError) + expect(() => sendGoal(request, 1672531300000, { ...payload, name: '' }, settings)).toThrowError( + PayloadValidationError + ) expect(() => sendGoal( request, + 1672531300000, { ...payload, name: null @@ -38,6 +39,7 @@ describe('sendGoal()', () => { expect(() => sendGoal( request, + 1672531300000, { ...payload, name: undefined @@ -47,44 +49,13 @@ describe('sendGoal()', () => { ).toThrowError(PayloadValidationError) }) - it('should throw on invalid publishedAt', async () => { - const request = jest.fn() - - expect(() => sendGoal(request, { ...payload, publishedAt: 0 }, settings)).toThrowError(PayloadValidationError) - expect(() => - sendGoal( - request, - { - ...payload, - publishedAt: 'invalid date' - }, - settings - ) - ).toThrowError(PayloadValidationError) - }) - - it('should throw on invalid achievedAt', async () => { - const request = jest.fn() - - expect(() => sendGoal(request, { ...payload, achievedAt: 0 }, settings)).toThrowError(PayloadValidationError) - expect(() => - sendGoal( - request, - { - ...payload, - achievedAt: 'invalid date' - }, - settings - ) - ).toThrowError(PayloadValidationError) - }) - it('should throw on invalid properties', async () => { const request = jest.fn() expect(() => sendGoal( request, + 1672531300000, { ...payload, properties: 'bleh' @@ -95,6 +66,7 @@ describe('sendGoal()', () => { expect(() => sendGoal( request, + 1672531300000, { ...payload, properties: 0 @@ -107,18 +79,19 @@ describe('sendGoal()', () => { it('should send event with correct format', async () => { const request = jest.fn() - await sendGoal(request, payload, settings) + await sendGoal(request, 1672531300000, payload, settings) expect(sendEvent).toHaveBeenCalledWith( request, settings, { - publishedAt: 1672531200300, + historic: true, + publishedAt: 1672531300000, units: mapUnits(payload), goals: [ { name: payload.name, - achievedAt: 1672531200000, + achievedAt: 1672531300000, properties: payload.properties ?? null } ] diff --git a/packages/destination-actions/src/destinations/absmartly/__tests__/timestamp.test.ts b/packages/destination-actions/src/destinations/absmartly/__tests__/timestamp.test.ts index 8fac1e2770..d040998298 100644 --- a/packages/destination-actions/src/destinations/absmartly/__tests__/timestamp.test.ts +++ b/packages/destination-actions/src/destinations/absmartly/__tests__/timestamp.test.ts @@ -36,4 +36,11 @@ describe('unixTimestampOf()', () => { expect(unixTimestampOf('2023-01-01T00:00:00.00345Z')).toBe(1672531200003) expect(unixTimestampOf('2023-01-01T00:00:00.003456Z')).toBe(1672531200003) }) + + it('should convert Date to number representing Unix timestamp in milliseconds', async () => { + expect(unixTimestampOf(new Date('2000-01-01T00:00:00Z'))).toBe(946684800000) + expect(unixTimestampOf(new Date('2023-01-01T00:00:00.003Z'))).toBe(1672531200003) + expect(unixTimestampOf(new Date('2023-01-01T00:00:00.00345Z'))).toBe(1672531200003) + expect(unixTimestampOf(new Date('2023-01-01T00:00:00.003456Z'))).toBe(1672531200003) + }) }) diff --git a/packages/destination-actions/src/destinations/absmartly/event.ts b/packages/destination-actions/src/destinations/absmartly/event.ts index a45074afc4..cf64f4544d 100644 --- a/packages/destination-actions/src/destinations/absmartly/event.ts +++ b/packages/destination-actions/src/destinations/absmartly/event.ts @@ -1,4 +1,4 @@ -import { InputField, JSONObject, ModifiedResponse, RequestClient } from '@segment/actions-core' +import { InputField, ModifiedResponse, RequestClient } from '@segment/actions-core' import { Settings } from './generated-types' import { PublishRequestUnit } from './unit' import { PublishRequestAttribute } from './attribute' @@ -6,30 +6,26 @@ import { PublishRequestGoal } from './goal' import { Data } from 'ws' export interface PublishRequestEvent { + historic?: boolean publishedAt: number units: PublishRequestUnit[] goals?: PublishRequestGoal[] - exposures?: JSONObject[] + exposures?: { + name: string + variant: number + exposedAt: number + assigned: boolean + eligible: boolean + }[] attributes?: PublishRequestAttribute[] } export interface DefaultPayload { - publishedAt: string | number agent?: string application?: string } export const defaultEventFields: Record = { - publishedAt: { - label: 'Event Sent Time', - type: 'datetime', - required: true, - description: - 'Exact timestamp when the event was sent (measured by the client clock). Must be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number', - default: { - '@path': '$.sentAt' - } - }, agent: { label: 'Agent', type: 'string', diff --git a/packages/destination-actions/src/destinations/absmartly/exposure.ts b/packages/destination-actions/src/destinations/absmartly/exposure.ts index ddfe383a07..110f4f2cda 100644 --- a/packages/destination-actions/src/destinations/absmartly/exposure.ts +++ b/packages/destination-actions/src/destinations/absmartly/exposure.ts @@ -1,4 +1,10 @@ -import { InputField, ModifiedResponse, PayloadValidationError, RequestClient } from '@segment/actions-core' +import { + InputField, + JSONPrimitive, + ModifiedResponse, + PayloadValidationError, + RequestClient +} from '@segment/actions-core' import { defaultEventFields, DefaultPayload, PublishRequestEvent, sendEvent } from './event' import { Settings } from './generated-types' import { isValidTimestamp, unixTimestampOf } from './timestamp' @@ -23,11 +29,18 @@ export const defaultExposureFields: Record = { ...defaultEventFields } -function isValidExposure(exposure?: PublishRequestEvent | Record): exposure is PublishRequestEvent { +function isValidExposureRequest( + exposure?: PublishRequestEvent | Record +): exposure is PublishRequestEvent { if (exposure == null || typeof exposure != 'object') { return false } + const publishedAt = exposure['publishedAt'] as JSONPrimitive + if (!isValidTimestamp(publishedAt)) { + return false + } + const units = exposure['units'] if (!Array.isArray(units) || units.length == 0) { return false @@ -38,6 +51,10 @@ function isValidExposure(exposure?: PublishRequestEvent | Record typeof x['exposedAt'] !== 'number' || !isValidTimestamp(x['exposedAt']))) { + return false + } + const goals = exposure['goals'] if (goals != null && (!Array.isArray(goals) || goals.length > 0)) { return false @@ -53,32 +70,40 @@ function isValidExposure(exposure?: PublishRequestEvent | Record> { - if (!isValidTimestamp(payload.publishedAt)) { - throw new PayloadValidationError( - 'Exposure `publishedAt` is required to be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number' - ) - } - - const exposure = payload.exposure - if (exposure == null || typeof exposure != 'object') { + const exposureRequest = payload.exposure as unknown as PublishRequestEvent + if (exposureRequest == null || typeof exposureRequest != 'object') { throw new PayloadValidationError('Field `exposure` is required to be an object when tracking exposures') } - if (!isValidExposure(exposure)) { + if (!isValidExposureRequest(exposureRequest)) { throw new PayloadValidationError( 'Field `exposure` is malformed or contains goals. Ensure you are sending a valid ABsmartly exposure payload without goals.' ) } + const offset = timestamp - unixTimestampOf(exposureRequest.publishedAt) + const exposures = exposureRequest.exposures?.map((x) => ({ + ...x, + exposedAt: x.exposedAt + offset + })) + const attributes = exposureRequest.attributes?.map((x) => ({ + ...x, + setAt: x.setAt + offset + })) + return sendEvent( request, settings, { - ...exposure, - publishedAt: unixTimestampOf(payload.publishedAt) + ...exposureRequest, + historic: true, + publishedAt: timestamp, + exposures, + attributes }, payload.agent, payload.application diff --git a/packages/destination-actions/src/destinations/absmartly/goal.ts b/packages/destination-actions/src/destinations/absmartly/goal.ts index 1481e4d994..7fc22e29ad 100644 --- a/packages/destination-actions/src/destinations/absmartly/goal.ts +++ b/packages/destination-actions/src/destinations/absmartly/goal.ts @@ -2,7 +2,7 @@ import { mapUnits, Units } from './unit' import { InputField, ModifiedResponse, PayloadValidationError, RequestClient } from '@segment/actions-core' import { sendEvent, PublishRequestEvent, defaultEventFields, DefaultPayload } from './event' import { Settings } from './generated-types' -import { isValidTimestamp, unixTimestampOf } from './timestamp' +import { unixTimestampOf } from './timestamp' import { Data } from 'ws' export interface PublishRequestGoal { @@ -13,7 +13,6 @@ export interface PublishRequestGoal { export interface GoalPayload extends Units, DefaultPayload { name: string - achievedAt: string | number properties?: null | Record } @@ -43,16 +42,6 @@ export const defaultGoalFields: Record = { '@path': '$.event' } }, - achievedAt: { - label: 'Goal Achievement Time', - type: 'datetime', - required: true, - description: - 'Exact timestamp when the goal was achieved (measured by the client clock). Must be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number', - default: { - '@path': '$.originalTimestamp' - } - }, properties: { label: 'Goal Properties', type: 'object', @@ -67,6 +56,7 @@ export const defaultGoalFields: Record = { export function sendGoal( request: RequestClient, + timestamp: number, payload: GoalPayload, settings: Settings ): Promise> { @@ -74,29 +64,18 @@ export function sendGoal( throw new PayloadValidationError('Goal `name` is required to be a non-empty string') } - if (!isValidTimestamp(payload.publishedAt)) { - throw new PayloadValidationError( - 'Goal `publishedAt` is required to be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number' - ) - } - - if (!isValidTimestamp(payload.achievedAt)) { - throw new PayloadValidationError( - 'Goal `achievedAt` is required to be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number' - ) - } - if (payload.properties != null && typeof payload.properties != 'object') { throw new PayloadValidationError('Goal `properties` if present is required to be an object') } const event: PublishRequestEvent = { - publishedAt: unixTimestampOf(payload.publishedAt), + historic: true, + publishedAt: unixTimestampOf(timestamp), units: mapUnits(payload), goals: [ { name: payload.name, - achievedAt: unixTimestampOf(payload.achievedAt), + achievedAt: unixTimestampOf(timestamp), properties: payload.properties ?? null } ] diff --git a/packages/destination-actions/src/destinations/absmartly/segment.ts b/packages/destination-actions/src/destinations/absmartly/segment.ts new file mode 100644 index 0000000000..00fdffbfeb --- /dev/null +++ b/packages/destination-actions/src/destinations/absmartly/segment.ts @@ -0,0 +1,11 @@ +export interface RequestData { + rawData: { + timestamp: string + type: string + receivedAt: string + sentAt: string + } + rawMapping: Record + settings: Settings + payload: Payload +} diff --git a/packages/destination-actions/src/destinations/absmartly/timestamp.ts b/packages/destination-actions/src/destinations/absmartly/timestamp.ts index d9a195fb98..204a7d5f54 100644 --- a/packages/destination-actions/src/destinations/absmartly/timestamp.ts +++ b/packages/destination-actions/src/destinations/absmartly/timestamp.ts @@ -9,7 +9,8 @@ export function isValidTimestamp(timestamp: JSONPrimitive): boolean { return false } -export function unixTimestampOf(timestamp: JSONPrimitive): number { +export function unixTimestampOf(timestamp: JSONPrimitive | Date): number { if (typeof timestamp === 'number') return timestamp + if (timestamp instanceof Date) return timestamp.getTime() return Date.parse(timestamp as string) } diff --git a/packages/destination-actions/src/destinations/absmartly/trackExposure/__tests__/index.test.ts b/packages/destination-actions/src/destinations/absmartly/trackExposure/__tests__/index.test.ts index b826793df4..1d1c8850d6 100644 --- a/packages/destination-actions/src/destinations/absmartly/trackExposure/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/absmartly/trackExposure/__tests__/index.test.ts @@ -1,6 +1,8 @@ import nock from 'nock' import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' +import { unixTimestampOf } from '../../timestamp' +import { PublishRequestEvent } from '../../event' const testDestination = createTestIntegration(Destination) @@ -17,7 +19,7 @@ describe('ABsmartly.trackExposure', () => { anonymousId: 'anon-123', properties: { exposure: { - publishedAt: 123, + publishedAt: 1602531300000, units: [{ type: 'anonymousId', uid: 'anon-123' }], exposures: [ { @@ -50,15 +52,19 @@ describe('ABsmartly.trackExposure', () => { useDefaultMappings: true }) + const timestamp = unixTimestampOf(exposureEvent.timestamp!) + const exposureRequest = exposureEvent.properties.exposure as PublishRequestEvent + expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(await responses[0].request.json()).toStrictEqual({ - publishedAt: 1672531200100, + historic: true, + publishedAt: timestamp, units: [{ type: 'anonymousId', uid: 'anon-123' }], exposures: [ { assigned: true, - exposedAt: 1602531200000, + exposedAt: timestamp - (exposureRequest.publishedAt - exposureRequest.exposures?.[0].exposedAt), id: 10, name: 'test_experiment' } @@ -67,7 +73,7 @@ describe('ABsmartly.trackExposure', () => { { name: 'test', value: 'test', - setAt: 1602530000000 + setAt: timestamp - (exposureRequest.publishedAt - exposureRequest.attributes?.[0].setAt) } ] }) diff --git a/packages/destination-actions/src/destinations/absmartly/trackExposure/generated-types.ts b/packages/destination-actions/src/destinations/absmartly/trackExposure/generated-types.ts index cfade7555e..d95f8bd3cf 100644 --- a/packages/destination-actions/src/destinations/absmartly/trackExposure/generated-types.ts +++ b/packages/destination-actions/src/destinations/absmartly/trackExposure/generated-types.ts @@ -7,10 +7,6 @@ export interface Payload { exposure: { [k: string]: unknown } - /** - * Exact timestamp when the event was sent (measured by the client clock). Must be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number - */ - publishedAt: string | number /** * Optional agent identifier that originated the event. Used to identify which SDK generated the event. */ diff --git a/packages/destination-actions/src/destinations/absmartly/trackExposure/index.ts b/packages/destination-actions/src/destinations/absmartly/trackExposure/index.ts index 0c09c0d7ba..a0f1ea827c 100644 --- a/packages/destination-actions/src/destinations/absmartly/trackExposure/index.ts +++ b/packages/destination-actions/src/destinations/absmartly/trackExposure/index.ts @@ -1,7 +1,9 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { defaultExposureFields, sendExposure } from '../exposure' +import { defaultExposureFields, ExposurePayload, sendExposure } from '../exposure' +import { RequestData } from '../segment' +import { unixTimestampOf } from '../timestamp' const fields = { ...defaultExposureFields } @@ -10,8 +12,12 @@ const action: ActionDefinition = { description: 'Send an experiment exposure event to ABsmartly', fields: fields, defaultSubscription: 'type = "track" and event = "Experiment Viewed"', - perform: (request, { payload, settings }) => { - return sendExposure(request, payload, settings) + perform: (request, data) => { + const requestData = data as RequestData + const timestamp = unixTimestampOf(requestData.rawData.timestamp) + const payload = requestData.payload + const settings = requestData.settings + return sendExposure(request, timestamp, payload, settings) } } diff --git a/packages/destination-actions/src/destinations/absmartly/trackGoal/__tests__/index.test.ts b/packages/destination-actions/src/destinations/absmartly/trackGoal/__tests__/index.test.ts index c17abf3591..a13fc75625 100644 --- a/packages/destination-actions/src/destinations/absmartly/trackGoal/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/absmartly/trackGoal/__tests__/index.test.ts @@ -1,6 +1,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' +import { unixTimestampOf } from '../../timestamp' const testDestination = createTestIntegration(Destination) @@ -32,17 +33,20 @@ describe('ABsmartly.trackGoal', () => { useDefaultMappings: true }) + const timestamp = unixTimestampOf(baseEvent.timestamp!) + expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(await responses[0].request.json()).toStrictEqual({ - publishedAt: 1672531200100, + historic: true, + publishedAt: timestamp, units: [ { type: 'anonymousId', uid: 'anon-123' }, { type: 'userId', uid: '123' } ], goals: [ { - achievedAt: 1672531200000, + achievedAt: timestamp, name: 'Order Completed', properties: baseEvent.properties } diff --git a/packages/destination-actions/src/destinations/absmartly/trackGoal/generated-types.ts b/packages/destination-actions/src/destinations/absmartly/trackGoal/generated-types.ts index 13983dd560..26b0465b49 100644 --- a/packages/destination-actions/src/destinations/absmartly/trackGoal/generated-types.ts +++ b/packages/destination-actions/src/destinations/absmartly/trackGoal/generated-types.ts @@ -11,20 +11,12 @@ export interface Payload { * The name of the goal to track */ name: string - /** - * Exact timestamp when the goal was achieved (measured by the client clock). Must be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number - */ - achievedAt: string | number /** * Custom properties of the goal */ properties: { [k: string]: unknown } - /** - * Exact timestamp when the event was sent (measured by the client clock). Must be an ISO 8601 date-time string, or a Unix timestamp (milliseconds) number - */ - publishedAt: string | number /** * Optional agent identifier that originated the event. Used to identify which SDK generated the event. */ diff --git a/packages/destination-actions/src/destinations/absmartly/trackGoal/index.ts b/packages/destination-actions/src/destinations/absmartly/trackGoal/index.ts index 8ade0cec0a..324e747c53 100644 --- a/packages/destination-actions/src/destinations/absmartly/trackGoal/index.ts +++ b/packages/destination-actions/src/destinations/absmartly/trackGoal/index.ts @@ -1,7 +1,9 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { defaultGoalFields, sendGoal } from '../goal' +import { defaultGoalFields, GoalPayload, sendGoal } from '../goal' +import { RequestData } from '../segment' +import { unixTimestampOf } from '../timestamp' const fields = { ...defaultGoalFields } @@ -10,8 +12,12 @@ const action: ActionDefinition = { description: 'Send a goal event to ABsmartly', fields: fields, defaultSubscription: 'type = "track" and event != "Experiment Viewed"', - perform: (request, { payload, settings }) => { - return sendGoal(request, payload, settings) + perform: (request, data) => { + const requestData = data as RequestData + const timestamp = unixTimestampOf(requestData.rawData.timestamp) + const payload = requestData.payload + const settings = requestData.settings + return sendGoal(request, timestamp, payload, settings) } } diff --git a/packages/destination-actions/src/destinations/accoil-analytics/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/accoil-analytics/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..eea73bf7d7 --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-accoil-analytics destination: postToAccoil action - all fields 1`] = ` +Object { + "testType": "MlZ)Z", +} +`; + +exports[`Testing snapshot for actions-accoil-analytics destination: postToAccoil action - required fields 1`] = ` +Object { + "testType": "MlZ)Z", +} +`; diff --git a/packages/destination-actions/src/destinations/accoil-analytics/__tests__/index.test.ts b/packages/destination-actions/src/destinations/accoil-analytics/__tests__/index.test.ts new file mode 100644 index 0000000000..161b8d83fc --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/__tests__/index.test.ts @@ -0,0 +1,19 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Accoil Analytics', () => { + describe('testAuthentication', () => { + it('should Test Auth Header', async () => { + nock('https://in.accoil.com') + .post('/segment') + .reply(400, { message: "API Key should start with 'Basic' and be followed by a space and your API key." }) + + // This should match your authentication.fields + const authData = { api_key: 'secret' } + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/accoil-analytics/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/accoil-analytics/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..6e0aa06180 --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-accoil-analytics' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/accoil-analytics/generated-types.ts b/packages/destination-actions/src/destinations/accoil-analytics/generated-types.ts new file mode 100644 index 0000000000..16b0e17a0d --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Accoil.com API Key. You can find your API Key in your Accoil.com account settings. + */ + api_key: string +} diff --git a/packages/destination-actions/src/destinations/accoil-analytics/index.ts b/packages/destination-actions/src/destinations/accoil-analytics/index.ts new file mode 100644 index 0000000000..a2f32a6dd9 --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/index.ts @@ -0,0 +1,68 @@ +import { defaultValues, DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import postToAccoil from './postToAccoil' + +const destination: DestinationDefinition = { + name: 'Accoil Analytics', + slug: 'actions-accoil-analytics', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + api_key: { + label: 'API Key', + description: 'Your Accoil.com API Key. You can find your API Key in your Accoil.com account settings.', + type: 'password', + required: true + } + }, + testAuthentication: async (request, { settings }) => { + const AUTH_KEY = Buffer.from(`${settings.api_key}:`).toString('base64') + return await request(`https://in.accoil.com/segment`, { + method: 'post', + headers: { + Authorization: `Basic ${AUTH_KEY}` + }, + json: {} + }) + } + }, + presets: [ + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'postToAccoil', + mapping: defaultValues(postToAccoil.fields), + type: 'automatic' + }, + { + name: 'Group', + subscribe: 'type = "group"', + partnerAction: 'postToAccoil', + mapping: defaultValues(postToAccoil.fields), + type: 'automatic' + }, + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'postToAccoil', + mapping: defaultValues(postToAccoil.fields), + type: 'automatic' + } + ], + extendRequest: ({ settings }) => { + const AUTH_KEY = Buffer.from(`${settings.api_key}:`).toString('base64') + return { + headers: { + Authorization: `Basic ${AUTH_KEY}` + } + } + }, + actions: { + postToAccoil + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..44a5f1a1fa --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for AccoilAnalytics's postToAccoil destination action: all fields 1`] = ` +Object { + "testType": "0L3I&x9a", +} +`; + +exports[`Testing snapshot for AccoilAnalytics's postToAccoil destination action: required fields 1`] = ` +Object { + "testType": "0L3I&x9a", +} +`; diff --git a/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/index.test.ts b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/index.test.ts new file mode 100644 index 0000000000..6479b97f35 --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/index.test.ts @@ -0,0 +1,34 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('AccoilAnalytics.postToAccoil', () => { + // TODO: Test your action + const event1 = createTestEvent() + + it('should validate api keys', async () => { + nock('https://in.accoil.com') + .post('/segment') + .reply(400, { message: "API Key should start with 'Basic' and be followed by a space and your API key." }) + try { + await testDestination.testAuthentication({ api_key: 'secret' }) + } catch (err: any) { + console.log('THIS IS ERROR', err) + expect(err.message).toContain('Credentials are invalid: 400 Bad Request') + } + }) + + it('should send data upstream', async () => { + nock('https://in.accoil.com').post('/segment').reply(202, {}) + + const response = await testDestination.testAction('postToAccoil', { + event: event1, + useDefaultMappings: true + }) + expect(response.length).toBe(1) + expect(new URL(response[0].url).pathname).toBe('/segment') + expect(response[0].status).toBe(202) + }) +}) diff --git a/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..a15bf157f1 --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'postToAccoil' +const destinationSlug = 'AccoilAnalytics' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/generated-types.ts b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/generated-types.ts new file mode 100644 index 0000000000..9cd068e3be --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/generated-types.ts @@ -0,0 +1,10 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Segment Event Payload + */ + segmentEventData: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/index.ts b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/index.ts new file mode 100644 index 0000000000..6f5c06672c --- /dev/null +++ b/packages/destination-actions/src/destinations/accoil-analytics/postToAccoil/index.ts @@ -0,0 +1,30 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Post to Accoil', + description: 'Send Data to Accoil Analytics', + defaultSubscription: 'type = "track"', + + fields: { + segmentEventData: { + label: 'Event Payload', + description: 'Segment Event Payload', + type: 'object', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.' + } + } + }, + perform: (request, { payload }) => { + return request(`https://in.accoil.com/segment`, { + method: 'post', + json: payload.segmentEventData + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/generated-types.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/generated-types.ts index 2b64c2dd5b..8724bef796 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/generated-types.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/generated-types.ts @@ -24,7 +24,7 @@ export interface Settings { /** * * - * Last-Modified: 09.19.2023 10.30.43 + * Last-Modified: 02.01.2024 10.30.43 * * */ diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/index.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/index.ts index 2269e619ef..ec4ef23059 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/index.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/index.ts @@ -3,7 +3,7 @@ import { Settings } from './generated-types' import receiveEvents from './receiveEvents/index' const mod = ` -Last-Modified: 09.19.2023 10.30.43 +Last-Modified: 02.01.2024 10.30.43 ` //August 2023, refactor for S3Cache @@ -15,7 +15,7 @@ const presets: DestinationDefinition['presets'] = [ partnerAction: 'receiveEvents', mapping: { ...defaultValues(receiveEvents.fields), - email: { + uniqueRecipientId: { '@if': { exists: { '@path': '$.properties.email' }, then: { '@path': '$.properties.email' }, @@ -31,7 +31,7 @@ const presets: DestinationDefinition['presets'] = [ partnerAction: 'receiveEvents', mapping: { ...defaultValues(receiveEvents.fields), - email: { + uniqueRecipientId: { '@if': { exists: { '@path': '$.traits.email' }, then: { '@path': '$.traits.email' }, diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/__snapshots__/eventprocessing.test.ts.snap b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/__snapshots__/eventprocessing.test.ts.snap index 7f7b6be567..18874136b8 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/__snapshots__/eventprocessing.test.ts.snap +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/__snapshots__/eventprocessing.test.ts.snap @@ -1,68 +1,82 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`addUpdateEvents addUpdateEvents should return expected output 1`] = ` -"EMAIL, EventSource, EventName, EventValue, EventTimestamp -acmeTest@gmail.com, undefined Event, email, acmeTest@gmail.com, undefined -acmeTest@gmail.com, undefined Event, action_source, system_generated, undefined -acmeTest@gmail.com, undefined Event, cart_id, fff7b1597270349875cffad3852067ab, undefined -acmeTest@gmail.com, undefined Event, category, Shopify (Littledata), undefined -acmeTest@gmail.com, undefined Event, checkout_id, 26976972210285, undefined -acmeTest@gmail.com, undefined Event, coupon, HONEY15, undefined -acmeTest@gmail.com, undefined Event, currency, USD, undefined -acmeTest@gmail.com, undefined Event, discount, 4.79, undefined -acmeTest@gmail.com, undefined Event, presentment_amount, 31.98, undefined -acmeTest@gmail.com, undefined Event, presentment_currency, USD, undefined -acmeTest@gmail.com, undefined Event, price, 31.98, undefined -acmeTest@gmail.com, undefined Event, products.0.brand, acme, undefined -acmeTest@gmail.com, undefined Event, products.0.category, Fragrance, undefined -acmeTest@gmail.com, undefined Event, products.0.image_url, https://cdn.shopify.com/s/files/1/0023/0021/5405/products/SimplyLavender_Prod_1.jpg?v=1649347142, undefined -acmeTest@gmail.com, undefined Event, products.0.name, Simply Lavender, undefined -acmeTest@gmail.com, undefined Event, products.0.presentment_amount, 12.99, undefined -acmeTest@gmail.com, undefined Event, products.0.presentment_currency, USD, undefined -acmeTest@gmail.com, undefined Event, products.0.price, 12.99, undefined -acmeTest@gmail.com, undefined Event, products.0.product_id, 1542783500397, undefined -acmeTest@gmail.com, undefined Event, products.0.quantity, 1, undefined -acmeTest@gmail.com, undefined Event, products.0.shopify_product_id, 1542783500397, undefined -acmeTest@gmail.com, undefined Event, products.0.shopify_variant_id, 14369408221293, undefined -acmeTest@gmail.com, undefined Event, products.0.sku, NGL, undefined -acmeTest@gmail.com, undefined Event, products.0.url, https://acme-scents.myshopify.com/products/simply-lavender, undefined -acmeTest@gmail.com, undefined Event, products.0.variant, Simply Lavender, undefined -acmeTest@gmail.com, undefined Event, products.1.brand, NEST New York, undefined -acmeTest@gmail.com, undefined Event, products.1.category, Fragrance, undefined -acmeTest@gmail.com, undefined Event, products.1.image_url, https://cdn.shopify.com/s/files/1/0023/0021/5405/products/Grapefruit_Prod_1.jpg?v=1649344617, undefined -acmeTest@gmail.com, undefined Event, products.1.name, Grapefruit, undefined -acmeTest@gmail.com, undefined Event, products.1.presentment_amount, 18.99, undefined -acmeTest@gmail.com, undefined Event, products.1.presentment_currency, USD, undefined -acmeTest@gmail.com, undefined Event, products.1.price, 18.99, undefined -acmeTest@gmail.com, undefined Event, products.1.product_id, 3979374755949, undefined -acmeTest@gmail.com, undefined Event, products.1.quantity, 1, undefined -acmeTest@gmail.com, undefined Event, products.1.shopify_product_id, 3979374755949, undefined -acmeTest@gmail.com, undefined Event, products.1.shopify_variant_id, 29660017000557, undefined -acmeTest@gmail.com, undefined Event, products.1.sku, MXV, undefined -acmeTest@gmail.com, undefined Event, products.1.url, https://acme-scents.myshopify.com/products/grapefruit, undefined -acmeTest@gmail.com, undefined Event, products.1.variant, Grapefruit, undefined -acmeTest@gmail.com, undefined Event, sent_from, Littledata app, undefined -acmeTest@gmail.com, undefined Event, shipping_method, Standard Shipping (5-7 days), undefined -acmeTest@gmail.com, undefined Event, source_name, web, undefined -acmeTest@gmail.com, undefined Event, step, 2, undefined -acmeTest@gmail.com, undefined Event, integration.name, shopify_littledata, undefined -acmeTest@gmail.com, undefined Event, integration.version, 9.1, undefined -acmeTest@gmail.com, undefined Event, library.name, analytics-node, undefined -acmeTest@gmail.com, undefined Event, library.version, 3.5.0, undefined -acmeTest@gmail.com, undefined Event, traits.address.city, greenville, undefined -acmeTest@gmail.com, undefined Event, traits.address.country, us, undefined -acmeTest@gmail.com, undefined Event, traits.address.postalCode, 29609, undefined -acmeTest@gmail.com, undefined Event, traits.address.state, sc, undefined -acmeTest@gmail.com, undefined Event, traits.email, acmeTest@gmail.com, undefined -acmeTest@gmail.com, undefined Event, traits.firstName, james, undefined -acmeTest@gmail.com, undefined Event, traits.lastName, acmeTest, undefined -" +Object { + "l": undefined, + "propertiesTraitsKV": Object { + "action_source": "system_generated", + "cart_id": "fff7b1597270349875cffad3852067ab", + "category": "Shopify (Littledata)", + "checkout_id": 26976972210285, + "coupon": "HONEY15", + "currency": "USD", + "discount": 4.79, + "email": "acmeTest@gmail.com", + "eventSource": "undefined Event", + "integration.name": "shopify_littledata", + "integration.version": "9.1", + "library.name": "analytics-node", + "library.version": "3.5.0", + "presentment_amount": "31.98", + "presentment_currency": "USD", + "price": 31.98, + "products.0.brand": "acme", + "products.0.category": "Fragrance", + "products.0.image_url": "https://cdn.shopify.com/s/files/1/0023/0021/5405/products/SimplyLavender_Prod_1.jpg?v=1649347142", + "products.0.name": "Simply Lavender", + "products.0.presentment_amount": "12.99", + "products.0.presentment_currency": "USD", + "products.0.price": 12.99, + "products.0.product_id": "1542783500397", + "products.0.quantity": 1, + "products.0.shopify_product_id": "1542783500397", + "products.0.shopify_variant_id": "14369408221293", + "products.0.sku": "NGL", + "products.0.url": "https://acme-scents.myshopify.com/products/simply-lavender", + "products.0.variant": "Simply Lavender", + "products.1.brand": "NEST New York", + "products.1.category": "Fragrance", + "products.1.image_url": "https://cdn.shopify.com/s/files/1/0023/0021/5405/products/Grapefruit_Prod_1.jpg?v=1649344617", + "products.1.name": "Grapefruit", + "products.1.presentment_amount": "18.99", + "products.1.presentment_currency": "USD", + "products.1.price": 18.99, + "products.1.product_id": "3979374755949", + "products.1.quantity": 1, + "products.1.shopify_product_id": "3979374755949", + "products.1.shopify_variant_id": "29660017000557", + "products.1.sku": "MXV", + "products.1.url": "https://acme-scents.myshopify.com/products/grapefruit", + "products.1.variant": "Grapefruit", + "sent_from": "Littledata app", + "shipping_method": "Standard Shipping (5-7 days)", + "source_name": "web", + "step": 2, + "timestamp": undefined, + "traits.address.city": "greenville", + "traits.address.country": "us", + "traits.address.postalCode": "29609", + "traits.address.state": "sc", + "traits.email": "acmeTest@gmail.com", + "traits.firstName": "james", + "traits.lastName": "acmeTest", + "undefined": "Audience Exited", + "uniqueRecipient": "acmeTest@gmail.com", + }, +} `; exports[`addUpdateEvents adds update events to CSV rows 1`] = ` -"EMAIL, EventSource, EventName, EventValue, EventTimestamp -example@example.com, undefined Event, key1, value1, undefined -" +Object { + "l": undefined, + "propertiesTraitsKV": Object { + "eventSource": "undefined Event", + "key1": "value1", + "timestamp": undefined, + "undefined": "Audience Exited", + "uniqueRecipient": "example@example.com", + }, +} `; exports[`parseSections parseSections should match correct outcome 1`] = ` diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/eventprocessing.test.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/eventprocessing.test.ts index d43996459e..4223c52cea 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/eventprocessing.test.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/eventprocessing.test.ts @@ -186,7 +186,7 @@ describe('addUpdateEvents', () => { // const retValue = await addUpdateEvents(request,payload,settings,auth,email); const payload = { - email: 'acmeTest99@gmail.com', + uniqueRecipientId: 'acmeTest99@gmail.com', type: 'track', enable_batching: false, timestamp: '2023-02-12T15:07:21.381Z', @@ -272,7 +272,7 @@ describe('addUpdateEvents', () => { describe('addUpdateEvents', () => { test('adds update events to CSV rows', () => { const mockPayload = { - email: 'example@example.com', + uniqueRecipientId: 'example@example.com', type: 'track', timestamp: '2023-02-07T02:19:23.469Z', key_value_pairs: { diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/index.test.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/index.test.ts index 474d09c902..f1d1c36c32 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/index.test.ts @@ -16,7 +16,7 @@ describe('Send Events Action', () => { s3_secret: 'secret', s3_region: 'us-east-1', s3_bucket_accesspoint_alias: 'my-bucket', - fileNamePrefix: 'prefix' + fileNamePrefix: 'prefix_' } as Settings test('perform ValidateSettings call with valid payload and settings', async () => { diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/preCheck.test.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/preCheck.test.ts index e81760ac04..e604e5cf56 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/preCheck.test.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/__tests__/preCheck.test.ts @@ -9,7 +9,7 @@ const validS3Settings = { s3_secret: 'secret', s3_region: 'us-east-1', s3_bucket_accesspoint_alias: 'my-bucket', - fileNamePrefix: 'prefix' + fileNamePrefix: 'prefix_' } describe('validateSettings', () => { diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/eventprocessing.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/eventprocessing.ts index 05a391c8ab..6520d378f2 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/eventprocessing.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/eventprocessing.ts @@ -8,7 +8,7 @@ export function parseSections(section: { [key: string]: string }, nestDepth: num if (nestDepth > 10) throw new IntegrationError( - 'Event data exceeds nesting depth. Use Mapping to flatten the data to no more than 3 levels deep', + 'Event data exceeds nesting depth. Plese use mapping to flatten the data to no more than three levels deep.', 'NESTING_DEPTH_EXCEEDED', 400 ) @@ -31,30 +31,43 @@ export function parseSections(section: { [key: string]: string }, nestDepth: num } } } catch (e) { - throw new IntegrationError( - `Unexpected Exception while parsing Event payload.\n ${e}`, - 'UNEXPECTED_EVENT_PARSING_EXCEPTION', - 400 - ) + const ie = e as IntegrationError + if (ie.code === 'NESTING_DEPTH_EXCEEDED') + throw new IntegrationError( + 'Event data exceeds nesting depth. Use Mapping to flatten data structures to no more than 3 levels deep', + 'NESTING_DEPTH_EXCEEDED', + 400 + ) + else + throw new IntegrationError( + `Unexpected Exception while parsing Event payload.\n ${e}`, + 'UNEXPECTED_EVENT_PARSING_EXCEPTION', + 400 + ) } return parseResults } -export function addUpdateEvents(payload: Payload, email: string) { - let eventName = '' - let eventValue = '' +export function addUpdateEvents(payload: Payload, uniqRecip: string) { + //Index Signature type + let propertiesTraitsKV: { [key: string]: string } = {} - //Header - let csvRows = 'EMAIL, EventSource, EventName, EventValue, EventTimestamp\n' + propertiesTraitsKV = { + ...propertiesTraitsKV, + ...{ ['uniqueRecipient']: uniqRecip } + } - //Event Source - const eventSource = get(payload, 'type', 'Null') + ' Event' + propertiesTraitsKV = { + ...propertiesTraitsKV, + ...{ ['eventSource']: get(payload, 'type', 'Null') + ' Event' } + } //Timestamp // "timestamp": "2023-02-07T02:19:23.469Z"` - const timestamp = get(payload, 'timestamp', 'Null') - - let propertiesTraitsKV: { [key: string]: string } = {} + propertiesTraitsKV = { + ...propertiesTraitsKV, + ...{ ['timestamp']: get(payload, 'timestamp', 'Null') as string } + } if (payload.key_value_pairs) propertiesTraitsKV = { @@ -84,17 +97,16 @@ export function addUpdateEvents(payload: Payload, email: string) { ...parseSections(payload.context as { [key: string]: string }, 0) } - let ak = '' - let av = '' - const getValue = (o: object, part: string) => Object.entries(o).find(([k, _v]) => k.includes(part))?.[1] as string const getKey = (o: object, part: string) => Object.entries(o).find(([k, _v]) => k.includes(part))?.[0] as string + let ak, av + if (getValue(propertiesTraitsKV, 'computation_class')?.toLowerCase() === 'audience') { ak = getValue(propertiesTraitsKV, 'computation_key') av = getValue(propertiesTraitsKV, `${ak}`) - //Clean out already parsed attributes, reduce redundant attributes + //reduce redundant attributes let x = getKey(propertiesTraitsKV, 'computation_class') delete propertiesTraitsKV[`${x}`] x = getKey(propertiesTraitsKV, 'computation_key') @@ -106,32 +118,23 @@ export function addUpdateEvents(payload: Payload, email: string) { ak = getValue(propertiesTraitsKV, 'audience_key') av = getValue(propertiesTraitsKV, `${ak}`) - //Clean out already parsed attributes, reduce redundant attributes + //reduce redundant attributes const x = getKey(propertiesTraitsKV, 'audience_key') delete propertiesTraitsKV[`${x}`] delete propertiesTraitsKV[`${ak}`] } - if (av !== '') { - let audiStatus = av - - eventValue = audiStatus - audiStatus = audiStatus.toString().toLowerCase() - if (audiStatus === 'true') eventValue = 'Audience Entered' - if (audiStatus === 'false') eventValue = 'Audience Exited' - - eventName = ak + if (av !== null) { + if (av) av = 'Audience Entered' + if (!av) av = 'Audience Exited' - //Initial Row - csvRows += `${email}, ${eventSource}, ${eventName}, ${eventValue}, ${timestamp}\n` + propertiesTraitsKV = { + ...propertiesTraitsKV, + ...{ [`${ak}`]: av } //as { [key: string]: string } + } } - //Add the rest of the CSV rows - for (const e in propertiesTraitsKV) { - const eventName = e - const eventValue = propertiesTraitsKV[e] + const l = propertiesTraitsKV.length - csvRows += `${email}, ${eventSource}, ${eventName}, ${eventValue}, ${timestamp}\n` - } - return csvRows + return { propertiesTraitsKV, l } } diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/generated-types.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/generated-types.ts index 1c7f27d121..7e6a81fa00 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/generated-types.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/generated-types.ts @@ -32,9 +32,9 @@ export interface Payload { [k: string]: unknown } /** - * Do Not Modify - Email is required + * The field to be used to uniquely identify the Recipient in Acoustic. This field is required with Email preferred but not required. */ - email: string + uniqueRecipientId: string /** * Do Not Modify - The type of event. e.g. track or identify, this field is required */ diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/index.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/index.ts index 46c56e8b92..17de04d6ef 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/index.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/index.ts @@ -42,11 +42,12 @@ const action: ActionDefinition = { 'If the data is present in a Traits section, use this to map the attributes of a Traits Section (optional) ', type: 'object' }, - email: { - label: 'Email', - description: 'Do Not Modify - Email is required', + uniqueRecipientId: { + label: 'UniqueRecipientId', + description: + 'The field to be used to uniquely identify the Recipient in Acoustic. This field is required with Email preferred but not required.', type: 'string', - format: 'email', + format: 'text', required: true, default: { '@if': { @@ -76,10 +77,14 @@ const action: ActionDefinition = { } }, perform: async (request, { settings, payload }) => { - const email = get(payload, 'email', '') + const uniqRecip = get(payload, 'uniqueRecipientId', '') - if (!email) { - throw new IntegrationError('Email Not Found, invalid Event received.', 'INVALID_EVENT_HAS_NO_EMAIL', 400) + if (!uniqRecip) { + throw new IntegrationError( + 'Unique Recipient Id Not Found, invalid Event received.', + 'INVALID_EVENT_HAS_NO_UNIQUERECIPIENTID', + 400 + ) } if (!payload.context && !payload.traits && !payload.properties) @@ -92,10 +97,13 @@ const action: ActionDefinition = { validateSettings(settings) //Parse Event-Payload into an Update - const csvRows = addUpdateEvents(payload, email) + const parsed = addUpdateEvents(payload, uniqRecip) + let jsonData = '' + + jsonData = JSON.stringify(parsed.propertiesTraitsKV) //Set File Store Name - const fileName = settings.fileNamePrefix + `${new Date().toISOString().replace(/(\.|-|:)/g, '_')}` + '.csv' + const fileName = settings.fileNamePrefix + `${new Date().toISOString().replace(/(\.|-|:)/g, '_')}` + '.json' const method = 'PUT' const opts = await generateS3RequestOptions( @@ -103,7 +111,7 @@ const action: ActionDefinition = { settings.s3_region, fileName, method, - csvRows, + jsonData, settings.s3_access_key, settings.s3_secret ) diff --git a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/preCheck.ts b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/preCheck.ts index 0f38e40e38..9248a1cce6 100644 --- a/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/preCheck.ts +++ b/packages/destination-actions/src/destinations/acoustic-s3tc/receiveEvents/preCheck.ts @@ -19,7 +19,25 @@ function validateSettings(settings: Settings) { } if (!settings.fileNamePrefix) { - throw new IntegrationError('Missing Customer Prefix', 'MISSING_CUSTOMER_PREFIX', 400) + throw new IntegrationError( + 'Missing Customer Prefix. Customer Prefix should be of the form of 4-5 characters followed by an underscore character', + 'MISSING_CUSTOMER_PREFIX', + 400 + ) + } + if (!settings.fileNamePrefix.endsWith('_')) { + throw new IntegrationError( + 'Invalid Customer Prefix. Customer Prefix must end with an underscore character "_". ', + 'INVALID_CUSTOMER_PREFIX', + 400 + ) + } + if (settings.fileNamePrefix === 'customer_org_') { + throw new IntegrationError( + 'Unedited Customer Prefix. Customer Prefix remains as the default value, must edit and provide a valid Customer prefix (4-5 characters) followed by an underscore', + 'INVALID_CUSTOMER_PREFIX', + 400 + ) } } diff --git a/packages/destination-actions/src/destinations/aggregations-io/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/aggregations-io/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..55e6b402f4 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for aggregations-io destination: send action - all fields 1`] = ` +Array [ + Object { + "testType": "!80aBDHS1i", + }, +] +`; + +exports[`Testing snapshot for aggregations-io destination: send action - required fields 1`] = ` +Array [ + null, +] +`; diff --git a/packages/destination-actions/src/destinations/aggregations-io/__tests__/index.test.ts b/packages/destination-actions/src/destinations/aggregations-io/__tests__/index.test.ts new file mode 100644 index 0000000000..eb9c1c30e6 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/__tests__/index.test.ts @@ -0,0 +1,24 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) +const fakeApiKey = 'super-secret-key' +const fakeIngestId = 'abc456' +describe('Aggregations Io', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://app.aggregations.io') + .get(`/api/v1/organization/ping-w?ingest_id=${fakeIngestId}&schema=ARRAY_OF_EVENTS`) + .reply(200) + .matchHeader('x-api-token', fakeApiKey) + + const authData = { + api_key: fakeApiKey, + ingest_id: fakeIngestId + } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/aggregations-io/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/aggregations-io/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..e428638194 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'aggregations-io' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/aggregations-io/generated-types.ts b/packages/destination-actions/src/destinations/aggregations-io/generated-types.ts new file mode 100644 index 0000000000..d1632123d8 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Aggregations.io API Key. This key requires Write permissions. + */ + api_key: string + /** + * The ID of the ingest you want to send data to. This ingest should be set up as "Array of JSON Objects". Find your ID on the Aggregations.io Organization page. + */ + ingest_id: string +} diff --git a/packages/destination-actions/src/destinations/aggregations-io/index.ts b/packages/destination-actions/src/destinations/aggregations-io/index.ts new file mode 100644 index 0000000000..b936462198 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/index.ts @@ -0,0 +1,59 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { InvalidAuthenticationError } from '@segment/actions-core' +import type { Settings } from './generated-types' +import send from './send' +import { AggregationsAuthError } from './types' + +const destination: DestinationDefinition = { + name: 'Aggregations.io (Actions)', + slug: 'actions-aggregations-io', + mode: 'cloud', + description: 'Send Segment events to Aggregations.io', + authentication: { + scheme: 'custom', + fields: { + api_key: { + label: 'API Key', + description: 'Your Aggregations.io API Key. This key requires Write permissions.', + type: 'password', + required: true + }, + ingest_id: { + label: 'Ingest Id', + description: + 'The ID of the ingest you want to send data to. This ingest should be set up as "Array of JSON Objects". Find your ID on the Aggregations.io Organization page.', + type: 'string', + required: true + } + }, + testAuthentication: async (request, { settings }) => { + try { + return await request( + `https://app.aggregations.io/api/v1/organization/ping-w?ingest_id=${settings.ingest_id}&schema=ARRAY_OF_EVENTS`, + { + method: 'get', + headers: { + 'x-api-token': settings.api_key + } + } + ) + } catch (e: any) { + const error = e as AggregationsAuthError + if (error.response.data) { + const { message } = error.response.data + throw new InvalidAuthenticationError(message) + } + throw new InvalidAuthenticationError('Error Validating Credentials') + } + } + }, + extendRequest({ settings }) { + return { + headers: { 'x-api-token': settings.api_key } + } + }, + actions: { + send + } +} +export default destination diff --git a/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..544859c0fe --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for AggregationsIo's send destination action: all fields 1`] = ` +Array [ + Object { + "testType": "#^vP0", + }, +] +`; + +exports[`Testing snapshot for AggregationsIo's send destination action: required fields 1`] = ` +Array [ + null, +] +`; diff --git a/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/index.test.ts b/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/index.test.ts new file mode 100644 index 0000000000..3cf1749059 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/index.test.ts @@ -0,0 +1,42 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const testIngestId = 'abc123' +const testApiKey = 'super-secret-key' +const ingestUrl = 'https://ingest.aggregations.io' +describe('AggregationsIo.send', () => { + const event1 = createTestEvent() + const event2 = createTestEvent() + + it('should work for single event', async () => { + nock(ingestUrl).post(`/${testIngestId}`).reply(200).matchHeader('x-api-token', testApiKey) + const response = await testDestination.testAction('send', { + event: event1, + settings: { + api_key: testApiKey, + ingest_id: testIngestId + }, + useDefaultMappings: true + }) + expect(response.length).toBe(1) + expect(new URL(response[0].url).pathname).toBe('/' + testIngestId) + expect(response[0].status).toBe(200) + }) + + it('should work for batched events', async () => { + nock(ingestUrl).post(`/${testIngestId}`).reply(200).matchHeader('x-api-token', testApiKey) + const response = await testDestination.testBatchAction('send', { + events: [event1, event2], + settings: { + api_key: testApiKey, + ingest_id: testIngestId + }, + useDefaultMappings: true + }) + expect(response.length).toBe(1) + expect(new URL(response[0].url).pathname).toBe('/' + testIngestId) + expect(response[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..7c3d8a1178 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/send/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'send' +const destinationSlug = 'AggregationsIo' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/aggregations-io/send/generated-types.ts b/packages/destination-actions/src/destinations/aggregations-io/send/generated-types.ts new file mode 100644 index 0000000000..ded4cc4ef5 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/send/generated-types.ts @@ -0,0 +1,18 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Payload to deliver (JSON-encoded). + */ + data?: { + [k: string]: unknown + } + /** + * Enabling sending batches of events to Aggregations.io. + */ + enable_batching: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. If you know your events are large, you may want to tune your batch size down to meet API requirements. + */ + batch_size?: number +} diff --git a/packages/destination-actions/src/destinations/aggregations-io/send/index.ts b/packages/destination-actions/src/destinations/aggregations-io/send/index.ts new file mode 100644 index 0000000000..21448226f6 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/send/index.ts @@ -0,0 +1,47 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Send Events', + description: 'Send events to Aggregations.io.', + fields: { + data: { + label: 'Data', + description: 'Payload to deliver (JSON-encoded).', + type: 'object', + default: { '@path': '$.' } + }, + enable_batching: { + label: 'Enable Batching', + description: 'Enabling sending batches of events to Aggregations.io.', + type: 'boolean', + required: true, + default: true + }, + batch_size: { + label: 'Batch Size', + description: + 'Maximum number of events to include in each batch. Actual batch sizes may be lower. If you know your events are large, you may want to tune your batch size down to meet API requirements.', + type: 'number', + required: false, + default: 300, + readOnly: true, + unsafe_hidden: true + } + }, + perform: (request, { settings, payload }) => { + return request('https://ingest.aggregations.io/' + settings.ingest_id, { + method: 'POST', + json: [payload.data] + }) + }, + performBatch: (request, { settings, payload }) => { + return request('https://ingest.aggregations.io/' + settings.ingest_id, { + method: 'POST', + json: payload.map((x) => x.data) + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/aggregations-io/types.ts b/packages/destination-actions/src/destinations/aggregations-io/types.ts new file mode 100644 index 0000000000..19b41df033 --- /dev/null +++ b/packages/destination-actions/src/destinations/aggregations-io/types.ts @@ -0,0 +1,9 @@ +import { HTTPError } from '@segment/actions-core' + +export class AggregationsAuthError extends HTTPError { + response: Response & { + data: { + message: string + } + } +} diff --git a/packages/destination-actions/src/destinations/airship/registerAndAssociate/generated-types.ts b/packages/destination-actions/src/destinations/airship/registerAndAssociate/generated-types.ts index af70c38e51..ec79526a6f 100644 --- a/packages/destination-actions/src/destinations/airship/registerAndAssociate/generated-types.ts +++ b/packages/destination-actions/src/destinations/airship/registerAndAssociate/generated-types.ts @@ -1,6 +1,14 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { + /** + * Email (default) or SMS + */ + channel_type?: string + /** + * A long or short code the app is configured to send from (if using for SMS). + */ + sms_sender?: string /** * The identifier assigned in Airship as the Named User */ @@ -22,7 +30,7 @@ export interface Payload { */ channel_object: { /** - * Email address to register (required) + * Email address or mobile number to register (required) */ address: string /** @@ -65,6 +73,10 @@ export interface Payload { * If an email channel is suppressed, the reason for its suppression. Email channels with any suppression state set will not have any delivery to them fulfilled. If a more specific reason is not known, use imported. Possible values: spam_complaint, bounce, imported */ suppression_state?: string + /** + * The date-time when a user gave explicit permission to receive SMS messages. + */ + sms_opted_in?: string [k: string]: unknown } } diff --git a/packages/destination-actions/src/destinations/airship/registerAndAssociate/index.ts b/packages/destination-actions/src/destinations/airship/registerAndAssociate/index.ts index 5a8dfee5b0..d69b752737 100644 --- a/packages/destination-actions/src/destinations/airship/registerAndAssociate/index.ts +++ b/packages/destination-actions/src/destinations/airship/registerAndAssociate/index.ts @@ -1,13 +1,30 @@ import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { register, associate_named_user, getChannelId } from '../utilities' +import { register, associate_named_user, getChannelId, EMAIL, SMS } from '../utilities' const action: ActionDefinition = { title: 'Register And Associate', - description: 'Register an Email address and associate it with a Named User ID.', - defaultSubscription: 'type = "track" and event="Email Address Registered"', + description: 'Register an Email address or SMS number and associate it with a Named User ID.', + defaultSubscription: 'type = "track" and event="Address Registered"', fields: { + channel_type: { + label: 'Channel Type', + description: 'Email (default) or SMS', + type: 'string', + choices: [ + { label: 'Email', value: EMAIL }, + { label: 'SMS', value: SMS } + ], + default: EMAIL, + required: false + }, + sms_sender: { + label: 'SMS Sender', + description: 'A long or short code the app is configured to send from (if using for SMS).', + type: 'string', + required: false + }, named_user_id: { label: 'Airship Named User ID', description: 'The identifier assigned in Airship as the Named User', @@ -60,8 +77,8 @@ const action: ActionDefinition = { required: true, properties: { address: { - label: 'Email Address', - description: 'Email address to register (required)', + label: 'Email Address or MSISDN', + description: 'Email address or mobile number to register (required)', type: 'string', required: true }, @@ -126,10 +143,16 @@ const action: ActionDefinition = { 'If an email channel is suppressed, the reason for its suppression. Email channels with any suppression state set will not have any delivery to them fulfilled. If a more specific reason is not known, use imported. Possible values: spam_complaint, bounce, imported', type: 'string', required: false + }, + sms_opted_in: { + label: 'SMS Opt In Date-Time', + description: 'The date-time when a user gave explicit permission to receive SMS messages.', + type: 'string', + required: false } }, default: { - address: { '@path': '$.properties.email' }, + address: { '@path': '$.properties.address' }, new_address: { '@path': '$.properties.new_email' }, commercial_opted_in: { '@path': '$.properties.commercial_opted_in' }, commercial_opted_out: { '@path': '$.properties.commercial_opted_out' }, @@ -139,7 +162,8 @@ const action: ActionDefinition = { open_tracking_opted_out: { '@path': '$.properties.open_tracking_opted_out' }, transactional_opted_in: { '@path': '$.properties.transactional_opted_in' }, transactional_opted_out: { '@path': '$.properties.transactional_opted_out' }, - suppression_state: { '@path': '$.context.suppression_state' } + suppression_state: { '@path': '$.context.suppression_state' }, + sms_opted_in: { '@path': '$.properties.sms_opted_in' } } } }, @@ -170,9 +194,13 @@ const action: ActionDefinition = { const data = JSON.parse(response_content) const channel_id = data.channel_id + let channel_type = EMAIL.toLowerCase() + if (payload.channel_type) { + channel_type = payload.channel_type.toLowerCase() + } if (payload.named_user_id && payload.named_user_id.length > 0) { - // If there's a Named User ID to associate with the email address, do it here - return await associate_named_user(request, settings, channel_id, payload.named_user_id) + // If there's a Named User ID to associate with the address, do it here + return await associate_named_user(request, settings, channel_id, payload.named_user_id, channel_type) } else { // If not, simply return the registration request, success or failure, for Segment to handle as per policy return register_response diff --git a/packages/destination-actions/src/destinations/airship/utilities.ts b/packages/destination-actions/src/destinations/airship/utilities.ts index 9a0bcf79e0..694243fae4 100644 --- a/packages/destination-actions/src/destinations/airship/utilities.ts +++ b/packages/destination-actions/src/destinations/airship/utilities.ts @@ -4,7 +4,9 @@ import { Payload as CustomEventsPayload } from './customEvents/generated-types' import { Payload as AttributesPayload } from './setAttributes/generated-types' import { Payload as TagsPayload } from './manageTags/generated-types' import { Payload as RegisterPayload } from './registerAndAssociate/generated-types' -import { timezone } from '../segment/segment-properties' + +export const EMAIL = 'email' +export const SMS = 'sms' // exported Action function export function register( @@ -14,6 +16,8 @@ export function register( old_channel: string | null ) { let address_to_use = payload.channel_object.address + const channel_type = payload.channel_type ?? EMAIL + const endpoint = map_endpoint(settings.endpoint) let register_uri = `${endpoint}/api/channels/email` if (old_channel && payload.channel_object.new_address) { @@ -24,78 +28,127 @@ export function register( if (payload.locale && payload.locale.length > 0) { country_language = _extract_country_language(payload.locale) } - const register_payload: { - channel: { - commercial_opted_in?: string - commercial_opted_out?: string - click_tracking_opted_in?: string - click_tracking_opted_out?: string - open_tracking_opted_in?: string - open_tracking_opted_out?: string - transactional_opted_in?: string - transactional_opted_out?: string - suppression_state?: string - type: string - address: string + + let locale_country = '' + let locale_language = '' + if (Array.isArray(country_language) && country_language.length === 2) { + locale_language = country_language[0] + locale_country = country_language[1] + } + + // set up email_email_register_payload + if (channel_type == SMS) { + register_uri = `${endpoint}/api/channels/sms` + const sms_register_payload: { + msisdn: string + sender?: string + opted_in?: string timezone?: string locale_language?: string locale_country?: string + } = { + msisdn: address_to_use } - } = { - channel: { - type: 'email', - address: address_to_use + if (payload.channel_object.sms_opted_in) { + sms_register_payload.opted_in = _parse_and_format_date(payload.channel_object.sms_opted_in) } + if (payload.sms_sender) { + sms_register_payload.sender = payload.sms_sender + } + // additional properties + if (locale_language) { + sms_register_payload.locale_language = locale_language + } + if (locale_country) { + sms_register_payload.locale_country = locale_country + } + if (payload.timezone) { + sms_register_payload.timezone = payload.timezone + } + return do_request(request, register_uri, sms_register_payload) + } else { + const email_register_payload: { + channel: { + commercial_opted_in?: string + commercial_opted_out?: string + click_tracking_opted_in?: string + click_tracking_opted_out?: string + open_tracking_opted_in?: string + open_tracking_opted_out?: string + transactional_opted_in?: string + transactional_opted_out?: string + suppression_state?: string + type?: string + address: string + timezone?: string + locale_language?: string + locale_country?: string + sms_opted_in?: string + sms_sender?: string + } + } = { + channel: { + type: channel_type, + address: address_to_use + } + } + email_register_payload.channel.type = 'email' + // handle and format all optional date params + if (payload.channel_object.suppression_state) { + email_register_payload.channel.suppression_state = payload.channel_object.suppression_state + } + if (payload.channel_object.commercial_opted_in) { + email_register_payload.channel.commercial_opted_in = _parse_and_format_date( + payload.channel_object.commercial_opted_in + ) + } + if (payload.channel_object.commercial_opted_out) { + email_register_payload.channel.commercial_opted_out = _parse_and_format_date( + payload.channel_object.commercial_opted_out + ) + } + if (payload.channel_object.click_tracking_opted_in) { + email_register_payload.channel.commercial_opted_in = _parse_and_format_date( + payload.channel_object.click_tracking_opted_in + ) + } + if (payload.channel_object.click_tracking_opted_out) { + email_register_payload.channel.click_tracking_opted_in = _parse_and_format_date( + payload.channel_object.click_tracking_opted_out + ) + } + if (payload.channel_object.open_tracking_opted_in) { + email_register_payload.channel.open_tracking_opted_in = _parse_and_format_date( + payload.channel_object.open_tracking_opted_in + ) + } + if (payload.channel_object.open_tracking_opted_out) { + email_register_payload.channel.open_tracking_opted_out = _parse_and_format_date( + payload.channel_object.open_tracking_opted_out + ) + } + if (payload.channel_object.transactional_opted_in) { + email_register_payload.channel.transactional_opted_in = _parse_and_format_date( + payload.channel_object.transactional_opted_in + ) + } + if (payload.channel_object.transactional_opted_out) { + email_register_payload.channel.transactional_opted_out = _parse_and_format_date( + payload.channel_object.transactional_opted_out + ) + } + // additional properties + if (locale_language) { + email_register_payload.channel.locale_language = locale_language + } + if (locale_country) { + email_register_payload.channel.locale_country = locale_country + } + if (payload.timezone) { + email_register_payload.channel.timezone = payload.timezone + } + return do_request(request, register_uri, email_register_payload) } - if (Array.isArray(country_language) && country_language.length === 2) { - payload.channel_object.locale_language = country_language[0] - payload.channel_object.locale_country = country_language[1] - } - if (timezone) { - payload.channel_object.timezone = payload.timezone - } - // handle and format all optional date params - if (payload.channel_object.commercial_opted_in) { - register_payload.channel.commercial_opted_in = _parse_and_format_date(payload.channel_object.commercial_opted_in) - } - if (payload.channel_object.commercial_opted_out) { - register_payload.channel.commercial_opted_out = _parse_and_format_date(payload.channel_object.commercial_opted_out) - } - if (payload.channel_object.click_tracking_opted_in) { - register_payload.channel.commercial_opted_in = _parse_and_format_date( - payload.channel_object.click_tracking_opted_in - ) - } - if (payload.channel_object.click_tracking_opted_out) { - register_payload.channel.click_tracking_opted_in = _parse_and_format_date( - payload.channel_object.click_tracking_opted_out - ) - } - if (payload.channel_object.open_tracking_opted_in) { - register_payload.channel.open_tracking_opted_in = _parse_and_format_date( - payload.channel_object.open_tracking_opted_in - ) - } - if (payload.channel_object.open_tracking_opted_out) { - register_payload.channel.open_tracking_opted_out = _parse_and_format_date( - payload.channel_object.open_tracking_opted_out - ) - } - if (payload.channel_object.transactional_opted_in) { - register_payload.channel.transactional_opted_in = _parse_and_format_date( - payload.channel_object.transactional_opted_in - ) - } - if (payload.channel_object.transactional_opted_out) { - register_payload.channel.transactional_opted_out = _parse_and_format_date( - payload.channel_object.transactional_opted_out - ) - } - if (payload.channel_object.suppression_state) { - register_payload.channel.suppression_state = payload.channel_object.suppression_state - } - - return do_request(request, register_uri, register_payload) } // exported Action function @@ -103,14 +156,15 @@ export function associate_named_user( request: RequestClient, settings: Settings, channel_id: string, - named_user_id: string + named_user_id: string, + channel_type: string ) { const endpoint = map_endpoint(settings.endpoint) const uri = `${endpoint}/api/named_users/associate` const associate_payload = { channel_id: channel_id, - device_type: 'email', + device_type: `${channel_type}`, named_user_id: named_user_id } @@ -230,8 +284,8 @@ function _build_attribute(attribute_key: string, attribute_value: any, occurred: adjustedDate = _parse_date(attribute_value) } - if (['home_phone', 'work_phone', 'mobile_phone'].includes(attribute_key) && typeof(attribute_value) == "string") { - attribute_value = parseInt(attribute_value.replace(/[^0-9]/g, "")) + if (['home_phone', 'work_phone', 'mobile_phone'].includes(attribute_key) && typeof attribute_value == 'string') { + attribute_value = parseInt(attribute_value.replace(/[^0-9]/g, '')) } const attribute: { @@ -257,7 +311,6 @@ function _build_attribute(attribute_key: string, attribute_value: any, occurred: return attribute } - function _build_tags_object(payload: TagsPayload): object { /* This function takes a Group event and builds a Tag payload. It assumes values are booleans @@ -274,6 +327,12 @@ function _build_tags_object(payload: TagsPayload): object { } else { tags_to_remove.push(k) } + } else if (typeof v == 'string' && ['true', 'false'].includes(v.toString().toLowerCase())) { + if (v.toLowerCase() === 'true') { + tags_to_add.push(k) + } else { + tags_to_remove.push(k) + } } } const airship_payload: { audience: {}; add?: {}; remove?: {} } = { audience: {} } diff --git a/packages/destination-actions/src/destinations/algolia-insights/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/algolia-insights/__tests__/__snapshots__/snapshot.test.ts.snap index 256e571a12..ca624207d3 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/algolia-insights/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,9 +4,19 @@ exports[`Testing snapshot for actions-algolia-insights destination: conversionEv Object { "events": Array [ Object { - "eventName": "Conversion Event", - "eventType": "conversion", + "currency": "HTG", + "eventName": "U[ABpE$k", + "eventSubtype": "purchase", + "eventType": "view", "index": "U[ABpE$k", + "objectData": Array [ + Object { + "discount": -52282070788997.12, + "price": -52282070788997.12, + "quantity": -52282070788997.12, + "queryID": "U[ABpE$k", + }, + ], "objectIDs": Array [ "U[ABpE$k", ], @@ -14,6 +24,7 @@ Object { "testType": "U[ABpE$k", "timestamp": null, "userToken": "U[ABpE$k", + "value": -52282070788997.12, }, ], } @@ -24,6 +35,7 @@ Object { "events": Array [ Object { "eventName": "Conversion Event", + "eventSubtype": "purchase", "eventType": "conversion", "index": "U[ABpE$k", "objectIDs": Array [ @@ -39,8 +51,8 @@ exports[`Testing snapshot for actions-algolia-insights destination: productAdded Object { "events": Array [ Object { - "eventName": "Add to cart", - "eventType": "conversion", + "eventName": "g)$f*TeM", + "eventType": "view", "index": "g)$f*TeM", "objectIDs": Array [ "g)$f*TeM", @@ -74,8 +86,8 @@ exports[`Testing snapshot for actions-algolia-insights destination: productClick Object { "events": Array [ Object { - "eventName": "Product Clicked", - "eventType": "click", + "eventName": "LLjxSD^^GnH", + "eventType": "conversion", "index": "LLjxSD^^GnH", "objectIDs": Array [ "LLjxSD^^GnH", @@ -112,8 +124,8 @@ exports[`Testing snapshot for actions-algolia-insights destination: productListF Object { "events": Array [ Object { - "eventName": "Product List Filtered", - "eventType": "click", + "eventName": "6O0djra", + "eventType": "view", "filters": Array [ "6O0djra:6O0djra", ], @@ -147,7 +159,7 @@ exports[`Testing snapshot for actions-algolia-insights destination: productViewe Object { "events": Array [ Object { - "eventName": "Product Viewed", + "eventName": "BLFCPcmz", "eventType": "view", "index": "BLFCPcmz", "objectIDs": Array [ diff --git a/packages/destination-actions/src/destinations/algolia-insights/algolia-insight-api.ts b/packages/destination-actions/src/destinations/algolia-insights/algolia-insight-api.ts index 7bd0ada893..b910f83a49 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/algolia-insight-api.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/algolia-insight-api.ts @@ -4,33 +4,43 @@ export const AlgoliaBehaviourURL = BaseAlgoliaInsightsURL + '/1/events' export const algoliaApiPermissionsUrl = (settings: Settings) => `https://${settings.appId}.algolia.net/1/keys/${settings.apiKey}` +export type AlgoliaEventType = 'view' | 'click' | 'conversion' + +export type AlgoliaEventSubtype = 'addToCart' | 'purchase' + type EventCommon = { eventName: string index: string userToken: string timestamp?: number queryID?: string + eventType: AlgoliaEventType } export type AlgoliaProductViewedEvent = EventCommon & { - eventType: 'view' objectIDs: string[] } export type AlgoliaProductClickedEvent = EventCommon & { - eventType: 'click' positions?: number[] objectIDs: string[] } export type AlgoliaFilterClickedEvent = EventCommon & { - eventType: 'click' filters: string[] } export type AlgoliaConversionEvent = EventCommon & { - eventType: 'conversion' + eventSubtype?: AlgoliaEventSubtype objectIDs: string[] + objectData?: { + queryID?: string + price?: number | string + discount?: number | string + quantity?: number + }[] + value?: number + currency?: string } export type AlgoliaApiPermissions = { diff --git a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/__snapshots__/snapshot.test.ts.snap index c2e852456e..4107c80fc1 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,9 +4,19 @@ exports[`Testing snapshot for AlgoliaInsights's conversionEvents destination act Object { "events": Array [ Object { - "eventName": "Conversion Event", - "eventType": "conversion", + "currency": "CUC", + "eventName": ")j)vR5%1AP*epuo8A%R", + "eventSubtype": "addToCart", + "eventType": "click", "index": ")j)vR5%1AP*epuo8A%R", + "objectData": Array [ + Object { + "discount": 76163635352698.88, + "price": 76163635352698.88, + "quantity": 76163635352698.88, + "queryID": ")j)vR5%1AP*epuo8A%R", + }, + ], "objectIDs": Array [ ")j)vR5%1AP*epuo8A%R", ], @@ -14,6 +24,7 @@ Object { "testType": ")j)vR5%1AP*epuo8A%R", "timestamp": null, "userToken": ")j)vR5%1AP*epuo8A%R", + "value": 76163635352698.88, }, ], } @@ -24,6 +35,7 @@ Object { "events": Array [ Object { "eventName": "Conversion Event", + "eventSubtype": "purchase", "eventType": "conversion", "index": ")j)vR5%1AP*epuo8A%R", "objectIDs": Array [ diff --git a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/index.test.ts b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/index.test.ts index ca0ad248bf..a8727e353d 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/__tests__/index.test.ts @@ -53,6 +53,7 @@ describe('AlgoliaInsights.conversionEvents', () => { expect(algoliaEvent.eventName).toBe('Conversion Event') expect(algoliaEvent.eventType).toBe('conversion') + expect(algoliaEvent.eventSubtype).toBe('purchase') expect(algoliaEvent.index).toBe(event.properties?.search_index) expect(algoliaEvent.userToken).toBe(event.userId) expect(algoliaEvent.objectIDs).toContain('9876') @@ -103,4 +104,177 @@ describe('AlgoliaInsights.conversionEvents', () => { const algoliaEvent = await testAlgoliaDestination(event) expect(algoliaEvent.queryID).toBe(event.properties?.query_id) }) + + it('should pass value if present', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + properties: { + query_id: '1234', + search_index: 'fashion_1', + products: [ + { + product_id: '9876', + product_name: 'skirt 1' + }, + { + product_id: '5432', + product_name: 'skirt 2' + } + ], + value: 200 + } + }) + const algoliaEvent = await testAlgoliaDestination(event) + expect(algoliaEvent.value).toBe(200) + }) + + it('should pass currency if present', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + properties: { + query_id: '1234', + search_index: 'fashion_1', + products: [ + { + product_id: '9876', + product_name: 'skirt 1' + }, + { + product_id: '5432', + product_name: 'skirt 2' + } + ], + currency: 'AUD' + } + }) + const algoliaEvent = await testAlgoliaDestination(event) + expect(algoliaEvent.currency).toBe('AUD') + }) + + describe('should pass product price data if present', () => { + it('all products contain all price properties', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + properties: { + query_id: '1234', + search_index: 'fashion_1', + products: [ + { + product_id: '9876', + product_name: 'skirt 1', + price: 105.99, + discount: 22.99, + quantity: 5 + }, + { + product_id: '5432', + product_name: 'skirt 2', + price: 0.6, + discount: 0.1, + quantity: 2 + } + ] + } + }) + const algoliaEvent = await testAlgoliaDestination(event) + expect(algoliaEvent.objectIDs).toEqual(['9876', '5432']) + expect(algoliaEvent.objectData).toEqual([ + { price: 105.99, discount: 22.99, quantity: 5 }, + { price: 0.6, discount: 0.1, quantity: 2 } + ]) + }) + + it('some products contain some price properties', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + properties: { + query_id: '1234', + search_index: 'fashion_1', + products: [ + { + product_id: '9876', + product_name: 'skirt 1', + price: 105.99, + quantity: 5 + }, + { + product_id: '9212', + product_name: 'dress 1', + price: 299.99, + discount: 12.99 + } + ] + } + }) + const algoliaEvent = await testAlgoliaDestination(event) + expect(algoliaEvent.objectIDs).toEqual(['9876', '9212']) + expect(algoliaEvent.objectData).toEqual([ + { price: 105.99, quantity: 5 }, + { price: 299.99, discount: 12.99 } + ]) + }) + + it('some products contain no price properties', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + properties: { + query_id: '1234', + search_index: 'fashion_1', + products: [ + { + product_id: '9876', + product_name: 'skirt 1', + price: 105.99, + discount: 22.99, + quantity: 5 + }, + { + product_id: '5432', + product_name: 'skirt 2' + }, + { + product_id: '9212', + product_name: 'dress 1', + price: 299.99, + discount: 12.99 + } + ] + } + }) + const algoliaEvent = await testAlgoliaDestination(event) + expect(algoliaEvent.objectIDs).toEqual(['9876', '5432', '9212']) + expect(algoliaEvent.objectData).toEqual([ + { price: 105.99, discount: 22.99, quantity: 5 }, + {}, + { price: 299.99, discount: 12.99 } + ]) + }) + + it('no products contain price properties', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Order Completed', + properties: { + query_id: '1234', + search_index: 'fashion_1', + products: [ + { + product_id: '9876' + }, + { + product_id: '5432' + } + ] + } + }) + const algoliaEvent = await testAlgoliaDestination(event) + expect(algoliaEvent.objectIDs).toEqual(['9876', '5432']) + expect(algoliaEvent.objectData).toBeUndefined() + }) + }) }) diff --git a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/generated-types.ts b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/generated-types.ts index 53bec96a71..3bc33277a3 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/generated-types.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/generated-types.ts @@ -2,10 +2,18 @@ export interface Payload { /** - * Populates the ObjectIds field in the Algolia Insights API. An array of objects representing the purchased items. Each object must contains a product_id field. + * Sub-type of the event, "purchase" or "addToCart". + */ + eventSubtype?: string + /** + * Populates the ObjectIDs field in the Algolia Insights API. An array of objects representing the purchased items. Each object must contain a product_id field. */ products: { product_id: string + price?: number + quantity?: number + discount?: number + queryID?: string }[] /** * Name of the targeted search index. @@ -23,10 +31,26 @@ export interface Payload { * The timestamp of the event. */ timestamp?: string + /** + * The value of the cart that is being converted. + */ + value?: number + /** + * Currency of the objects associated with the event in 3-letter ISO 4217 format. Required when `value` or `price` is set. + */ + currency?: string /** * Additional fields for this event. This field may be useful for Algolia Insights fields which are not mapped in Segment. */ extraProperties?: { [k: string]: unknown } + /** + * The name of the event to send to Algolia. Defaults to 'Conversion Event' + */ + eventName?: string + /** + * The type of event to send to Algolia. Defaults to 'conversion' + */ + eventType?: string } diff --git a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/index.ts b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/index.ts index 1853b38ef4..01f649138d 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/index.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/conversionEvents/index.ts @@ -1,24 +1,68 @@ import type { ActionDefinition, Preset } from '@segment/actions-core' import { defaultValues } from '@segment/actions-core' -import { AlgoliaBehaviourURL, AlgoliaConversionEvent } from '../algolia-insight-api' +import { + AlgoliaBehaviourURL, + AlgoliaConversionEvent, + AlgoliaEventSubtype, + AlgoliaEventType +} from '../algolia-insight-api' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' +const notUndef = (thing: unknown) => typeof thing !== 'undefined' + export const conversionEvents: ActionDefinition = { title: 'Conversion Events', description: 'In ecommerce, conversions are purchase events often but not always involving multiple products. Outside of a conversion can be any positive signal associated with an index record. Query ID is optional and indicates that the view events is the result of a search query.', fields: { + eventSubtype: { + label: 'Event Subtype', + description: 'Sub-type of the event, "purchase" or "addToCart".', + type: 'string', + required: false, + choices: [ + { value: 'purchase', label: 'Purchase' }, + { value: 'addToCart', label: 'Add To Cart' } + ], + default: 'purchase' + }, products: { label: 'Product Details', description: - 'Populates the ObjectIds field in the Algolia Insights API. An array of objects representing the purchased items. Each object must contains a product_id field.', + 'Populates the ObjectIDs field in the Algolia Insights API. An array of objects representing the purchased items. Each object must contain a product_id field.', type: 'object', multiple: true, - properties: { product_id: { label: 'product_id', type: 'string', required: true } }, + defaultObjectUI: 'keyvalue', + properties: { + product_id: { label: 'product_id', type: 'string', required: true }, + price: { label: 'price', type: 'number', required: false }, + quantity: { label: 'quantity', type: 'number', required: false }, + discount: { label: 'discount', type: 'number', required: false }, + queryID: { label: 'queryID', type: 'string', required: false } + }, required: true, default: { - '@path': '$.properties.products' + '@arrayPath': [ + '$.properties.products', + { + product_id: { + '@path': '$.product_id' + }, + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + discount: { + '@path': '$.discount' + }, + queryID: { + '@path': '$.queryID' + } + } + ] } }, index: { @@ -36,14 +80,18 @@ export const conversionEvents: ActionDefinition = { type: 'string', required: false, default: { - '@path': '$.properties.query_id' + '@if': { + exists: { '@path': '$.properties.query_id' }, + then: { '@path': '$.properties.query_id' }, + else: { '@path': '$.integrations.Algolia Insights (Actions).query_id' } + } } }, userToken: { type: 'string', required: true, description: 'The ID associated with the user.', - label: 'userToken', + label: 'User Token', default: { '@if': { exists: { '@path': '$.userId' }, @@ -56,11 +104,26 @@ export const conversionEvents: ActionDefinition = { type: 'string', required: false, description: 'The timestamp of the event.', - label: 'timestamp', + label: 'Timestamp', default: { '@path': '$.timestamp' } }, + value: { + type: 'number', + required: false, + description: 'The value of the cart that is being converted.', + label: 'Value', + default: { '@path': '$.properties.value' } + }, + currency: { + type: 'string', + required: false, + description: + 'Currency of the objects associated with the event in 3-letter ISO 4217 format. Required when `value` or `price` is set.', + label: 'Currency', + default: { '@path': '$.properties.currency' } + }, extraProperties: { - label: 'extraProperties', + label: 'Extra Properties', required: false, description: 'Additional fields for this event. This field may be useful for Algolia Insights fields which are not mapped in Segment.', @@ -68,17 +131,50 @@ export const conversionEvents: ActionDefinition = { default: { '@path': '$.properties' } + }, + eventName: { + label: 'Event Name', + description: "The name of the event to send to Algolia. Defaults to 'Conversion Event'", + type: 'string', + required: false, + default: 'Conversion Event' + }, + eventType: { + label: 'Event Type', + description: "The type of event to send to Algolia. Defaults to 'conversion'", + type: 'string', + required: false, + default: 'conversion', + choices: [ + { label: 'View', value: 'view' }, + { label: 'Conversion', value: 'conversion' }, + { label: 'Click', value: 'click' } + ] } }, defaultSubscription: 'type = "track" and event = "Order Completed"', perform: (request, data) => { + const objectData = data.payload.products.some(({ queryID, price, discount, quantity }) => { + return notUndef(queryID) || notUndef(price) || notUndef(discount) || notUndef(quantity) + }) + ? data.payload.products.map(({ queryID, price, discount, quantity }) => ({ + queryID, + price, + discount, + quantity + })) + : undefined const insightEvent: AlgoliaConversionEvent = { ...data.payload.extraProperties, - eventName: 'Conversion Event', - eventType: 'conversion', + eventName: data.payload.eventName ?? 'Conversion Event', + eventType: (data.payload.eventType as AlgoliaEventType) ?? ('conversion' as AlgoliaEventType), + eventSubtype: (data.payload.eventSubtype as AlgoliaEventSubtype) ?? 'purchase', index: data.payload.index, queryID: data.payload.queryID, objectIDs: data.payload.products.map((product) => product.product_id), + objectData, + value: data.payload.value, + currency: data.payload.currency, userToken: data.payload.userToken, timestamp: data.payload.timestamp ? new Date(data.payload.timestamp).valueOf() : undefined } diff --git a/packages/destination-actions/src/destinations/algolia-insights/index.ts b/packages/destination-actions/src/destinations/algolia-insights/index.ts index 9cd81bc4a6..80d19c0595 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/index.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/index.ts @@ -55,8 +55,14 @@ const destination: DestinationDefinition = { } } }, - // TODO: figure out how to pass multiple presets presets: [ + { + name: 'Algolia Plugin', + subscribe: 'type = "track" or type = "identify" or type = "group" or type = "page" or type = "alias"', + partnerAction: 'algoliaPlugin', + mapping: {}, + type: 'automatic' + }, productClickPresets, conversionPresets, productViewedPresets, diff --git a/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/__tests__/__snapshots__/snapshot.test.ts.snap index 1df5766a59..8718475486 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,8 +4,8 @@ exports[`Testing snapshot for AlgoliaInsights's productAddedEvents destination a Object { "events": Array [ Object { - "eventName": "Add to cart", - "eventType": "conversion", + "eventName": "D9W&9sjJ$g9LNBPqU", + "eventType": "click", "index": "D9W&9sjJ$g9LNBPqU", "objectIDs": Array [ "D9W&9sjJ$g9LNBPqU", diff --git a/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/generated-types.ts b/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/generated-types.ts index 93a008a3e0..3840498447 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/generated-types.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/generated-types.ts @@ -27,4 +27,12 @@ export interface Payload { extraProperties?: { [k: string]: unknown } + /** + * The name of the event to be send to Algolia. Defaults to 'Add to cart' + */ + eventName?: string + /** + * The type of event to send to Algolia. Defaults to 'conversion' + */ + eventType?: string } diff --git a/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/index.ts b/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/index.ts index c338b6604e..1808889278 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/index.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productAddedEvents/index.ts @@ -2,7 +2,7 @@ import type { ActionDefinition, Preset } from '@segment/actions-core' import { defaultValues } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { AlgoliaBehaviourURL, AlgoliaConversionEvent } from '../algolia-insight-api' +import { AlgoliaBehaviourURL, AlgoliaConversionEvent, AlgoliaEventType } from '../algolia-insight-api' export const productAddedEvents: ActionDefinition = { title: 'Product Added Events', @@ -34,14 +34,18 @@ export const productAddedEvents: ActionDefinition = { type: 'string', required: false, default: { - '@path': '$.properties.query_id' + '@if': { + exists: { '@path': '$.properties.query_id' }, + then: { '@path': '$.properties.query_id' }, + else: { '@path': '$.integrations.Algolia Insights (Actions).query_id' } + } } }, userToken: { type: 'string', required: true, description: 'The ID associated with the user.', - label: 'userToken', + label: 'User Token', default: { '@if': { exists: { '@path': '$.userId' }, @@ -54,11 +58,11 @@ export const productAddedEvents: ActionDefinition = { type: 'string', required: false, description: 'The timestamp of the event.', - label: 'timestamp', + label: 'Timestamp', default: { '@path': '$.timestamp' } }, extraProperties: { - label: 'extraProperties', + label: 'Extra Properties', required: false, description: 'Additional fields for this event. This field may be useful for Algolia Insights fields which are not mapped in Segment.', @@ -66,14 +70,33 @@ export const productAddedEvents: ActionDefinition = { default: { '@path': '$.properties' } + }, + eventName: { + label: 'Event Name', + description: "The name of the event to be send to Algolia. Defaults to 'Add to cart'", + type: 'string', + required: false, + default: 'Add to cart' + }, + eventType: { + label: 'Event Type', + description: "The type of event to send to Algolia. Defaults to 'conversion'", + type: 'string', + required: false, + default: 'conversion', + choices: [ + { label: 'view', value: 'view' }, + { label: 'conversion', value: 'conversion' }, + { label: 'click', value: 'click' } + ] } }, defaultSubscription: 'type = "track" and event = "Product Added"', perform: (request, data) => { const insightEvent: AlgoliaConversionEvent = { ...data.payload.extraProperties, - eventName: 'Add to cart', - eventType: 'conversion', + eventName: data.payload.eventName ?? 'Add to cart', + eventType: (data.payload.eventType as AlgoliaEventType) ?? ('conversion' as AlgoliaEventType), index: data.payload.index, queryID: data.payload.queryID, objectIDs: [data.payload.product], diff --git a/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/__tests__/__snapshots__/snapshot.test.ts.snap index 6b3056dd63..171113b996 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,8 +4,8 @@ exports[`Testing snapshot for AlgoliaInsights's productClickedEvents destination Object { "events": Array [ Object { - "eventName": "Product Clicked", - "eventType": "click", + "eventName": "tTO6#", + "eventType": "view", "index": "tTO6#", "objectIDs": Array [ "tTO6#", diff --git a/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/generated-types.ts b/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/generated-types.ts index ffeb5d4420..d932d1edde 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/generated-types.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/generated-types.ts @@ -31,4 +31,12 @@ export interface Payload { extraProperties?: { [k: string]: unknown } + /** + * The name of the event to be send to Algolia. Defaults to 'Product Clicked' + */ + eventName?: string + /** + * The type of event to send to Algolia. Defaults to 'click' + */ + eventType?: string } diff --git a/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/index.ts b/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/index.ts index 062f0dac94..13229ba8b4 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/index.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productClickedEvents/index.ts @@ -1,6 +1,6 @@ import type { ActionDefinition, Preset } from '@segment/actions-core' import { defaultValues } from '@segment/actions-core' -import { AlgoliaBehaviourURL, AlgoliaProductClickedEvent } from '../algolia-insight-api' +import { AlgoliaBehaviourURL, AlgoliaProductClickedEvent, AlgoliaEventType } from '../algolia-insight-api' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -32,7 +32,11 @@ export const productClickedEvents: ActionDefinition = { type: 'string', required: false, default: { - '@path': '$.properties.query_id' + '@if': { + exists: { '@path': '$.properties.query_id' }, + then: { '@path': '$.properties.query_id' }, + else: { '@path': '$.integrations.Algolia Insights (Actions).query_id' } + } } }, position: { @@ -48,7 +52,7 @@ export const productClickedEvents: ActionDefinition = { type: 'string', required: true, description: 'The ID associated with the user.', - label: 'userToken', + label: 'User Token', default: { '@if': { exists: { '@path': '$.userId' }, @@ -61,11 +65,11 @@ export const productClickedEvents: ActionDefinition = { type: 'string', required: false, description: 'The timestamp of the event.', - label: 'timestamp', + label: 'Timestamp', default: { '@path': '$.timestamp' } }, extraProperties: { - label: 'extraProperties', + label: 'Extra Properties', required: false, description: 'Additional fields for this event. This field may be useful for Algolia Insights fields which are not mapped in Segment.', @@ -73,14 +77,33 @@ export const productClickedEvents: ActionDefinition = { default: { '@path': '$.properties' } + }, + eventName: { + label: 'Event Name', + description: "The name of the event to be send to Algolia. Defaults to 'Product Clicked'", + type: 'string', + required: false, + default: 'Product Clicked' + }, + eventType: { + label: 'Event Type', + description: "The type of event to send to Algolia. Defaults to 'click'", + type: 'string', + required: false, + default: 'click', + choices: [ + { label: 'view', value: 'view' }, + { label: 'conversion', value: 'conversion' }, + { label: 'click', value: 'click' } + ] } }, defaultSubscription: 'type = "track" and event = "Product Clicked"', perform: (request, data) => { const insightEvent: AlgoliaProductClickedEvent = { ...data.payload.extraProperties, - eventName: 'Product Clicked', - eventType: 'click', + eventName: data.payload.eventName ?? 'Product Clicked', + eventType: (data.payload.eventType as AlgoliaEventType) ?? ('click' as AlgoliaEventType), index: data.payload.index, queryID: data.payload.queryID, objectIDs: [data.payload.objectID], diff --git a/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/__tests__/__snapshots__/snapshot.test.ts.snap index 18cc089414..7297765501 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,8 +4,8 @@ exports[`Testing snapshot for AlgoliaInsights's productListFilteredEvents destin Object { "events": Array [ Object { - "eventName": "Product List Filtered", - "eventType": "click", + "eventName": "E625IsTOULbrg8", + "eventType": "conversion", "filters": Array [ "E625IsTOULbrg8:E625IsTOULbrg8", ], diff --git a/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/generated-types.ts b/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/generated-types.ts index ecea21f0d1..5cc63dbc34 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/generated-types.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/generated-types.ts @@ -36,4 +36,12 @@ export interface Payload { extraProperties?: { [k: string]: unknown } + /** + * The name of the event to be send to Algolia. Defaults to 'Product List Filtered' + */ + eventName?: string + /** + * The type of event to send to Algolia. Defaults to 'click' + */ + eventType?: string } diff --git a/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/index.ts b/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/index.ts index ef6ee4e451..79f9359c55 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/index.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productListFilteredEvents/index.ts @@ -1,6 +1,6 @@ import type { ActionDefinition, Preset } from '@segment/actions-core' import { defaultValues } from '@segment/actions-core' -import { AlgoliaBehaviourURL, AlgoliaFilterClickedEvent } from '../algolia-insight-api' +import { AlgoliaBehaviourURL, AlgoliaFilterClickedEvent, AlgoliaEventType } from '../algolia-insight-api' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -40,14 +40,18 @@ export const productListFilteredEvents: ActionDefinition = { type: 'string', required: false, default: { - '@path': '$.properties.query_id' + '@if': { + exists: { '@path': '$.properties.query_id' }, + then: { '@path': '$.properties.query_id' }, + else: { '@path': '$.integrations.Algolia Insights (Actions).query_id' } + } } }, userToken: { type: 'string', required: true, description: 'The ID associated with the user.', - label: 'userToken', + label: 'User Token', default: { '@if': { exists: { '@path': '$.userId' }, @@ -60,11 +64,11 @@ export const productListFilteredEvents: ActionDefinition = { type: 'string', required: false, description: 'The timestamp of the event.', - label: 'timestamp', + label: 'Timestamp', default: { '@path': '$.timestamp' } }, extraProperties: { - label: 'extraProperties', + label: 'Extra Properties', required: false, description: 'Additional fields for this event. This field may be useful for Algolia Insights fields which are not mapped in Segment.', @@ -72,6 +76,25 @@ export const productListFilteredEvents: ActionDefinition = { default: { '@path': '$.properties' } + }, + eventName: { + label: 'Event Name', + description: "The name of the event to be send to Algolia. Defaults to 'Product List Filtered'", + type: 'string', + required: false, + default: 'Product List Filtered' + }, + eventType: { + label: 'Event Type', + description: "The type of event to send to Algolia. Defaults to 'click'", + type: 'string', + required: false, + default: 'click', + choices: [ + { label: 'view', value: 'view' }, + { label: 'conversion', value: 'conversion' }, + { label: 'click', value: 'click' } + ] } }, defaultSubscription: 'type = "track" and event = "Product List Filtered"', @@ -79,8 +102,8 @@ export const productListFilteredEvents: ActionDefinition = { const filters: string[] = data.payload.filters.map(({ attribute, value }) => `${attribute}:${value}`) const insightEvent: AlgoliaFilterClickedEvent = { ...data.payload.extraProperties, - eventName: 'Product List Filtered', - eventType: 'click', + eventName: data.payload.eventName ?? 'Product List Filtered', + eventType: (data.payload.eventType as AlgoliaEventType) ?? ('click' as AlgoliaEventType), index: data.payload.index, queryID: data.payload.queryID, filters, diff --git a/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/__tests__/__snapshots__/snapshot.test.ts.snap index a18b0e94d4..499d433b8e 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,8 +4,8 @@ exports[`Testing snapshot for AlgoliaInsights's productViewedEvents destination Object { "events": Array [ Object { - "eventName": "Product Viewed", - "eventType": "view", + "eventName": "og&DCP)aINw@qxe)", + "eventType": "click", "index": "og&DCP)aINw@qxe)", "objectIDs": Array [ "og&DCP)aINw@qxe)", diff --git a/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/generated-types.ts b/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/generated-types.ts index 0c36945027..f9dae0471f 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/generated-types.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/generated-types.ts @@ -27,4 +27,12 @@ export interface Payload { extraProperties?: { [k: string]: unknown } + /** + * The name of the event to be send to Algolia. Defaults to 'Product Viewed' + */ + eventName?: string + /** + * The type of event to send to Algolia. Defaults to 'view' + */ + eventType?: string } diff --git a/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/index.ts b/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/index.ts index 4b5847f40f..8b6e3bafd0 100644 --- a/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/index.ts +++ b/packages/destination-actions/src/destinations/algolia-insights/productViewedEvents/index.ts @@ -1,6 +1,6 @@ import type { ActionDefinition, Preset } from '@segment/actions-core' import { defaultValues } from '@segment/actions-core' -import { AlgoliaBehaviourURL, AlgoliaProductViewedEvent } from '../algolia-insight-api' +import { AlgoliaBehaviourURL, AlgoliaProductViewedEvent, AlgoliaEventType } from '../algolia-insight-api' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -33,14 +33,18 @@ export const productViewedEvents: ActionDefinition = { type: 'string', required: false, default: { - '@path': '$.properties.query_id' + '@if': { + exists: { '@path': '$.properties.query_id' }, + then: { '@path': '$.properties.query_id' }, + else: { '@path': '$.integrations.Algolia Insights (Actions).query_id' } + } } }, userToken: { type: 'string', required: true, description: 'The ID associated with the user.', - label: 'userToken', + label: 'User Token', default: { '@if': { exists: { '@path': '$.userId' }, @@ -53,11 +57,11 @@ export const productViewedEvents: ActionDefinition = { type: 'string', required: false, description: 'The timestamp of the event.', - label: 'timestamp', + label: 'Timestamp', default: { '@path': '$.timestamp' } }, extraProperties: { - label: 'extraProperties', + label: 'Extra Properties', required: false, description: 'Additional fields for this event. This field may be useful for Algolia Insights fields which are not mapped in Segment.', @@ -65,14 +69,33 @@ export const productViewedEvents: ActionDefinition = { default: { '@path': '$.properties' } + }, + eventName: { + label: 'Event Name', + description: "The name of the event to be send to Algolia. Defaults to 'Product Viewed'", + type: 'string', + required: false, + default: 'Product Viewed' + }, + eventType: { + label: 'Event Type', + description: "The type of event to send to Algolia. Defaults to 'view'", + type: 'string', + required: false, + default: 'view', + choices: [ + { label: 'view', value: 'view' }, + { label: 'conversion', value: 'conversion' }, + { label: 'click', value: 'click' } + ] } }, defaultSubscription: 'type = "track" and event = "Product Viewed"', perform: (request, data) => { const insightEvent: AlgoliaProductViewedEvent = { ...data.payload.extraProperties, - eventName: 'Product Viewed', - eventType: 'view', + eventName: data.payload.eventName ?? 'Product Viewed', + eventType: (data.payload.eventType as AlgoliaEventType) ?? ('view' as AlgoliaEventType), index: data.payload.index, queryID: data.payload.queryID, objectIDs: [data.payload.objectID], diff --git a/packages/destination-actions/src/destinations/amazon-ads/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/amazon-ads/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..fc3eebcc65 --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-amazon-ads destination: syncAudiences action - all fields 1`] = `Object {}`; + +exports[`Testing snapshot for actions-amazon-ads destination: syncAudiences action - required fields 1`] = `Object {}`; diff --git a/packages/destination-actions/src/destinations/amazon-ads/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/amazon-ads/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..98c220f8ff --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-amazon-ads' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/amazon-ads/generated-types.ts b/packages/destination-actions/src/destinations/amazon-ads/generated-types.ts new file mode 100644 index 0000000000..ce8fa11e89 --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Region for API Endpoint, either NA, EU, FE. + */ + region: string +} diff --git a/packages/destination-actions/src/destinations/amazon-ads/index.ts b/packages/destination-actions/src/destinations/amazon-ads/index.ts new file mode 100644 index 0000000000..94e7a0f392 --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/index.ts @@ -0,0 +1,113 @@ +import type { AudienceDestinationDefinition } from '@segment/actions-core' +import { InvalidAuthenticationError, IntegrationError, ErrorCodes } from '@segment/actions-core' +import type { RefreshTokenResponse, AmazonRefreshTokenError, AmazonTestAuthenticationError } from './types' +import type { Settings } from './generated-types' + +import syncAudiences from './syncAudiences' + +// For an example audience destination, refer to webhook-audiences. The Readme section is under 'Audience Support' +const destination: AudienceDestinationDefinition = { + name: 'Amazon Ads', + slug: 'actions-amazon-ads', + mode: 'cloud', + + authentication: { + scheme: 'oauth2', + fields: { + region: { + label: 'Region', + description: 'Region for API Endpoint, either NA, EU, FE.', + choices: [ + { label: 'North America (NA)', value: 'https://advertising-api.amazon.com' }, + { label: 'Europe (EU)', value: 'https://advertising-api-eu.amazon.com' }, + { label: 'Far East (FE)', value: 'https://advertising-api-fe.amazon.com' } + ], + default: 'North America (NA)', + type: 'string', + required: true + } + }, + testAuthentication: async (request, { auth }) => { + if (!auth?.accessToken) { + throw new InvalidAuthenticationError('Please authenticate via Oauth before enabling the destination.') + } + + try { + await request('https://advertising-api.amazon.com/v2/profiles', { + method: 'GET' + }) + } catch (e: any) { + const error = e as AmazonTestAuthenticationError + if (error.message === 'Unauthorized') { + throw new Error( + 'Invalid Amazon Oauth access token. Please reauthenticate to retrieve a valid access token before enabling the destination.' + ) + } + throw e + } + }, + refreshAccessToken: async (request, { auth }) => { + let res + + try { + res = await request('https://api.amazon.com/auth/o2/token', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: auth.refreshToken, + client_id: auth.clientId, + client_secret: auth.clientSecret, + grant_type: 'refresh_token' + }) + }) + } catch (e: any) { + const error = e as AmazonRefreshTokenError + if (error.response?.data?.error === 'invalid_grant') { + throw new IntegrationError( + `Invalid Authentication: Your refresh token is invalid or expired. Please re-authenticate to fetch a new refresh token.`, + ErrorCodes.REFRESH_TOKEN_EXPIRED, + 401 + ) + } + + throw new IntegrationError( + `Failed to fetch a new access token. Reason: ${error.response?.data?.error}`, + ErrorCodes.OAUTH_REFRESH_FAILED, + 401 + ) + } + + return { accessToken: res?.data?.access_token } + } + }, + extendRequest({ auth }) { + return { + headers: { + authorization: `Bearer ${auth?.accessToken}` + } + } + }, + + audienceFields: {}, + + audienceConfig: { + mode: { + type: 'synced', // Indicates that the audience is synced on some schedule; update as necessary + full_audience_sync: false // If true, we send the entire audience. If false, we just send the delta. + } + + // Get/Create are optional and only needed if you need to create an audience before sending events/users. + // createAudience: async (request, createAudienceInput) => { + + // }, + + // getAudience: async (request, getAudienceInput) => { + // // Right now, `getAudience` will mostly serve as a check to ensure the audience still exists in the destination + // return {externalId: ''} + // } + }, + actions: { + syncAudiences + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..bd8c26042e --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for AmazonAds's syncAudiences destination action: all fields 1`] = `Object {}`; + +exports[`Testing snapshot for AmazonAds's syncAudiences destination action: required fields 1`] = `Object {}`; diff --git a/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/index.test.ts b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/index.test.ts new file mode 100644 index 0000000000..552b716d56 --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/index.test.ts @@ -0,0 +1,27 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const event = createTestEvent({ + event: 'Example Event', + type: 'track', + context: { + traits: { + email: 'testing@testing.com' + } + } +}) + +describe('AmazonAds.syncAudiences', () => { + //This is an example unit test case, needs to update after developing streamConversion action + it('A sample unit case', async () => { + nock('https://example.com').post('/').reply(200, {}) + await expect( + testDestination.testAction('sampleEvent', { + event + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..780f2388fb --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'syncAudiences' +const destinationSlug = 'AmazonAds' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/generated-types.ts b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/generated-types.ts new file mode 100644 index 0000000000..944d22b085 --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload {} diff --git a/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/index.ts b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/index.ts new file mode 100644 index 0000000000..99b7f9b2a9 --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/syncAudiences/index.ts @@ -0,0 +1,17 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Sync Audiences', + description: 'Sync audiences from Segment to Amazon Ads Audience.', + fields: {}, + perform: (request, data) => { + return request('https://example.com', { + method: 'post', + json: data.payload + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/amazon-ads/types.ts b/packages/destination-actions/src/destinations/amazon-ads/types.ts new file mode 100644 index 0000000000..2f81259a0a --- /dev/null +++ b/packages/destination-actions/src/destinations/amazon-ads/types.ts @@ -0,0 +1,29 @@ +import { HTTPError } from '@segment/actions-core' + +export interface RefreshTokenResponse { + access_token: string + scope: string + expires_in: number + token_type: string +} + +// export interface ProfileAPIResponse { +// id: string +// } + +export class AmazonTestAuthenticationError extends HTTPError { + response: Response & { + data: { + message: string + } + } +} + +export class AmazonRefreshTokenError extends HTTPError { + response: Response & { + data: { + error: string + error_description: string + } + } +} diff --git a/packages/destination-actions/src/destinations/ambee/subscribeUserToCampaign/index.ts b/packages/destination-actions/src/destinations/ambee/subscribeUserToCampaign/index.ts index 056e2e59bf..13eb50379d 100644 --- a/packages/destination-actions/src/destinations/ambee/subscribeUserToCampaign/index.ts +++ b/packages/destination-actions/src/destinations/ambee/subscribeUserToCampaign/index.ts @@ -10,13 +10,15 @@ const action: ActionDefinition = { label: 'Segment Library', description: 'The Segment library used when the event was triggered. This Integration will only work with analytics.js or Mobile Segment libraries', - type: 'hidden', + type: 'string', + unsafe_hidden: true, default: { '@path': '$.context.library.name' } }, platform: { label: 'User Device Platform', description: 'The platform of the device which generated the event e.g. "Android" or "iOS"', - type: 'hidden', + type: 'string', + unsafe_hidden: true, default: { '@path': '$.context.device.type' } }, campaignId: { diff --git a/packages/destination-actions/src/destinations/apolloio/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/apolloio/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..59dca46a96 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-apolloio destination: track action - all fields 1`] = ` +Array [ + Object { + "anonymousId": "72d7bed1-4f42-4f2f-8955-72677340546b", + "campaign": Object { + "content": "campaign_content", + "medium": "campaign_medium", + "name": "campaign_name", + "source": "campaign_source", + "term": "campaign_term", + }, + "event": "Test Event", + "ipAddress": "111.222.333.444", + "page": Object { + "search": "search_query", + }, + "properties": Object { + "product_id": "pid_1", + }, + "timestamp": "2023-07-29T00:00:00.000Z", + "userId": "user1234", + }, +] +`; + +exports[`Testing snapshot for actions-apolloio destination: track action - required fields 1`] = ` +Array [ + Object { + "anonymousId": "anonId1234", + "campaign": Object {}, + "event": "Test Event", + "ipAddress": "111.222.333.444", + "page": Object { + "search": "search_query", + }, + "properties": Object {}, + "timestamp": "2023-07-29T00:00:00.000Z", + "userId": "user1234", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/apolloio/__tests__/index.test.ts b/packages/destination-actions/src/destinations/apolloio/__tests__/index.test.ts new file mode 100644 index 0000000000..c87303ec43 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/__tests__/index.test.ts @@ -0,0 +1,19 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import destination from '../index' + +const testDestination = createTestIntegration(destination) + +describe('Apollo.io', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + const settings = { + apiToken: 'test' + } + + nock(`https://apollo.io/${settings.apiToken}`).get(/.*/).reply(200, { is_logged_in: true }) + + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/apolloio/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/apolloio/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..411f0bc810 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/__tests__/snapshot.test.ts @@ -0,0 +1,105 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-apolloio' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + type: 'track', + event: 'Test Event', + userId: 'user1234', + timestamp: '2023-07-29T00:00:00.000Z', + context: { + page: { + search: 'search_query' + }, + ip: '111.222.333.444' + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + useDefaultMappings: true, + settings: { + apiToken: 'test' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + type: 'track', + event: 'Test Event', + userId: 'user1234', + anonymousId: '72d7bed1-4f42-4f2f-8955-72677340546b', + timestamp: '2023-07-29T00:00:00.000Z', + properties: { + product_id: 'pid_1' + }, + context: { + page: { + search: 'search_query' + }, + ip: '111.222.333.444', + campaign: { + name: 'campaign_name', + term: 'campaign_term', + source: 'campaign_source', + medium: 'campaign_medium', + content: 'campaign_content' + } + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + useDefaultMappings: true, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/apolloio/generated-types.ts b/packages/destination-actions/src/destinations/apolloio/generated-types.ts new file mode 100644 index 0000000000..e61f73b565 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * API Token for authorization. + */ + apiToken: string +} diff --git a/packages/destination-actions/src/destinations/apolloio/index.ts b/packages/destination-actions/src/destinations/apolloio/index.ts new file mode 100644 index 0000000000..303f2ba91e --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/index.ts @@ -0,0 +1,57 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { IntegrationError, defaultValues } from '@segment/actions-core' + +import track from './track' +export const baseURL = process?.env?.ACTIONS_APOLLOIO_BASE_URL_SECRET ?? 'https://apollo.io/' +export const authURL = process?.env?.ACTIONS_APOLLOIO_AUTH_URL_SECRET ?? 'https://apollo.io/' +export const headerSecret = `${process.env.ACTIONS_APOLLOIO_HEADER_SECRET}` + +const destination: DestinationDefinition = { + name: 'Apollo.io', + slug: 'actions-apolloio', + mode: 'cloud', + description: 'Send Segment analytics events to Apollo.io', + + authentication: { + scheme: 'custom', + fields: { + apiToken: { + label: 'API Token', + description: 'API Token for authorization.', + type: 'password', + required: true + } + }, + testAuthentication: (request, { settings }) => { + return request(authURL + settings.apiToken).then(async (response) => { + const { is_logged_in } = await response.json() + if (is_logged_in === false) { + throw new IntegrationError(`Invalid API Token`, 'INVALID_API_TOKEN', 401) + } + }) + } + }, + extendRequest({ settings }) { + return { + headers: { + api_key: settings.apiToken, + secret: headerSecret + } + } + }, + actions: { + track + }, + presets: [ + { + name: 'Track', + subscribe: 'type = "track"', + partnerAction: 'track', + mapping: defaultValues(track.fields), + type: 'automatic' + } + ] +} + +export default destination diff --git a/packages/destination-actions/src/destinations/apolloio/track/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/apolloio/track/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..32aa5e8d6a --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/track/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-apolloio's track destination action: all fields 1`] = ` +Array [ + Object { + "anonymousId": "72d7bed1-4f42-4f2f-8955-72677340546b", + "campaign": Object { + "content": "campaign_content", + "medium": "campaign_medium", + "name": "campaign_name", + "source": "campaign_source", + "term": "campaign_term", + }, + "event": "Test Event", + "ipAddress": "111.222.333.444", + "page": Object { + "search": "search_query", + }, + "properties": Object { + "product_id": "pid_1", + }, + "timestamp": "2023-07-29T00:00:00.000Z", + "userId": "user1234", + }, +] +`; + +exports[`Testing snapshot for actions-apolloio's track destination action: required fields 1`] = ` +Array [ + Object { + "anonymousId": "anonId1234", + "campaign": Object {}, + "event": "Test Event", + "ipAddress": "111.222.333.444", + "page": Object { + "search": "search_query", + }, + "properties": Object {}, + "timestamp": "2023-07-29T00:00:00.000Z", + "userId": "user1234", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/apolloio/track/__tests__/index.test.ts b/packages/destination-actions/src/destinations/apolloio/track/__tests__/index.test.ts new file mode 100644 index 0000000000..2e31170bd5 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/track/__tests__/index.test.ts @@ -0,0 +1,69 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { Settings } from '../../generated-types' + +const testDestination = createTestIntegration(Destination) +const actionSlug = 'track' + +const settings: Settings = { + apiToken: 'test' +} + +const event = createTestEvent({ + type: 'track', + event: 'Test Event', + userId: 'user1234', + anonymousId: '72d7bed1-4f42-4f2f-8955-72677340546b', + timestamp: '2022-03-30T17:24:58Z', + properties: { + product_id: 'pid_1' + }, + context: { + page: { + search: 'search_query' + }, + ip: '111.222.333.444', + campaign: { + name: 'campaign_name', + term: 'campaign_term', + source: 'campaign_source', + medium: 'campaign_medium', + content: 'campaign_content' + } + } +}) + +describe('Apolloio.track', () => { + it('should send event to Apollo.io', async () => { + nock('https://apollo.io/').post(/.*/).reply(200) + + const responses = await testDestination.testAction(actionSlug, { + event, + settings: settings, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject([ + { + event: 'Test Event', + properties: { product_id: 'pid_1' }, + timestamp: '2022-03-30T17:24:58Z', + ipAddress: '111.222.333.444', + userId: 'user1234', + campaign: { + name: 'campaign_name', + term: 'campaign_term', + source: 'campaign_source', + medium: 'campaign_medium', + content: 'campaign_content' + }, + page: { + search: 'search_query' + } + } + ]) + }) +}) diff --git a/packages/destination-actions/src/destinations/apolloio/track/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/apolloio/track/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..a6a5331c17 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/track/__tests__/snapshot.test.ts @@ -0,0 +1,100 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'track' +const destinationSlug = 'actions-apolloio' + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + type: 'track', + event: 'Test Event', + userId: 'user1234', + timestamp: '2023-07-29T00:00:00.000Z', + context: { + page: { + search: 'search_query' + }, + ip: '111.222.333.444' + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + useDefaultMappings: true, + settings: { + apiToken: 'test' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + type: 'track', + event: 'Test Event', + userId: 'user1234', + anonymousId: '72d7bed1-4f42-4f2f-8955-72677340546b', + timestamp: '2023-07-29T00:00:00.000Z', + properties: { + product_id: 'pid_1' + }, + context: { + page: { + search: 'search_query' + }, + ip: '111.222.333.444', + campaign: { + name: 'campaign_name', + term: 'campaign_term', + source: 'campaign_source', + medium: 'campaign_medium', + content: 'campaign_content' + } + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + useDefaultMappings: true, + settings: { + apiToken: 'test' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/apolloio/track/generated-types.ts b/packages/destination-actions/src/destinations/apolloio/track/generated-types.ts new file mode 100644 index 0000000000..96386364a3 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/track/generated-types.ts @@ -0,0 +1,84 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * An anonymous identifier + */ + anonymousId?: string + /** + * Event name + */ + event: string + /** + * Properties to associate with the event + */ + properties?: { + [k: string]: unknown + } + /** + * The timestamp of the event + */ + timestamp: string + /** + * The users's IP address. + */ + ipAddress: string + /** + * Timezone + */ + timezone?: string + /** + * The ID associated with the user + */ + userId?: string + /** + * UTM campaign information. + */ + campaign?: { + /** + * The name of the campaign. + */ + name?: string + /** + * The source of the campaign. + */ + source?: string + /** + * The medium of the campaign. + */ + medium?: string + /** + * The term of the campaign. + */ + term?: string + /** + * The content of the campaign. + */ + content?: string + } + /** + * Information about the page where the event occurred. + */ + page: { + /** + * The URL of the page where the event occurred. + */ + url?: string + /** + * The title of the page where the event occurred. + */ + title?: string + /** + * The referrer of the page where the event occurred. + */ + referrer?: string + /** + * The path of the page where the event occurred. + */ + path?: string + /** + * The search query of the page where the event occurred. + */ + search?: string + } +} diff --git a/packages/destination-actions/src/destinations/apolloio/track/index.ts b/packages/destination-actions/src/destinations/apolloio/track/index.ts new file mode 100644 index 0000000000..ee5e9b83a6 --- /dev/null +++ b/packages/destination-actions/src/destinations/apolloio/track/index.ts @@ -0,0 +1,164 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { baseURL } from '..' + +const action: ActionDefinition = { + title: 'Track', + description: 'Send user analytics events to Apollo.io', + defaultSubscription: 'type = "track"', + fields: { + anonymousId: { + type: 'string', + description: 'An anonymous identifier', + label: 'Anonymous ID', + default: { '@path': '$.anonymousId' } + }, + event: { + type: 'string', + label: 'Name', + description: 'Event name', + required: true, + default: { '@path': '$.event' } + }, + properties: { + type: 'object', + label: 'Properties', + description: 'Properties to associate with the event', + default: { '@path': '$.properties' } + }, + timestamp: { + type: 'string', + format: 'date-time', + required: true, + description: 'The timestamp of the event', + label: 'Timestamp', + default: { '@path': '$.timestamp' } + }, + ipAddress: { + label: 'IP Address', + description: "The users's IP address.", + type: 'string', + required: true, + default: { '@path': '$.context.ip' } + }, + timezone: { + label: 'Timezone', + description: 'Timezone', + type: 'string', + default: { + '@path': '$.context.timezone' + } + }, + userId: { + type: 'string', + description: 'The ID associated with the user', + label: 'User ID', + default: { '@path': '$.userId' } + }, + campaign: { + type: 'object', + required: false, + description: 'UTM campaign information.', + label: 'Campaign', + default: { + name: { '@path': '$.context.campaign.name' }, + source: { '@path': '$.context.campaign.source' }, + medium: { '@path': '$.context.campaign.medium' }, + term: { '@path': '$.context.campaign.term' }, + content: { '@path': '$.context.campaign.content' } + }, + properties: { + name: { + type: 'string', + required: false, + description: 'The name of the campaign.', + label: 'Name' + }, + source: { + type: 'string', + required: false, + description: 'The source of the campaign.', + label: 'Source' + }, + medium: { + type: 'string', + required: false, + description: 'The medium of the campaign.', + label: 'Medium' + }, + term: { + type: 'string', + required: false, + description: 'The term of the campaign.', + label: 'Term' + }, + content: { + type: 'string', + required: false, + description: 'The content of the campaign.', + label: 'Content' + } + } + }, + page: { + type: 'object', + required: true, + description: 'Information about the page where the event occurred.', + label: 'Page', + default: { + url: { '@path': '$.context.page.url' }, + title: { '@path': '$.context.page.title' }, + referrer: { '@path': '$.context.page.referrer' }, + path: { '@path': '$.context.page.path' }, + search: { '@path': '$.context.page.search' } + }, + properties: { + url: { + type: 'string', + required: false, + description: 'The URL of the page where the event occurred.', + label: 'URL' + }, + title: { + type: 'string', + required: false, + description: 'The title of the page where the event occurred.', + label: 'Title' + }, + referrer: { + type: 'string', + required: false, + description: 'The referrer of the page where the event occurred.', + label: 'Referrer' + }, + path: { + type: 'string', + required: false, + description: 'The path of the page where the event occurred.', + label: 'Path' + }, + search: { + type: 'string', + required: false, + description: 'The search query of the page where the event occurred.', + label: 'Search' + } + } + } + }, + perform: (request, data) => { + return request(baseURL, { + method: 'post', + json: [data.payload] + }) + }, + performBatch: (request, data) => { + return request(baseURL, { + method: 'post', + json: data.payload + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/app-fit/index.ts b/packages/destination-actions/src/destinations/app-fit/index.ts index 29a325b581..881a95b587 100644 --- a/packages/destination-actions/src/destinations/app-fit/index.ts +++ b/packages/destination-actions/src/destinations/app-fit/index.ts @@ -5,7 +5,7 @@ import AppFitConfig from './config' import track from './track' const destination: DestinationDefinition = { - name: 'App Fit', + name: 'AppFit', slug: 'actions-app-fit', mode: 'cloud', diff --git a/packages/destination-actions/src/destinations/attio/api/index.ts b/packages/destination-actions/src/destinations/attio/api/index.ts index 47032bdb71..522c152168 100644 --- a/packages/destination-actions/src/destinations/attio/api/index.ts +++ b/packages/destination-actions/src/destinations/attio/api/index.ts @@ -30,7 +30,8 @@ export class AttioClient { } /** - * Either create or update a Record in the Attio system. + * Either create or update a Record in the Attio system. Multi-select attribute values + * are always appended, never replaced. * * @param matching_attribute The Attribute to match the Record on (e.g. an email address) * @param object The Attio Object (id / api_slug) that this Record should belong to (e.g. "people") @@ -49,7 +50,7 @@ export class AttioClient { requestOptions?: Partial }): Promise> { return await this.request( - `${this.api_url}/v2/objects/${object}/records/simple?matching_attribute=${matching_attribute}`, + `${this.api_url}/v2/objects/${object}/records/simple?matching_attribute=${matching_attribute}&append_to_existing_values=true`, { method: 'put', json: { data: { values } }, diff --git a/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts index 627a3874a0..e0440d3b5e 100644 --- a/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/attio/assertRecord/__tests__/index.test.ts @@ -32,7 +32,7 @@ const mapping = { describe('Attio.assertRecord', () => { it('asserts a Record', async () => { nock('https://api.attio.com') - .put('/v2/objects/vehicles/records/simple?matching_attribute=name', { + .put('/v2/objects/vehicles/records/simple?matching_attribute=name&append_to_existing_values=true', { data: { values: { name: 'Stair car', diff --git a/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts index d93128a23e..fea8b6f6e1 100644 --- a/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/attio/groupWorkspace/__tests__/index.test.ts @@ -17,7 +17,8 @@ const event = createTestEvent({ const mapping = { domain: { '@path': '$.traits.domain' }, - workspace_id: { '@path': '$.traits.id' } + workspace_id: { '@path': '$.traits.id' }, + user_id: { '@path': '$.userId' } } describe('Attio.groupWorkspace', () => { @@ -35,7 +36,7 @@ describe('Attio.groupWorkspace', () => { } nock('https://api.attio.com') - .put('/v2/objects/companies/records/simple?matching_attribute=domains', { + .put('/v2/objects/companies/records/simple?matching_attribute=domains&append_to_existing_values=true', { data: { values: { domains: domain @@ -45,11 +46,12 @@ describe('Attio.groupWorkspace', () => { .reply(200, companyResponse) nock('https://api.attio.com') - .put('/v2/objects/workspaces/records/simple?matching_attribute=workspace_id', { + .put('/v2/objects/workspaces/records/simple?matching_attribute=workspace_id&append_to_existing_values=true', { data: { values: { company: 'record_id', - workspace_id: '42' + workspace_id: '42', + users: ['user1234'] } } }) @@ -66,9 +68,102 @@ describe('Attio.groupWorkspace', () => { expect(responses[1].status).toBe(200) }) + it('does not set a `users` property if missing from event', async () => { + const companyResponse: AssertResponse = { + data: { + id: { + workspace_id: 'workspace_id', + object_id: 'object_id', + record_id: 'record_id' + }, + created_at: new Date().toISOString(), + values: {} + } + } + + nock('https://api.attio.com') + .put('/v2/objects/companies/records/simple?matching_attribute=domains&append_to_existing_values=true', { + data: { + values: { + domains: domain + } + } + }) + .reply(200, companyResponse) + + nock('https://api.attio.com') + .put('/v2/objects/workspaces/records/simple?matching_attribute=workspace_id&append_to_existing_values=true', { + data: { + values: { + company: 'record_id', + workspace_id: '42' + } + } + }) + .reply(200, {}) + + const responses = await testDestination.testAction('groupWorkspace', { + event: { ...event, userId: null }, + mapping, + settings: {} + }) + + expect(responses.length).toBe(2) + expect(responses[0].status).toBe(200) + expect(responses[1].status).toBe(200) + }) + + it('does not set a `users` property if mapping is blank', async () => { + const companyResponse: AssertResponse = { + data: { + id: { + workspace_id: 'workspace_id', + object_id: 'object_id', + record_id: 'record_id' + }, + created_at: new Date().toISOString(), + values: {} + } + } + + nock('https://api.attio.com') + .put('/v2/objects/companies/records/simple?matching_attribute=domains&append_to_existing_values=true', { + data: { + values: { + domains: domain + } + } + }) + .reply(200, companyResponse) + + nock('https://api.attio.com') + .put('/v2/objects/workspaces/records/simple?matching_attribute=workspace_id&append_to_existing_values=true', { + data: { + values: { + company: 'record_id', + workspace_id: '42' + } + } + }) + .reply(200, {}) + + const responses = await testDestination.testAction('groupWorkspace', { + event, + mapping: { + ...mapping, + user_id: '' + }, + settings: {} + }) + + expect(responses.length).toBe(2) + expect(responses[0].status).toBe(200) + expect(responses[1].status).toBe(200) + }) + it('fails to assert a Company and returns', async () => { nock('https://api.attio.com') - .put('/v2/objects/companies/records/simple?matching_attribute=domains', { + .put('/v2/objects/companies/records/simple?matching_attribute=domains&append_to_existing_values=true', { data: { values: { domains: domain diff --git a/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts b/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts index 57ab611cf3..e2854dc918 100644 --- a/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts +++ b/packages/destination-actions/src/destinations/attio/groupWorkspace/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * The ID of the Workspace */ workspace_id: string + /** + * The ID of the User, if you'd like to link them to this Workspace (leave blank to skip). This assumes you will have already called the Attio identifyUser action: unrecognised Users will fail this action otherwise. + */ + user_id?: string /** * Additional attributes to either set or update on the Attio Company Record. The values on the left should be Segment attributes or custom text, and the values on the right are Attio Attribute IDs or Slugs. For example: traits.name → name */ diff --git a/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts b/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts index d42d336319..7780c4386d 100644 --- a/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts +++ b/packages/destination-actions/src/destinations/attio/groupWorkspace/index.ts @@ -34,6 +34,17 @@ const workspace_id: InputField = { } } +const user_id: InputField = { + type: 'string', + label: 'ID', + description: + "The ID of the User, if you'd like to link them to this Workspace (leave blank to skip). " + + 'This assumes you will have already called the Attio identifyUser action: unrecognised Users will fail this action otherwise.', + format: 'text', + required: false, + default: { '@path': '$.userId' } +} + const company_attributes: InputField = { type: 'object', label: 'Additional Company attributes', @@ -63,6 +74,7 @@ const action: ActionDefinition = { fields: { domain, workspace_id, + user_id, company_attributes, workspace_attributes }, @@ -85,6 +97,7 @@ const action: ActionDefinition = { values: { workspace_id: payload.workspace_id, company: company.data.data.id.record_id, + ...(payload.user_id ? { users: [payload.user_id] } : {}), ...(payload.workspace_attributes ?? {}) } }) diff --git a/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts index 6ab5dbb12c..2f836023b8 100644 --- a/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/attio/identifyUser/__tests__/index.test.ts @@ -28,7 +28,7 @@ const mapping = { describe('Attio.identifyUser', () => { it('asserts a Person and then a User', async () => { nock('https://api.attio.com') - .put('/v2/objects/people/records/simple?matching_attribute=email_addresses', { + .put('/v2/objects/people/records/simple?matching_attribute=email_addresses&append_to_existing_values=true', { data: { values: { email_addresses: email @@ -38,7 +38,7 @@ describe('Attio.identifyUser', () => { .reply(200, {}) nock('https://api.attio.com') - .put('/v2/objects/users/records/simple?matching_attribute=user_id', { + .put('/v2/objects/users/records/simple?matching_attribute=user_id&append_to_existing_values=true', { data: { values: { user_id: '9', @@ -63,7 +63,7 @@ describe('Attio.identifyUser', () => { it('fails to assert a Person and returns', async () => { nock('https://api.attio.com') - .put('/v2/objects/people/records/simple?matching_attribute=email_addresses', { + .put('/v2/objects/people/records/simple?matching_attribute=email_addresses&append_to_existing_values=true', { data: { values: { email_addresses: email diff --git a/packages/destination-actions/src/destinations/avo/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/avo/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..5489279832 --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-avo-inspector destination: sendSchemaToInspector action - all fields 1`] = ` +Array [ + Object { + "appName": "o5omS$Qs", + "appVersion": "o5omS$Qs", + "createdAt": "o5omS$Qs", + "eventHash": null, + "eventId": null, + "eventName": "o5omS$Qs", + "eventProperties": Array [ + Object { + "propertyName": "testType", + "propertyType": "string", + }, + ], + "libPlatform": "Segment", + "libVersion": "1.0.0", + "messageId": "o5omS$Qs", + "sessionId": "_", + "type": "event", + }, +] +`; + +exports[`Testing snapshot for actions-avo-inspector destination: sendSchemaToInspector action - required fields 1`] = ` +Array [ + Object { + "appName": "unnamed Segment app", + "appVersion": "unversioned", + "createdAt": "o5omS$Qs", + "eventHash": null, + "eventId": null, + "eventName": "o5omS$Qs", + "eventProperties": Array [ + Object { + "propertyName": "testType", + "propertyType": "string", + }, + ], + "libPlatform": "Segment", + "libVersion": "1.0.0", + "messageId": "o5omS$Qs", + "sessionId": "_", + "type": "event", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/avo/__tests__/index.test.ts b/packages/destination-actions/src/destinations/avo/__tests__/index.test.ts new file mode 100644 index 0000000000..d937a6c065 --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/__tests__/index.test.ts @@ -0,0 +1,21 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Avo Inspector', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://api.avo.app').post('/auth/inspector/validate').reply(200, {}) + + // This should match your authentication.fields + const authData = { + apiKey: 'test-api-key', + env: 'dev' + } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/avo/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/avo/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..7a97c05e88 --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/__tests__/snapshot.test.ts @@ -0,0 +1,80 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-avo-inspector' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + const receivedAt = '2024-01-31T22:06:15.449Z' + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + receivedAt: receivedAt, + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/avo/generated-types.ts b/packages/destination-actions/src/destinations/avo/generated-types.ts new file mode 100644 index 0000000000..7dc041ff6a --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Avo Inspector API Key can be found in the Inspector setup page on your source in Avo. + */ + apiKey: string + /** + * Avo Inspector Environment + */ + env: string + /** + * If you send a custom event property on all events that contains the app version, please enter the name of that property here (e.g. “app_version”). If you do not have a custom event property for the app version, please leave this field empty. + */ + appVersionPropertyName?: string +} diff --git a/packages/destination-actions/src/destinations/avo/index.ts b/packages/destination-actions/src/destinations/avo/index.ts new file mode 100644 index 0000000000..e4cce6ef4b --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/index.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { DestinationDefinition, defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' +import sendSchemaAction from './sendSchemaToInspector' +import { Environment } from './sendSchemaToInspector/avo-types' + +const destination: DestinationDefinition = { + name: 'Avo', + slug: 'actions-avo', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'Avo Inspector API Key', + description: 'Avo Inspector API Key can be found in the Inspector setup page on your source in Avo.', + type: 'string', + required: true + }, + env: { + label: 'Environment', + description: 'Avo Inspector Environment', + type: 'string', + choices: Object.values(Environment).map((environment) => ({ label: environment, value: environment })), + default: Environment.PROD, + required: true + }, + appVersionPropertyName: { + label: 'App Version Property', + description: + 'If you send a custom event property on all events that contains the app version, please enter the name of that property here (e.g. “app_version”). If you do not have a custom event property for the app version, please leave this field empty.', + type: 'string', + required: false + } + }, + testAuthentication: (request, { settings }) => { + // Return a request that tests/validates the user's credentials. + const resp = request(`https://api.avo.app/auth/inspector/validate`, { + method: 'post', + headers: { + 'Content-Type': 'application/json' // This line is crucial for sending JSON content + }, + body: JSON.stringify({ + apiKey: settings.apiKey + }) + }) + + return resp + } + }, + presets: [ + { + name: 'Track Schema From Event', + subscribe: 'type = "track"', + partnerAction: 'sendSchemaToInspector', + mapping: defaultValues(sendSchemaAction.fields), + type: 'automatic' + } + ], + actions: { + sendSchemaToInspector: sendSchemaAction // Add your action here + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/AvoSchemaParser.ts b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/AvoSchemaParser.ts new file mode 100644 index 0000000000..4f062578bc --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/AvoSchemaParser.ts @@ -0,0 +1,105 @@ +/* eslint-disable no-prototype-builtins */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { EventProperty } from './avo-types' + +const isArray = (obj: any): boolean => { + return Object.prototype.toString.call(obj) === '[object Array]' +} + +export class AvoSchemaParser { + static extractSchema(eventProperties: { [propName: string]: any }): Array { + if (eventProperties === null || eventProperties === undefined) { + return [] + } + + const mapping = (object: any) => { + if (isArray(object)) { + const list: [EventProperty] = object.map((x: any) => { + return mapping(x) + }) + return this.removeDuplicates(list) + } else if (typeof object === 'object') { + const mappedResult: Array = [] + for (const key in object) { + if (object.hasOwnProperty(key)) { + const val = object[key] + + const mappedEntry: EventProperty = { + propertyName: key, + propertyType: this.getPropValueType(val) + } + + if (typeof val === 'object' && val != null) { + mappedEntry['children'] = mapping(val) + } + + mappedResult.push(mappedEntry) + } + } + return mappedResult + } else { + return [] + } + } + + const mappedEventProps = mapping(eventProperties) + + return mappedEventProps + } + + private static removeDuplicates(array: Array): Array { + // Use a single object to track all seen propertyType:propertyName combinations + const seen: Record = {} + + return array.filter((item: EventProperty) => { + // Create a unique key based on propertyName and propertyType + const key = `${item.propertyName}:${item.propertyType}` + + if (!seen[key]) { + seen[key] = true // Mark this key as seen + return true // Include this item in the filtered result + } + // If the key was already seen, filter this item out + return false + }) + } + + private static getBasicPropType(propValue: any): string { + const propType = typeof propValue + if (propValue == null) { + return 'null' + } else if (propType === 'string') { + return 'string' + } else if (propType === 'number' || propType === 'bigint') { + if ((propValue + '').indexOf('.') >= 0) { + return 'float' + } else { + return 'int' + } + } else if (propType === 'boolean') { + return 'boolean' + } else if (propType === 'object') { + return 'object' + } else { + return 'unknown' + } + } + + private static getPropValueType(propValue: any): string { + if (isArray(propValue)) { + //we now know that propValue is an array. get first element in propValue array + const propElement = propValue[0] + + if (propElement == null) { + return 'list' // Default to list if the list is empty. + } else { + const propElementType = this.getBasicPropType(propElement) + return `list(${propElementType})` + } + } else { + return this.getBasicPropType(propValue) + } + } +} diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..ab22c4894b --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Avo's sendSchemaToInspector destination action: all fields 1`] = ` +Array [ + Object { + "appName": "NU$O%!ms2wKE]8XsN5@)", + "appVersion": "NU$O%!ms2wKE]8XsN5@)", + "createdAt": "NU$O%!ms2wKE]8XsN5@)", + "eventHash": null, + "eventId": null, + "eventName": "NU$O%!ms2wKE]8XsN5@)", + "eventProperties": Array [ + Object { + "propertyName": "testType", + "propertyType": "string", + }, + ], + "libPlatform": "Segment", + "libVersion": "1.0.0", + "messageId": "NU$O%!ms2wKE]8XsN5@)", + "sessionId": "_", + "type": "event", + }, +] +`; + +exports[`Testing snapshot for Avo's sendSchemaToInspector destination action: expect app Version to be extracted from property when set in settings 1`] = ` +Array [ + Object { + "appName": "NU$O%!ms2wKE]8XsN5@)", + "appVersion": "2.0.3", + "createdAt": "NU$O%!ms2wKE]8XsN5@)", + "eventHash": null, + "eventId": null, + "eventName": "NU$O%!ms2wKE]8XsN5@)", + "eventProperties": Array [ + Object { + "propertyName": "testType", + "propertyType": "string", + }, + ], + "libPlatform": "Segment", + "libVersion": "1.0.0", + "messageId": "NU$O%!ms2wKE]8XsN5@)", + "sessionId": "_", + "type": "event", + }, +] +`; + +exports[`Testing snapshot for Avo's sendSchemaToInspector destination action: required fields 1`] = ` +Array [ + Object { + "appName": "unnamed Segment app", + "appVersion": "unversioned", + "createdAt": "NU$O%!ms2wKE]8XsN5@)", + "eventHash": null, + "eventId": null, + "eventName": "NU$O%!ms2wKE]8XsN5@)", + "eventProperties": Array [ + Object { + "propertyName": "testType", + "propertyType": "string", + }, + ], + "libPlatform": "Segment", + "libVersion": "1.0.0", + "messageId": "NU$O%!ms2wKE]8XsN5@)", + "sessionId": "_", + "type": "event", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/index.test.ts b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/index.test.ts new file mode 100644 index 0000000000..e54add5473 --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/index.test.ts @@ -0,0 +1,25 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('Avo.sendSchemaToInspector', () => { + it('should validate action fields', async () => { + const event = createTestEvent({ previousId: 'test-prev-id' }) + + nock('https://api.avo.app').post('/inspector/segment/v1/track').reply(200, {}) + + const responses = await testDestination.testAction('sendSchemaToInspector', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key', + env: 'dev' + } + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + }) +}) diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..524f6dc4bc --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/__tests__/snapshot.test.ts @@ -0,0 +1,111 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendSchemaToInspector' +const destinationSlug = 'Avo' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + + it('expect app Version to be extracted from property when set in settings', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + ...eventData, + appVersion: '2.0.3' + } + }) + + settingsData.appVersionPropertyName = 'appVersion' + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/avo-types.ts b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/avo-types.ts new file mode 100644 index 0000000000..1f7be469bd --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/avo-types.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export enum Environment { + DEV = 'dev', + STAGING = 'staging', + PROD = 'prod' +} + +export interface EventProperty { + propertyName: string + propertyType: string + children?: any +} + +export interface BaseBody { + appName: string + appVersion: string + libVersion: string + libPlatform: string + messageId: string + createdAt: string + sessionId: string +} + +export interface EventSchemaBody extends BaseBody { + type: 'event' + eventName: string + eventProperties: Array + eventId: string | null + eventHash: string | null +} diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/avo.ts b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/avo.ts new file mode 100644 index 0000000000..08f5b77c4b --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/avo.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { BaseBody, EventSchemaBody } from './avo-types' + +import { AvoSchemaParser } from './AvoSchemaParser' + +import { Payload } from './generated-types' + +function getAppNameFromUrl(url: string) { + return url.split('/')[2] +} + +function generateBaseBody(event: Payload, appVersionPropertyName: string | undefined): BaseBody { + const appName = event.appName ?? (event.pageUrl ? getAppNameFromUrl(event.pageUrl) : 'unnamed Segment app') + + let appVersion: string + if (appVersionPropertyName !== undefined && appVersionPropertyName in event.properties) { + // Using bracket notation for dynamic property name access with type assertion + appVersion = event.properties[appVersionPropertyName] as string + } else { + appVersion = event.appVersion ?? 'unversioned' + } + + return { + appName: appName, + appVersion: appVersion, + libVersion: '1.0.0', + libPlatform: 'Segment', + messageId: event.messageId, + createdAt: event.createdAt, + sessionId: '_' + } +} + +function handleEvent(baseBody: BaseBody, event: Payload): EventSchemaBody { + // Initially declare eventBody with the type EventSchemaBody + // and explicitly set all properties to satisfy the type requirements. + const eventBody: EventSchemaBody = { + ...baseBody, // Spread operator to copy properties from baseBody + type: 'event', // Explicitly set type as 'event' + eventName: event.event, // Set from the event parameter + eventProperties: AvoSchemaParser.extractSchema(event.properties), + eventId: null, // Set default or actual value + eventHash: null // Set default or actual value + } + + return eventBody +} + +export function extractSchemaFromEvent(event: Payload, appVersionPropertyName: string | undefined) { + const baseBody: BaseBody = generateBaseBody(event, appVersionPropertyName) + + const eventBody: EventSchemaBody = handleEvent(baseBody, event) + + return eventBody +} diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/generated-types.ts b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/generated-types.ts new file mode 100644 index 0000000000..f6ed0f5aa1 --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/generated-types.ts @@ -0,0 +1,34 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Name of the event being sent + */ + event: string + /** + * Properties of the event being sent + */ + properties: { + [k: string]: unknown + } + /** + * Message ID of the event being sent + */ + messageId: string + /** + * Timestamp of when the event was sent + */ + createdAt: string + /** + * Version of the app that sent the event + */ + appVersion?: string + /** + * Name of the app that sent the event + */ + appName?: string + /** + * URL of the page that sent the event + */ + pageUrl?: string +} diff --git a/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/index.ts b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/index.ts new file mode 100644 index 0000000000..b45914eb49 --- /dev/null +++ b/packages/destination-actions/src/destinations/avo/sendSchemaToInspector/index.ts @@ -0,0 +1,101 @@ +import type { ActionDefinition, RequestClient } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +import { extractSchemaFromEvent } from './avo' + +const processEvents = async (request: RequestClient, settings: Settings, payload: Payload[]) => { + const events = payload.map((value) => extractSchemaFromEvent(value, settings.appVersionPropertyName)) + + const endpoint = 'https://api.avo.app/inspector/segment/v1/track' + + return request(endpoint, { + method: 'post', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + 'api-key': settings.apiKey, + env: settings.env + }, + body: JSON.stringify(events) + }) +} + +const sendSchemaAction: ActionDefinition = { + title: 'Track Schema From Event', + description: 'Sends event schema to the Avo Inspector API', + defaultSubscription: 'type = "track"', + fields: { + // Define any fields your action expects here + event: { + label: 'Event Name', + type: 'string', + description: 'Name of the event being sent', + required: true, + default: { + '@path': '$.event' + } + }, + properties: { + label: 'Properties', + type: 'object', + description: 'Properties of the event being sent', + required: true, + default: { + '@path': '$.properties' + } + }, + messageId: { + label: 'Message ID', + type: 'string', + description: 'Message ID of the event being sent', + required: true, + default: { + '@path': '$.messageId' + } + }, + createdAt: { + label: 'Created At', + type: 'string', + description: 'Timestamp of when the event was sent', + required: true, + default: { + '@path': '$.timestamp' + } + }, + appVersion: { + label: 'App Version', + type: 'string', + description: 'Version of the app that sent the event', + required: false, + default: { + '@path': '$.context.app.version' + } + }, + appName: { + label: 'App Name', + type: 'string', + description: 'Name of the app that sent the event', + required: false, + default: { + '@path': '$.context.app.name' + } + }, + pageUrl: { + label: 'Page URL', + type: 'string', + description: 'URL of the page that sent the event', + required: false, + default: { + '@path': '$.context.page.url' + } + } + }, + perform: async (request, { payload, settings }) => { + return processEvents(request, settings, [payload]) + }, + performBatch: async (request, { payload, settings }) => { + return processEvents(request, settings, payload) + } +} +export default sendSchemaAction diff --git a/packages/destination-actions/src/destinations/braze-cohorts/index.ts b/packages/destination-actions/src/destinations/braze-cohorts/index.ts index 4e20e7c4d4..7fc61e4790 100644 --- a/packages/destination-actions/src/destinations/braze-cohorts/index.ts +++ b/packages/destination-actions/src/destinations/braze-cohorts/index.ts @@ -29,6 +29,7 @@ const destination: DestinationDefinition = { { label: 'US-04 (https://dashboard-04.braze.com)', value: 'https://rest.iad-04.braze.com' }, { label: 'US-05 (https://dashboard-05.braze.com)', value: 'https://rest.iad-05.braze.com' }, { label: 'US-06 (https://dashboard-06.braze.com)', value: 'https://rest.iad-06.braze.com' }, + { label: 'US-07 (https://dashboard-07.braze.com)', value: 'https://rest.iad-07.braze.com' }, { label: 'US-08 (https://dashboard-08.braze.com)', value: 'https://rest.iad-08.braze.com' }, { label: 'EU-01 (https://dashboard-01.braze.eu)', value: 'https://rest.fra-01.braze.eu' }, { label: 'EU-02 (https://dashboard-02.braze.eu)', value: 'https://rest.fra-02.braze.eu' } diff --git a/packages/destination-actions/src/destinations/braze-cohorts/syncAudiences/index.ts b/packages/destination-actions/src/destinations/braze-cohorts/syncAudiences/index.ts index 8b7cdf73ca..84ff91303b 100644 --- a/packages/destination-actions/src/destinations/braze-cohorts/syncAudiences/index.ts +++ b/packages/destination-actions/src/destinations/braze-cohorts/syncAudiences/index.ts @@ -47,7 +47,8 @@ const action: ActionDefinition = { cohort_id: { label: 'Cohort ID', description: 'The Cohort Identifier', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_id' @@ -56,7 +57,8 @@ const action: ActionDefinition = { cohort_name: { label: 'Cohort Name', description: 'The name of Cohort', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_key' @@ -92,7 +94,8 @@ const action: ActionDefinition = { time: { label: 'Time', description: 'When the event occurred.', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.timestamp' diff --git a/packages/destination-actions/src/destinations/braze/__tests__/__snapshots__/braze.test.ts.snap b/packages/destination-actions/src/destinations/braze/__tests__/__snapshots__/braze.test.ts.snap index 9718695ae1..cd8404b25a 100644 --- a/packages/destination-actions/src/destinations/braze/__tests__/__snapshots__/braze.test.ts.snap +++ b/packages/destination-actions/src/destinations/braze/__tests__/__snapshots__/braze.test.ts.snap @@ -51,6 +51,7 @@ Object { "_update_existing_only": false, "app_id": "my-app-id", "braze_id": undefined, + "email": undefined, "external_id": "user1234", "name": "Test Event", "properties": Object {}, @@ -89,7 +90,7 @@ Headers { } `; -exports[`Braze Cloud Mode (Actions) updateUserProfile should require one of braze_id, user_alias, or external_id 1`] = `"One of \\"external_id\\" or \\"user_alias\\" or \\"braze_id\\" is required."`; +exports[`Braze Cloud Mode (Actions) updateUserProfile should require one of braze_id, user_alias, external_id or email 1`] = `"One of \\"external_id\\" or \\"user_alias\\" or \\"braze_id\\" or \\"email\\" is required."`; exports[`Braze Cloud Mode (Actions) updateUserProfile should work with default mappings 1`] = ` Headers { diff --git a/packages/destination-actions/src/destinations/braze/__tests__/braze.test.ts b/packages/destination-actions/src/destinations/braze/__tests__/braze.test.ts index 5ab1c25612..2a6210c188 100644 --- a/packages/destination-actions/src/destinations/braze/__tests__/braze.test.ts +++ b/packages/destination-actions/src/destinations/braze/__tests__/braze.test.ts @@ -35,7 +35,7 @@ describe('Braze Cloud Mode (Actions)', () => { expect(responses[0].options.json).toMatchSnapshot() }) - it('should require one of braze_id, user_alias, or external_id', async () => { + it('should require one of braze_id, user_alias, external_id or email', async () => { nock('https://rest.iad-01.braze.com').post('/users/track').reply(200, {}) const event = createTestEvent({ @@ -479,6 +479,59 @@ describe('Braze Cloud Mode (Actions)', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) }) + it('should success with mapping of preset and Journey Step Entered event(presets) ', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Journey Step Entered', + properties: { + journey_metadata: { + journey_id: 'test-journey-id', + journey_name: 'test-journey-name', + step_id: 'test-step-id', + step_name: 'test-step-name' + }, + journey_context: { + appointment_booked: { + type: 'track', + event: 'Appointment Booked', + timestamp: '2021-09-01T00:00:00.000Z', + properties: { + appointment_id: 'test-appointment-id', + appointment_date: '2021-09-01T00:00:00.000Z', + appointment_type: 'test-appointment-type' + } + }, + appointment_confirmed: { + type: 'track', + event: 'Appointment Confirmed', + timestamp: '2021-09-01T00:00:00.000Z', + properties: { + appointment_id: 'test-appointment-id', + appointment_date: '2021-09-01T00:00:00.000Z', + appointment_type: 'test-appointment-type' + } + } + } + } + }) + + nock('https://rest.iad-01.braze.com').post('/users/track').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + settings, + // Using the mapping of presets with event type 'track' + mapping: { + properties: { + '@path': '$.properties' + } + }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) it('should success with mapping of preset and `identify` call', async () => { const event = createTestEvent({ type: 'identify', diff --git a/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap index c20fec87ec..8a41ddd358 100644 --- a/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -11,6 +11,7 @@ Object { }, }, ], + "merge_behavior": "merge", } `; @@ -27,3 +28,17 @@ Object { ], } `; + +exports[`all fields - backwards compatibility testing 1`] = ` +Object { + "aliases_to_identify": Array [ + Object { + "external_id": "L6iTV8uKjdSaPxy2fjx9", + "user_alias": Object { + "alias_label": "L6iTV8uKjdSaPxy2fjx9", + "alias_name": "L6iTV8uKjdSaPxy2fjx9", + }, + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/snapshot.test.ts index aab7286ee3..c4035df309 100644 --- a/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/braze/identifyUser/__tests__/snapshot.test.ts @@ -73,3 +73,37 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac } }) }) + +it('all fields - backwards compatibility testing', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + // make sure existing destinations payload did not change if they don't have merge_behavior defined + delete eventData.merge_behavior + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } +}) diff --git a/packages/destination-actions/src/destinations/braze/identifyUser/generated-types.ts b/packages/destination-actions/src/destinations/braze/identifyUser/generated-types.ts index 9fd2401d27..257ab73994 100644 --- a/packages/destination-actions/src/destinations/braze/identifyUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/identifyUser/generated-types.ts @@ -12,4 +12,8 @@ export interface Payload { alias_name: string alias_label: string } + /** + * Sets the endpoint to merge some fields found exclusively on the anonymous user to the identified user. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_user_identify/#request-parameters). + */ + merge_behavior?: string } diff --git a/packages/destination-actions/src/destinations/braze/identifyUser/index.ts b/packages/destination-actions/src/destinations/braze/identifyUser/index.ts index ac88f02f17..af2336dce9 100644 --- a/packages/destination-actions/src/destinations/braze/identifyUser/index.ts +++ b/packages/destination-actions/src/destinations/braze/identifyUser/index.ts @@ -31,6 +31,16 @@ const action: ActionDefinition = { required: true } } + }, + merge_behavior: { + label: 'Merge Behavior', + description: + 'Sets the endpoint to merge some fields found exclusively on the anonymous user to the identified user. See [the docs](https://www.braze.com/docs/api/endpoints/user_data/post_user_identify/#request-parameters).', + type: 'string', + choices: [ + { value: 'none', label: 'None' }, + { value: 'merge', label: 'Merge' } + ] } }, perform: (request, { settings, payload }) => { @@ -42,7 +52,8 @@ const action: ActionDefinition = { external_id: payload.external_id, user_alias: payload.user_alias } - ] + ], + ...(payload.merge_behavior !== undefined && { merge_behavior: payload.merge_behavior }) } }) } diff --git a/packages/destination-actions/src/destinations/braze/index.ts b/packages/destination-actions/src/destinations/braze/index.ts index 873c6ea4a5..a991c437c0 100644 --- a/packages/destination-actions/src/destinations/braze/index.ts +++ b/packages/destination-actions/src/destinations/braze/index.ts @@ -152,6 +152,18 @@ const destination: DestinationDefinition = { }, type: 'specificEvent', eventSlug: 'warehouse_audience_membership_changed_identify' + }, + { + name: 'Journeys Step Transition Track', + partnerAction: 'trackEvent', + mapping: { + ...defaultValues(trackEvent.fields), + properties: { + '@path': '$.properties' + } + }, + type: 'specificEvent', + eventSlug: 'journeys_step_entered_track' } ] } diff --git a/packages/destination-actions/src/destinations/braze/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/braze/trackEvent/generated-types.ts index af3c4862af..b9ae9dd69e 100644 --- a/packages/destination-actions/src/destinations/braze/trackEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/trackEvent/generated-types.ts @@ -12,6 +12,10 @@ export interface Payload { alias_name?: string alias_label?: string } + /** + * The user email + */ + email?: string /** * The unique user identifier */ diff --git a/packages/destination-actions/src/destinations/braze/trackEvent/index.ts b/packages/destination-actions/src/destinations/braze/trackEvent/index.ts index 7a5b62bd98..99e3e22eeb 100644 --- a/packages/destination-actions/src/destinations/braze/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/braze/trackEvent/index.ts @@ -32,6 +32,14 @@ const action: ActionDefinition = { } } }, + email: { + label: 'Email', + description: 'The user email', + type: 'string', + default: { + '@path': '$.traits.email' + } + }, braze_id: { label: 'Braze User Identifier', description: 'The unique user identifier', @@ -79,7 +87,7 @@ const action: ActionDefinition = { label: 'Batch Data to Braze', description: 'If true, Segment will batch events before sending to Braze’s user track endpoint. Braze accepts batches of up to 75 events.', - default: false + default: true } }, perform: (request, { settings, payload }) => { diff --git a/packages/destination-actions/src/destinations/braze/trackPurchase/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/trackPurchase/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..edd6cafeae --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/trackPurchase/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,174 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Braze's trackPurchase destination action: all fields 1`] = ` +Object { + "purchases": Array [ + Object { + "_update_existing_only": false, + "app_id": "89*x$dc1)L5yD11", + "braze_id": "89*x$dc1)L5yD11", + "currency": "CAD", + "email": "sa@va.bh", + "external_id": "89*x$dc1)L5yD11", + "price": 32519063401922.56, + "product_id": "89*x$dc1)L5yD11", + "properties": Object { + "testType": "89*x$dc1)L5yD11", + }, + "quantity": 3251906340192256, + "time": "2021-02-01T00:00:00.000Z", + "user_alias": Object { + "alias_label": "89*x$dc1)L5yD11", + "alias_name": "89*x$dc1)L5yD11", + }, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's trackPurchase destination action: it should work with a single batched events 1`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer my-api-key", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + "x-braze-batch": Array [ + "true", + ], + }, +} +`; + +exports[`Testing snapshot for Braze's trackPurchase destination action: it should work with a single batched events 2`] = ` +Object { + "purchases": Array [ + Object { + "_update_existing_only": true, + "app_id": "my-app-id", + "braze_id": undefined, + "currency": "USD", + "email": undefined, + "external_id": "user1234", + "price": 100, + "product_id": "Bowflex Treadmill 10", + "properties": Object {}, + "quantity": 1, + "time": "2021-08-03T17:40:04.055Z", + "user_alias": undefined, + }, + Object { + "_update_existing_only": true, + "app_id": "my-app-id", + "braze_id": undefined, + "currency": "USD", + "email": undefined, + "external_id": "user1234", + "price": 200, + "product_id": "Bowflex Treadmill 20", + "properties": Object {}, + "quantity": 2, + "time": "2021-08-03T17:40:04.055Z", + "user_alias": undefined, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's trackPurchase destination action: it should work with batched events 1`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer my-api-key", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + "x-braze-batch": Array [ + "true", + ], + }, +} +`; + +exports[`Testing snapshot for Braze's trackPurchase destination action: it should work with batched events 2`] = ` +Object { + "purchases": Array [ + Object { + "_update_existing_only": true, + "app_id": "my-app-id", + "braze_id": undefined, + "currency": "USD", + "email": undefined, + "external_id": "user1234", + "price": 100, + "product_id": "Bowflex Treadmill 10", + "properties": Object {}, + "quantity": 1, + "time": "2021-08-03T17:40:04.055Z", + "user_alias": undefined, + }, + Object { + "_update_existing_only": true, + "app_id": "my-app-id", + "braze_id": undefined, + "currency": "USD", + "email": undefined, + "external_id": "user1234", + "price": 200, + "product_id": "Bowflex Treadmill 20", + "properties": Object {}, + "quantity": 2, + "time": "2021-08-03T17:40:04.055Z", + "user_alias": undefined, + }, + Object { + "_update_existing_only": true, + "app_id": "my-app-id", + "braze_id": undefined, + "currency": "USD", + "email": undefined, + "external_id": "user1234", + "price": 300, + "product_id": "Bowflex Treadmill 30", + "properties": Object {}, + "quantity": 3, + "time": "2021-08-03T17:40:04.055Z", + "user_alias": undefined, + }, + Object { + "_update_existing_only": true, + "app_id": "my-app-id", + "braze_id": undefined, + "currency": "USD", + "email": undefined, + "external_id": "user1234", + "price": 400, + "product_id": "Bowflex Treadmill 40", + "properties": Object {}, + "quantity": 4, + "time": "2021-08-03T17:40:04.055Z", + "user_alias": undefined, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's trackPurchase destination action: required fields 1`] = ` +Object { + "purchases": Array [ + Object { + "app_id": "89*x$dc1)L5yD11", + "braze_id": "89*x$dc1)L5yD11", + "currency": "USD", + "external_id": "89*x$dc1)L5yD11", + "price": 32519063401922.56, + "product_id": "89*x$dc1)L5yD11", + "properties": Object {}, + "time": "2021-02-01T00:00:00.000Z", + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/braze/trackPurchase/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/braze/trackPurchase/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..bce72c0362 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/trackPurchase/__tests__/snapshot.test.ts @@ -0,0 +1,228 @@ +import { SegmentEvent, createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'trackPurchase' +const destinationSlug = 'Braze' +const seedName = `${destinationSlug}#${actionSlug}` +const receivedAt = '2021-08-03T17:40:04.055Z' +const settings = { + app_id: 'my-app-id', + api_key: 'my-api-key', + endpoint: 'https://rest.iad-01.braze.com' as const +} + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + + it('it should work with batched events', async () => { + nock('https://rest.iad-01.braze.com').post('/users/track').reply(200, {}) + + const events: SegmentEvent[] = [ + createTestEvent({ + event: 'Test Event 1', + type: 'track', + receivedAt, + properties: { + products: [ + { + quantity: 1, + product_id: 'Bowflex Treadmill 10', + price: 100 + }, + { + quantity: 2, + product_id: 'Bowflex Treadmill 20', + price: 200 + } + ] + } + }), + createTestEvent({ + event: 'Test Event 2', + type: 'track', + receivedAt, + properties: { + products: [ + { + quantity: 3, + product_id: 'Bowflex Treadmill 30', + price: 300 + }, + { + quantity: 4, + product_id: 'Bowflex Treadmill 40', + price: 400 + } + ] + } + }) + ] + + const responses = await testDestination.testBatchAction(actionSlug, { + events, + useDefaultMappings: true, + // The email field defaults to traits.email when not otherwise set. This results in an undefined value for the email field in our snapshots + // We do not send email: undefined downstream to Braze as actions will filter that out automatically + mapping: { + external_id: { + '@path': '$.userId' + }, + user_alias: {}, + braze_id: { + '@path': '$.properties.braze_id' + }, + name: { + '@path': '$.event' + }, + time: { + '@path': '$.receivedAt' + }, + properties: { + '@path': '$.properties' + }, + products: { + '@path': '$.properties.products' + }, + enable_batching: true, + _update_existing_only: true + }, + settings: { + ...settings + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.headers).toMatchSnapshot() + expect(responses[0].options.json).toMatchSnapshot() + }) + + it('it should work with a single batched events', async () => { + nock('https://rest.iad-01.braze.com').post('/users/track').reply(200, {}) + + const events: SegmentEvent[] = [ + createTestEvent({ + event: 'Test Event 1', + type: 'track', + receivedAt, + properties: { + products: [ + { + quantity: 1, + product_id: 'Bowflex Treadmill 10', + price: 100 + }, + { + quantity: 2, + product_id: 'Bowflex Treadmill 20', + price: 200 + } + ] + } + }) + ] + + const responses = await testDestination.testBatchAction(actionSlug, { + events, + useDefaultMappings: true, + // The email field defaults to traits.email when not otherwise set. This results in an undefined value for the email field in our snapshots + // We do not send email: undefined downstream to Braze as actions will filter that out automatically + mapping: { + external_id: { + '@path': '$.userId' + }, + user_alias: {}, + braze_id: { + '@path': '$.properties.braze_id' + }, + name: { + '@path': '$.event' + }, + time: { + '@path': '$.receivedAt' + }, + properties: { + '@path': '$.properties' + }, + products: { + '@path': '$.properties.products' + }, + enable_batching: true, + _update_existing_only: true + }, + settings: { + ...settings + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.headers).toMatchSnapshot() + expect(responses[0].options.json).toMatchSnapshot() + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/trackPurchase/generated-types.ts b/packages/destination-actions/src/destinations/braze/trackPurchase/generated-types.ts index d446aceb7a..802a1860a5 100644 --- a/packages/destination-actions/src/destinations/braze/trackPurchase/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/trackPurchase/generated-types.ts @@ -12,6 +12,10 @@ export interface Payload { alias_name?: string alias_label?: string } + /** + * The user email + */ + email?: string /** * The unique user identifier */ @@ -40,4 +44,8 @@ export interface Payload { * Setting this flag to true will put the API in "Update Only" mode. When using a "user_alias", "Update Only" mode is always true. */ _update_existing_only?: boolean + /** + * If true, Segment will batch events before sending to Braze’s user track endpoint. Braze accepts batches of up to 75 events. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/braze/trackPurchase/index.ts b/packages/destination-actions/src/destinations/braze/trackPurchase/index.ts index f7ee6928aa..07f1c0871e 100644 --- a/packages/destination-actions/src/destinations/braze/trackPurchase/index.ts +++ b/packages/destination-actions/src/destinations/braze/trackPurchase/index.ts @@ -1,7 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { sendTrackPurchase } from '../utils' +import { sendTrackPurchase, sendBatchedTrackPurchase } from '../utils' const action: ActionDefinition = { title: 'Track Purchase', @@ -32,6 +32,14 @@ const action: ActionDefinition = { } } }, + email: { + label: 'Email', + description: 'The user email', + type: 'string', + default: { + '@path': '$.traits.email' + } + }, braze_id: { label: 'Braze User Identifier', description: 'The unique user identifier', @@ -95,10 +103,20 @@ const action: ActionDefinition = { 'Setting this flag to true will put the API in "Update Only" mode. When using a "user_alias", "Update Only" mode is always true.', type: 'boolean', default: false + }, + enable_batching: { + type: 'boolean', + label: 'Batch Data to Braze', + description: + 'If true, Segment will batch events before sending to Braze’s user track endpoint. Braze accepts batches of up to 75 events.', + default: true } }, perform: (request, { settings, payload }) => { return sendTrackPurchase(request, settings, payload) + }, + performBatch: (request, { settings, payload }) => { + return sendBatchedTrackPurchase(request, settings, payload) } } diff --git a/packages/destination-actions/src/destinations/braze/updateUserProfile/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/braze/updateUserProfile/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..d191892fc2 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/updateUserProfile/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,208 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Braze's updateUserProfile destination action: all fields 1`] = ` +Object { + "attributes": Array [ + Object { + "_update_existing_only": false, + "braze_id": "M8rtOywDKgN((jhUBk9", + "country": "M8rtOywDKgN((jhUBk9", + "current_location": Object { + "latitude": 74322492134522.88, + "longitude": 74322492134522.88, + }, + "date_of_first_session": "2021-02-01T00:00:00.000Z", + "date_of_last_session": "2021-02-01T00:00:00.000Z", + "dob": "2021-02-01", + "email": "wifot@vuri.cl", + "email_click_tracking_disabled": false, + "email_open_tracking_disabled": false, + "email_subscribe": "M8rtOywDKgN((jhUBk9", + "external_id": "M8rtOywDKgN((jhUBk9", + "facebook": Object { + "id": "M8rtOywDKgN((jhUBk9", + "likes": Array [ + "M8rtOywDKgN((jhUBk9", + ], + "num_friends": 7432249213452288, + }, + "first_name": "M8rtOywDKgN((jhUBk9", + "gender": "M8rtOywDKgN((jhUBk9", + "home_city": "M8rtOywDKgN((jhUBk9", + "image_url": "http://agekekci.va/dofupo", + "language": "M8rtOywDKgN((jhUBk9", + "last_name": "M8rtOywDKgN((jhUBk9", + "marked_email_as_spam_at": "2021-02-01T00:00:00.000Z", + "phone": "M8rtOywDKgN((jhUBk9", + "push_subscribe": "M8rtOywDKgN((jhUBk9", + "push_tokens": Array [ + Object { + "app_id": "M8rtOywDKgN((jhUBk9", + "device_id": "M8rtOywDKgN((jhUBk9", + "token": "M8rtOywDKgN((jhUBk9", + }, + ], + "testType": "M8rtOywDKgN((jhUBk9", + "time_zone": "M8rtOywDKgN((jhUBk9", + "twitter": Object { + "followers_count": 7432249213452288, + "friends_count": 7432249213452288, + "id": "M8rtOywDKgN((jhUBk9", + "screen_name": "M8rtOywDKgN((jhUBk9", + "statuses_count": 7432249213452288, + }, + "user_alias": Object { + "alias_label": "M8rtOywDKgN((jhUBk9", + "alias_name": "M8rtOywDKgN((jhUBk9", + }, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's updateUserProfile destination action: it should work with a single batched events 1`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer my-api-key", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; + +exports[`Testing snapshot for Braze's updateUserProfile destination action: it should work with a single batched events 2`] = ` +Object { + "attributes": Array [ + Object { + "_update_existing_only": true, + "braze_id": undefined, + "country": "United States", + "current_location": Object { + "latitude": 40.2964197, + "longitude": -76.9411617, + }, + "date_of_first_session": undefined, + "date_of_last_session": undefined, + "dob": undefined, + "email": undefined, + "email_click_tracking_disabled": undefined, + "email_open_tracking_disabled": undefined, + "email_subscribe": undefined, + "external_id": "user1234", + "facebook": undefined, + "first_name": undefined, + "gender": undefined, + "home_city": undefined, + "image_url": undefined, + "language": undefined, + "last_name": undefined, + "marked_email_as_spam_at": undefined, + "phone": undefined, + "push_subscribe": undefined, + "push_tokens": undefined, + "time_zone": undefined, + "twitter": undefined, + "user_alias": undefined, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's updateUserProfile destination action: it should work with batched events 1`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer my-api-key", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + "x-braze-batch": Array [ + "true", + ], + }, +} +`; + +exports[`Testing snapshot for Braze's updateUserProfile destination action: it should work with batched events 2`] = ` +Object { + "attributes": Array [ + Object { + "_update_existing_only": true, + "braze_id": undefined, + "country": "United States", + "current_location": Object { + "latitude": 40.2964197, + "longitude": -76.9411617, + }, + "date_of_first_session": "2000-01-05T12:00:00.000Z", + "date_of_last_session": "2000-01-05T12:00:00.000Z", + "dob": undefined, + "email": undefined, + "email_click_tracking_disabled": undefined, + "email_open_tracking_disabled": undefined, + "email_subscribe": undefined, + "external_id": "user1234", + "facebook": undefined, + "first_name": undefined, + "gender": undefined, + "home_city": undefined, + "image_url": undefined, + "language": undefined, + "last_name": undefined, + "marked_email_as_spam_at": "2000-01-05T12:00:00.000Z", + "phone": undefined, + "push_subscribe": undefined, + "push_tokens": undefined, + "time_zone": undefined, + "twitter": undefined, + "user_alias": undefined, + }, + Object { + "_update_existing_only": true, + "braze_id": undefined, + "country": "United States", + "current_location": Object { + "latitude": 40.2964197, + "longitude": -76.9411617, + }, + "date_of_first_session": "2000-01-05T12:00:00.000Z", + "date_of_last_session": "2000-01-05T12:00:00.000Z", + "dob": undefined, + "email": undefined, + "email_click_tracking_disabled": undefined, + "email_open_tracking_disabled": undefined, + "email_subscribe": undefined, + "external_id": "user1234", + "facebook": undefined, + "first_name": undefined, + "gender": undefined, + "home_city": undefined, + "image_url": undefined, + "language": undefined, + "last_name": undefined, + "marked_email_as_spam_at": "2000-01-05T12:00:00.000Z", + "phone": undefined, + "push_subscribe": undefined, + "push_tokens": undefined, + "time_zone": undefined, + "twitter": undefined, + "user_alias": undefined, + }, + ], +} +`; + +exports[`Testing snapshot for Braze's updateUserProfile destination action: required fields 1`] = ` +Object { + "attributes": Array [ + Object { + "braze_id": "M8rtOywDKgN((jhUBk9", + "external_id": "M8rtOywDKgN((jhUBk9", + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/braze/updateUserProfile/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/braze/updateUserProfile/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..52500d2c99 --- /dev/null +++ b/packages/destination-actions/src/destinations/braze/updateUserProfile/__tests__/snapshot.test.ts @@ -0,0 +1,189 @@ +import { SegmentEvent, createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'updateUserProfile' +const destinationSlug = 'Braze' +const seedName = `${destinationSlug}#${actionSlug}` +const receivedAt = '2021-08-03T17:40:04.055Z' +const settings = { + app_id: 'my-app-id', + api_key: 'my-api-key', + endpoint: 'https://rest.iad-01.braze.com' as const +} + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData, + receivedAt + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + + it('it should work with batched events', async () => { + nock('https://rest.iad-01.braze.com').post('/users/track').reply(200, {}) + + // ISO String can be obtained from new Date().toISOString() + const isoString = '2000-01-05T12:00:00.00Z' + + const events: SegmentEvent[] = [ + createTestEvent({ + event: 'Test Event 1', + type: 'identify', + receivedAt, + properties: {} + }), + createTestEvent({ + event: 'Test Event 2', + type: 'identify', + receivedAt, + properties: {} + }) + ] + + const responses = await testDestination.testBatchAction(actionSlug, { + events, + useDefaultMappings: true, + mapping: { + external_id: { + '@path': '$.userId' + }, + user_alias: {}, + braze_id: { + '@path': '$.properties.braze_id' + }, + name: { + '@path': '$.event' + }, + time: { + '@path': '$.receivedAt' + }, + properties: { + '@path': '$.properties' + }, + products: { + '@path': '$.properties.products' + }, + date_of_first_session: isoString, + date_of_last_session: isoString, + marked_email_as_spam_at: isoString, + enable_batching: true, + _update_existing_only: true + }, + settings: { + ...settings + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.headers).toMatchSnapshot() + expect(responses[0].options.json).toMatchSnapshot() + }) + + it('it should work with a single batched events', async () => { + nock('https://rest.iad-01.braze.com').post('/users/track').reply(200, {}) + + const events: SegmentEvent[] = [ + createTestEvent({ + event: 'Test Event 1', + type: 'identify', + receivedAt, + properties: {} + }) + ] + + const responses = await testDestination.testBatchAction(actionSlug, { + events, + useDefaultMappings: true, + mapping: { + external_id: { + '@path': '$.userId' + }, + user_alias: {}, + braze_id: { + '@path': '$.properties.braze_id' + }, + name: { + '@path': '$.event' + }, + time: { + '@path': '$.receivedAt' + }, + properties: { + '@path': '$.properties' + }, + enable_batching: true, + _update_existing_only: true + }, + settings: { + ...settings + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.headers).toMatchSnapshot() + expect(responses[0].options.json).toMatchSnapshot() + }) +}) diff --git a/packages/destination-actions/src/destinations/braze/updateUserProfile/generated-types.ts b/packages/destination-actions/src/destinations/braze/updateUserProfile/generated-types.ts index ab3a33f17c..5f79be61c5 100644 --- a/packages/destination-actions/src/destinations/braze/updateUserProfile/generated-types.ts +++ b/packages/destination-actions/src/destinations/braze/updateUserProfile/generated-types.ts @@ -140,4 +140,8 @@ export interface Payload { * Setting this flag to true will put the API in "Update Only" mode. When using a "user_alias", "Update Only" mode is always true. */ _update_existing_only?: boolean + /** + * If true, Segment will batch events before sending to Braze’s user track endpoint. Braze accepts batches of up to 75 events. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/braze/updateUserProfile/index.ts b/packages/destination-actions/src/destinations/braze/updateUserProfile/index.ts index 6fa7772eb8..c6bca080c7 100644 --- a/packages/destination-actions/src/destinations/braze/updateUserProfile/index.ts +++ b/packages/destination-actions/src/destinations/braze/updateUserProfile/index.ts @@ -1,61 +1,7 @@ -import { omit, removeUndefined, IntegrationError } from '@segment/actions-core' import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import dayjs from '../../../lib/dayjs' -import { getUserAlias } from '../userAlias' - -type DateInput = string | Date | number | null | undefined -type DateOutput = string | undefined | null - -function toISO8601(date: DateInput): DateOutput { - if (date === null || date === undefined) { - return date - } - - const d = dayjs(date) - return d.isValid() ? d.toISOString() : undefined -} - -function toDateFormat(date: DateInput, format: string): DateOutput { - if (date === null || date === undefined) { - return date - } - - const d = dayjs(date) - return d.isValid() ? d.format(format) : undefined -} - -function removeEmpty(obj: unknown) { - if (!obj) { - return obj - } - - const cleaned = removeUndefined(obj) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (typeof cleaned === 'object' && Object.keys(cleaned!).length > 0) { - return cleaned - } - - return undefined -} - -function toBrazeGender(gender: string | null | undefined): string | null | undefined { - if (!gender) { - return gender - } - - const genders: Record = { - M: ['man', 'male', 'm'], - F: ['woman', 'female', 'w', 'f'], - O: ['other', 'o'], - N: ['not applicable', 'n'], - P: ['prefer not to say', 'p'] - } - - const brazeGender = Object.keys(genders).find((key) => genders[key].includes(gender.toLowerCase())) - return brazeGender || gender -} +import { updateUserProfile, updateBatchedUserProfile } from '../utils' const action: ActionDefinition = { title: 'Update User Profile', @@ -154,12 +100,7 @@ const action: ActionDefinition = { email_subscribe: { label: 'Email Subscribe', description: `The user's email subscription preference: “opted_in” (explicitly registered to receive email messages), “unsubscribed” (explicitly opted out of email messages), and “subscribed” (neither opted in nor out).`, - type: 'string', - choices: [ - { label: 'OTPED_IN', value: 'opted_in' }, - { label: 'SUBSCRIBED', value: 'subscribed' }, - { label: 'UNSUBSCRIBED', value: 'unsubscribed' } - ] + type: 'string' }, email_open_tracking_disabled: { label: 'Email Open Tracking Disabled', @@ -339,72 +280,21 @@ const action: ActionDefinition = { 'Setting this flag to true will put the API in "Update Only" mode. When using a "user_alias", "Update Only" mode is always true.', type: 'boolean', default: false + }, + enable_batching: { + type: 'boolean', + label: 'Batch Data to Braze', + description: + 'If true, Segment will batch events before sending to Braze’s user track endpoint. Braze accepts batches of up to 75 events.', + default: true } }, perform: (request, { settings, payload }) => { - const { braze_id, external_id } = payload - - // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. - const user_alias = getUserAlias(payload.user_alias) - - if (!braze_id && !user_alias && !external_id) { - throw new IntegrationError( - 'One of "external_id" or "user_alias" or "braze_id" is required.', - 'Missing required fields', - 400 - ) - } - - // Since we are merge reserved keys on top of custom_attributes we need to remove them - // to respect the customers mappings that might resolve `undefined`, without this we'd - // potentially send a value from `custom_attributes` that conflicts with their mappings. - const reservedKeys = Object.keys(action.fields) - // push additional default keys so they are not added as custom attributes - reservedKeys.push('firstName', 'lastName', 'avatar') - const customAttrs = omit(payload.custom_attributes, reservedKeys) - - return request(`${settings.endpoint}/users/track`, { - method: 'post', - json: { - attributes: [ - { - ...customAttrs, - braze_id, - external_id, - user_alias, - // TODO format country code according to ISO-3166-1 alpha-2 standard? - // https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 - country: payload.country, - current_location: removeEmpty(payload.current_location), - date_of_first_session: toISO8601(payload.date_of_first_session), - date_of_last_session: toISO8601(payload.date_of_last_session), - dob: toDateFormat(payload.dob, 'YYYY-MM-DD'), - email: payload.email, - email_subscribe: payload.email_subscribe, - email_open_tracking_disabled: payload.email_open_tracking_disabled, - email_click_tracking_disabled: payload.email_click_tracking_disabled, - facebook: payload.facebook, - first_name: payload.first_name, - gender: toBrazeGender(payload.gender), - home_city: payload.home_city, - image_url: payload.image_url, - // TODO format as ISO-639-1 standard ? - // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes - // https://www.braze.com/docs/user_guide/data_and_analytics/user_data_collection/language_codes/ - language: payload.language, - last_name: payload.last_name, - marked_email_as_spam_at: toISO8601(payload.marked_email_as_spam_at), - phone: payload.phone, - push_subscribe: payload.push_subscribe, - push_tokens: payload.push_tokens, - time_zone: payload.time_zone, - twitter: payload.twitter, - _update_existing_only: payload._update_existing_only - } - ] - } - }) + return updateUserProfile(request, settings, payload) + }, + performBatch: (request, { settings, payload }) => { + return updateBatchedUserProfile(request, settings, payload) } } diff --git a/packages/destination-actions/src/destinations/braze/utils.ts b/packages/destination-actions/src/destinations/braze/utils.ts index c96281f57c..d6b4ad8fcb 100644 --- a/packages/destination-actions/src/destinations/braze/utils.ts +++ b/packages/destination-actions/src/destinations/braze/utils.ts @@ -1,10 +1,11 @@ import { omit } from '@segment/actions-core' -import { IntegrationError, RequestClient } from '@segment/actions-core' +import { IntegrationError, RequestClient, removeUndefined } from '@segment/actions-core' import dayjs from 'dayjs' import { Settings } from './generated-types' import action from './trackPurchase' import { Payload as TrackEventPayload } from './trackEvent/generated-types' import { Payload as TrackPurchasePayload } from './trackPurchase/generated-types' +import { Payload as UpdateUserProfilePayload } from './updateUserProfile/generated-types' import { getUserAlias } from './userAlias' type DateInput = string | Date | number | null | undefined type DateOutput = string | undefined | null @@ -18,11 +19,51 @@ function toISO8601(date: DateInput): DateOutput { return d.isValid() ? d.toISOString() : undefined } +function toDateFormat(date: DateInput, format: string): DateOutput { + if (date === null || date === undefined) { + return date + } + + const d = dayjs(date) + return d.isValid() ? d.format(format) : undefined +} + +function removeEmpty(obj: unknown) { + if (!obj) { + return obj + } + + const cleaned = removeUndefined(obj) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (typeof cleaned === 'object' && Object.keys(cleaned!).length > 0) { + return cleaned + } + + return undefined +} + +function toBrazeGender(gender: string | null | undefined): string | null | undefined { + if (!gender) { + return gender + } + + const genders: Record = { + M: ['man', 'male', 'm'], + F: ['woman', 'female', 'w', 'f'], + O: ['other', 'o'], + N: ['not applicable', 'n'], + P: ['prefer not to say', 'p'] + } + + const brazeGender = Object.keys(genders).find((key) => genders[key].includes(gender.toLowerCase())) + return brazeGender || gender +} + export function sendTrackEvent(request: RequestClient, settings: Settings, payload: TrackEventPayload) { - const { braze_id, external_id } = payload + const { braze_id, external_id, email } = payload const user_alias = getUserAlias(payload.user_alias) - if (!braze_id && !user_alias && !external_id) { + if (!braze_id && !user_alias && !external_id && !email) { throw new IntegrationError( 'One of "external_id" or "user_alias" or "braze_id" is required.', 'Missing required fields', @@ -37,6 +78,7 @@ export function sendTrackEvent(request: RequestClient, settings: Settings, paylo { braze_id, external_id, + email, user_alias, app_id: settings.app_id, name: payload.name, @@ -51,7 +93,7 @@ export function sendTrackEvent(request: RequestClient, settings: Settings, paylo export function sendBatchedTrackEvent(request: RequestClient, settings: Settings, payloads: TrackEventPayload[]) { const payload = payloads.map((payload) => { - const { braze_id, external_id } = payload + const { braze_id, external_id, email } = payload // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. const user_alias = getUserAlias(payload.user_alias) @@ -67,6 +109,7 @@ export function sendBatchedTrackEvent(request: RequestClient, settings: Settings return { braze_id, external_id, + email, user_alias, app_id: settings.app_id, name: payload.name, @@ -86,11 +129,11 @@ export function sendBatchedTrackEvent(request: RequestClient, settings: Settings } export function sendTrackPurchase(request: RequestClient, settings: Settings, payload: TrackPurchasePayload) { - const { braze_id, external_id } = payload + const { braze_id, external_id, email } = payload // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. const user_alias = getUserAlias(payload.user_alias) - if (!braze_id && !user_alias && !external_id) { + if (!braze_id && !user_alias && !external_id && !email) { throw new IntegrationError( 'One of "external_id" or "user_alias" or "braze_id" is required.', 'Missing required fields', @@ -108,6 +151,7 @@ export function sendTrackPurchase(request: RequestClient, settings: Settings, pa const base = { braze_id, external_id, + email, user_alias, app_id: settings.app_id, time: toISO8601(payload.time), @@ -134,3 +178,203 @@ export function sendTrackPurchase(request: RequestClient, settings: Settings, pa } }) } + +export function sendBatchedTrackPurchase(request: RequestClient, settings: Settings, payloads: TrackPurchasePayload[]) { + let payload = payloads + .map((payload) => { + const { braze_id, external_id, email } = payload + // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. + const user_alias = getUserAlias(payload.user_alias) + + // Disable errors until Actions Framework has a multistatus support + // if (!braze_id && !user_alias && !external_id) { + // throw new IntegrationError( + // 'One of "external_id" or "user_alias" or "braze_id" is required.', + // 'Missing required fields', + // 400 + // ) + // } + + // Skip when there are no products to send to Braze + if (payload.products.length === 0) { + return + } + + const base = { + braze_id, + external_id, + user_alias, + email, + app_id: settings.app_id, + time: toISO8601(payload.time), + _update_existing_only: payload._update_existing_only + } + + const reservedKeys = Object.keys(action.fields.products.properties ?? {}) + const event_properties = omit(payload.properties, ['products']) + + return payload.products.map(function (product) { + return { + ...base, + product_id: product.product_id, + currency: product.currency ?? 'USD', + price: product.price, + quantity: product.quantity, + properties: { + ...omit(product, reservedKeys), + ...event_properties + } + } + }) + }) + .filter((notFalsy) => notFalsy) + + // flatten arrays + payload = ([] as any[]).concat(...payload) + + return request(`${settings.endpoint}/users/track`, { + method: 'post', + ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + json: { + purchases: payload + } + }) +} + +export function updateUserProfile(request: RequestClient, settings: Settings, payload: UpdateUserProfilePayload) { + const { braze_id, external_id, email } = payload + + // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. + const user_alias = getUserAlias(payload.user_alias) + + if (!braze_id && !user_alias && !external_id && !email) { + throw new IntegrationError( + 'One of "external_id" or "user_alias" or "braze_id" or "email" is required.', + 'Missing required fields', + 400 + ) + } + + // Since we are merge reserved keys on top of custom_attributes we need to remove them + // to respect the customers mappings that might resolve `undefined`, without this we'd + // potentially send a value from `custom_attributes` that conflicts with their mappings. + const reservedKeys = Object.keys(action.fields) + // push additional default keys so they are not added as custom attributes + reservedKeys.push('firstName', 'lastName', 'avatar') + const customAttrs = omit(payload.custom_attributes, reservedKeys) + + return request(`${settings.endpoint}/users/track`, { + method: 'post', + json: { + attributes: [ + { + ...customAttrs, + braze_id, + external_id, + user_alias, + // TODO format country code according to ISO-3166-1 alpha-2 standard? + // https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 + country: payload.country, + current_location: removeEmpty(payload.current_location), + date_of_first_session: toISO8601(payload.date_of_first_session), + date_of_last_session: toISO8601(payload.date_of_last_session), + dob: toDateFormat(payload.dob, 'YYYY-MM-DD'), + email: payload.email, + email_subscribe: payload.email_subscribe, + email_open_tracking_disabled: payload.email_open_tracking_disabled, + email_click_tracking_disabled: payload.email_click_tracking_disabled, + facebook: payload.facebook, + first_name: payload.first_name, + gender: toBrazeGender(payload.gender), + home_city: payload.home_city, + image_url: payload.image_url, + // TODO format as ISO-639-1 standard ? + // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + // https://www.braze.com/docs/user_guide/data_and_analytics/user_data_collection/language_codes/ + language: payload.language, + last_name: payload.last_name, + marked_email_as_spam_at: toISO8601(payload.marked_email_as_spam_at), + phone: payload.phone, + push_subscribe: payload.push_subscribe, + push_tokens: payload.push_tokens, + time_zone: payload.time_zone, + twitter: payload.twitter, + _update_existing_only: payload._update_existing_only + } + ] + } + }) +} + +export function updateBatchedUserProfile( + request: RequestClient, + settings: Settings, + payloads: UpdateUserProfilePayload[] +) { + const payload = payloads.map((payload) => { + const { braze_id, external_id, email } = payload + + // Extract valid user_alias shape. Since it is optional (oneOf braze_id, external_id) we need to only include it if fully formed. + const user_alias = getUserAlias(payload.user_alias) + + // Disable errors until Actions Framework has a multistatus support + // if (!braze_id && !user_alias && !external_id) { + // throw new IntegrationError( + // 'One of "external_id" or "user_alias" or "braze_id" is required.', + // 'Missing required fields', + // 400 + // ) + // } + + // Since we are merge reserved keys on top of custom_attributes we need to remove them + // to respect the customers mappings that might resolve `undefined`, without this we'd + // potentially send a value from `custom_attributes` that conflicts with their mappings. + const reservedKeys = Object.keys(action.fields) + // push additional default keys so they are not added as custom attributes + reservedKeys.push('firstName', 'lastName', 'avatar') + const customAttrs = omit(payload.custom_attributes, reservedKeys) + + return { + ...customAttrs, + braze_id, + external_id, + user_alias, + // TODO format country code according to ISO-3166-1 alpha-2 standard? + // https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 + country: payload.country, + current_location: removeEmpty(payload.current_location), + date_of_first_session: toISO8601(payload.date_of_first_session), + date_of_last_session: toISO8601(payload.date_of_last_session), + dob: toDateFormat(payload.dob, 'YYYY-MM-DD'), + email, + email_subscribe: payload.email_subscribe, + email_open_tracking_disabled: payload.email_open_tracking_disabled, + email_click_tracking_disabled: payload.email_click_tracking_disabled, + facebook: payload.facebook, + first_name: payload.first_name, + gender: toBrazeGender(payload.gender), + home_city: payload.home_city, + image_url: payload.image_url, + // TODO format as ISO-639-1 standard ? + // https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + // https://www.braze.com/docs/user_guide/data_and_analytics/user_data_collection/language_codes/ + language: payload.language, + last_name: payload.last_name, + marked_email_as_spam_at: toISO8601(payload.marked_email_as_spam_at), + phone: payload.phone, + push_subscribe: payload.push_subscribe, + push_tokens: payload.push_tokens, + time_zone: payload.time_zone, + twitter: payload.twitter, + _update_existing_only: payload._update_existing_only + } + }) + + return request(`${settings.endpoint}/users/track`, { + method: 'post', + ...(payload.length > 1 ? { headers: { 'X-Braze-Batch': 'true' } } : undefined), + json: { + attributes: payload + } + }) +} diff --git a/packages/destination-actions/src/destinations/canvas/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/canvas/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..d110be922f --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-canvas destination: sendGroupEvent action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "^FXfxg", + "context": Object { + "testType": "^FXfxg", + }, + "enable_batching": true, + "group_id": "^FXfxg", + "message_id": "^FXfxg", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "^FXfxg", + "traits": Object { + "testType": "^FXfxg", + }, + "user_id": "^FXfxg", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendGroupEvent action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "^FXfxg", + "enable_batching": true, + "group_id": "^FXfxg", + "message_id": "^FXfxg", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "^FXfxg", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendIdentifyEvent action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "]HP4@pqD", + "context": Object { + "testType": "]HP4@pqD", + }, + "enable_batching": true, + "message_id": "]HP4@pqD", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "]HP4@pqD", + "traits": Object { + "testType": "]HP4@pqD", + }, + "user_id": "]HP4@pqD", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendIdentifyEvent action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "]HP4@pqD", + "enable_batching": true, + "message_id": "]HP4@pqD", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "]HP4@pqD", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendPageEvent action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "QwNSz0x", + "context": Object { + "testType": "QwNSz0x", + }, + "enable_batching": true, + "message_id": "QwNSz0x", + "name": "QwNSz0x", + "properties": Object { + "testType": "QwNSz0x", + }, + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "QwNSz0x", + "user_id": "QwNSz0x", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendPageEvent action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "QwNSz0x", + "enable_batching": true, + "message_id": "QwNSz0x", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "QwNSz0x", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendScreenEvent action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "cR7bY%SZFAWJihA*", + "context": Object { + "testType": "cR7bY%SZFAWJihA*", + }, + "enable_batching": false, + "message_id": "cR7bY%SZFAWJihA*", + "name": "cR7bY%SZFAWJihA*", + "properties": Object { + "testType": "cR7bY%SZFAWJihA*", + }, + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "cR7bY%SZFAWJihA*", + "user_id": "cR7bY%SZFAWJihA*", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendScreenEvent action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "cR7bY%SZFAWJihA*", + "enable_batching": false, + "message_id": "cR7bY%SZFAWJihA*", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "cR7bY%SZFAWJihA*", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendTrackEvent action - all fields 1`] = ` +Array [ + Object { + "anonymous_id": "y*U)R@s[@", + "context": Object { + "testType": "y*U)R@s[@", + }, + "enable_batching": true, + "event": "y*U)R@s[@", + "message_id": "y*U)R@s[@", + "properties": Object { + "testType": "y*U)R@s[@", + }, + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "y*U)R@s[@", + "user_id": "y*U)R@s[@", + }, +] +`; + +exports[`Testing snapshot for actions-canvas destination: sendTrackEvent action - required fields 1`] = ` +Array [ + Object { + "anonymous_id": "y*U)R@s[@", + "enable_batching": true, + "event": "y*U)R@s[@", + "message_id": "y*U)R@s[@", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "y*U)R@s[@", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/canvas/__tests__/index.test.ts b/packages/destination-actions/src/destinations/canvas/__tests__/index.test.ts new file mode 100644 index 0000000000..ea7a596a92 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/__tests__/index.test.ts @@ -0,0 +1,27 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { getAuthUrl } from '../api' +import { Settings } from '../generated-types' + +const testDestination = createTestIntegration(Definition) + +describe('Canvas', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + const settings: Settings = { + apiToken: 'myApiToken' + } + nock(getAuthUrl()).post('').matchHeader('X-Auth-Token', settings.apiToken).reply(200, {}) + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() + }) + + it('should reject invalid API key', async () => { + const settings: Settings = { + apiToken: 'myApiToken' + } + nock(getAuthUrl()).post('').matchHeader('X-Auth-Token', settings.apiToken).reply(200, {}) + await expect(testDestination.testAuthentication({ apiToken: 'invalidApiToken' })).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/canvas/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..b3c847e68b --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-canvas' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/canvas/api.ts b/packages/destination-actions/src/destinations/canvas/api.ts new file mode 100644 index 0000000000..c8b35796a5 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/api.ts @@ -0,0 +1,27 @@ +import { RequestFn } from '@segment/actions-core' +import { Settings } from './generated-types' + +export type EventType = 'track' | 'identify' | 'group' | 'page' | 'screen' + +export const getAuthUrl = (): string => `https://events.canvasapp.com/v1/auth` +export const getEventUrl = (eventType: EventType): string => `https://events.canvasapp.com/v1/event/${eventType}` + +export function perform(eventType: EventType): RequestFn { + return (request, data) => { + return request(getEventUrl(eventType), { + method: 'post', + json: [data.payload], + headers: { 'X-Auth-Token': data.settings.apiToken } + }) + } +} + +export function performBatch(eventType: EventType): RequestFn { + return (request, data) => { + return request(getEventUrl(eventType), { + method: 'post', + json: data.payload, + headers: { 'X-Auth-Token': data.settings.apiToken } + }) + } +} diff --git a/packages/destination-actions/src/destinations/canvas/common-fields.ts b/packages/destination-actions/src/destinations/canvas/common-fields.ts new file mode 100644 index 0000000000..5e3456939a --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/common-fields.ts @@ -0,0 +1,68 @@ +import { ActionDefinition } from '@segment/actions-core' +import { Settings } from './generated-types' + +export const commonFields: ActionDefinition['fields'] = { + enable_batching: { + required: true, + type: 'boolean', + label: 'Send data in batch to Canvas', + description: 'Sends events in bulk to Canvas. Highly recommended.', + default: true + }, + context: { + label: 'Event context', + description: 'Event context as it appears in Segment', + type: 'object', + required: false, + default: { '@path': '$.context' } + }, + anonymous_id: { + label: 'Anonymous ID', + description: 'The anonymous ID associated with the user', + type: 'string', + required: false, + default: { '@path': '$.anonymousId' } + }, + message_id: { + label: 'Message ID', + description: 'The Segment messageId', + type: 'string', + required: false, + default: { '@path': '$.messageId' } + }, + timestamp: { + label: 'Timestamp', + description: 'A timestamp of when the event took place. Default is current date and time.', + type: 'string', + default: { + '@path': '$.timestamp' + } + }, + received_at: { + label: 'Received at', + description: 'When the event was received.', + type: 'datetime', + required: true, + default: { + '@path': '$.receivedAt' + } + }, + sent_at: { + label: 'Sent at', + description: 'Device-time when the event was sent.', + type: 'datetime', + required: true, + default: { + '@path': '$.sentAt' + } + }, + user_id: { + type: 'string', + required: false, + description: "The user's id", + label: 'User ID', + default: { + '@path': '$.userId' + } + } +} diff --git a/packages/destination-actions/src/destinations/canvas/generated-types.ts b/packages/destination-actions/src/destinations/canvas/generated-types.ts new file mode 100644 index 0000000000..4f9779d825 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * API token generated by Canvas + */ + apiToken: string +} diff --git a/packages/destination-actions/src/destinations/canvas/index.ts b/packages/destination-actions/src/destinations/canvas/index.ts new file mode 100644 index 0000000000..98ef905263 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/index.ts @@ -0,0 +1,87 @@ +import { defaultValues, DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import sendTrackEvent from './sendTrackEvent' +import sendIdentifyEvent from './sendIdentifyEvent' +import sendGroupEvent from './sendGroupEvent' +import sendPageEvent from './sendPageEvent' +import sendScreenEvent from './sendScreenEvent' +import { getAuthUrl } from './api' + +const destination: DestinationDefinition = { + name: 'Canvas', + slug: 'actions-canvas', + mode: 'cloud', + description: 'Send your Segment data to Canvas', + + authentication: { + scheme: 'custom', + fields: { + apiToken: { + label: 'API Token', + description: 'API token generated by Canvas', + type: 'password', + required: true + } + }, + testAuthentication: (request) => { + return request(getAuthUrl(), { + method: 'post' + }) + } + }, + extendRequest({ settings }) { + return { + headers: { + 'X-Auth-Token': settings.apiToken + } + } + }, + + presets: [ + { + name: sendTrackEvent.title, + subscribe: 'type = "track"', + partnerAction: 'sendTrackEvent', + mapping: defaultValues(sendTrackEvent.fields), + type: 'automatic' + }, + { + name: sendIdentifyEvent.title, + subscribe: 'type = "identify"', + partnerAction: 'sendIdentifyEvent', + mapping: defaultValues(sendIdentifyEvent.fields), + type: 'automatic' + }, + { + name: sendGroupEvent.title, + subscribe: 'type = "group"', + partnerAction: 'sendGroupEvent', + mapping: defaultValues(sendGroupEvent.fields), + type: 'automatic' + }, + { + name: sendPageEvent.title, + subscribe: 'type = "page"', + partnerAction: 'sendPageEvent', + mapping: defaultValues(sendPageEvent.fields), + type: 'automatic' + }, + { + name: sendScreenEvent.title, + subscribe: 'type = "screen"', + partnerAction: 'sendScreenEvent', + mapping: defaultValues(sendScreenEvent.fields), + type: 'automatic' + } + ], + + actions: { + sendTrackEvent, + sendIdentifyEvent, + sendGroupEvent, + sendPageEvent, + sendScreenEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..55abd2a31d --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Canvas's sendGroupEvent destination action: all fields 1`] = ` +Array [ + Object { + "anonymous_id": "IcB6s5^k8", + "context": Object { + "testType": "IcB6s5^k8", + }, + "enable_batching": true, + "group_id": "IcB6s5^k8", + "message_id": "IcB6s5^k8", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "IcB6s5^k8", + "traits": Object { + "testType": "IcB6s5^k8", + }, + "user_id": "IcB6s5^k8", + }, +] +`; + +exports[`Testing snapshot for Canvas's sendGroupEvent destination action: required fields 1`] = ` +Array [ + Object { + "anonymous_id": "IcB6s5^k8", + "enable_batching": true, + "group_id": "IcB6s5^k8", + "message_id": "IcB6s5^k8", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "IcB6s5^k8", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..30d11e162d --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/index.test.ts @@ -0,0 +1,102 @@ +import { createTestEvent } from '@segment/actions-core' +import { testAction, testBatchAction, userAgent } from '../../testing' + +const actionName = 'sendGroupEvent' + +describe('Canvas', () => { + describe(actionName, () => { + it('should submit event on Group event', async () => { + const event = createTestEvent({ + type: 'group', + groupId: 'magic-man', + traits: { + name: 'Magic team', + email: 'team@magic.com' + }, + context: { + userAgent, + page: { + url: 'https://magic.com', + referrer: 'https://magic.com/other' + } + } + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + group_id: event.groupId, + timestamp: event.timestamp, + traits: { + name: 'Magic team', + email: 'team@magic.com' + }, + context: { + userAgent, + page: { + url: 'https://magic.com', + referrer: 'https://magic.com/other' + } + } + } + ]) + }) + + it('should submit event on Identify event with all optional fields omitted', async () => { + const event = createTestEvent({ + type: 'group', + groupId: 'magic-group' + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + timestamp: event.timestamp, + group_id: event.groupId + } + ]) + }) + + it('should submit event batch', async () => { + const events = [ + createTestEvent({ + type: 'group', + groupId: 'cool-group', + traits: { + name: 'Cool group' + } + }), + createTestEvent({ + type: 'group', + groupId: 'uncool-group', + traits: { + name: 'Uncool group' + } + }) + ] + const request = await testBatchAction(actionName, events) + expect(request).toMatchObject([ + { + group_id: 'cool-group', + traits: { + name: 'Cool group' + }, + user_id: events[0].userId, + anonymous_id: events[0].anonymousId, + timestamp: events[0].timestamp + }, + { + group_id: 'uncool-group', + traits: { + name: 'Uncool group' + }, + user_id: events[1].userId, + anonymous_id: events[1].anonymousId, + timestamp: events[1].timestamp + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..a19d5813ae --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendGroupEvent' +const destinationSlug = 'Canvas' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendGroupEvent/generated-types.ts b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/generated-types.ts new file mode 100644 index 0000000000..289d658dc3 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/generated-types.ts @@ -0,0 +1,48 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The unique identifier of the group. + */ + group_id: string + /** + * The properties of the group. + */ + traits?: { + [k: string]: unknown + } + /** + * Sends events in bulk to Canvas. Highly recommended. + */ + enable_batching: boolean + /** + * Event context as it appears in Segment + */ + context?: { + [k: string]: unknown + } + /** + * The anonymous ID associated with the user + */ + anonymous_id?: string + /** + * The Segment messageId + */ + message_id?: string + /** + * A timestamp of when the event took place. Default is current date and time. + */ + timestamp?: string + /** + * When the event was received. + */ + received_at: string | number + /** + * Device-time when the event was sent. + */ + sent_at: string | number + /** + * The user's id + */ + user_id?: string +} diff --git a/packages/destination-actions/src/destinations/canvas/sendGroupEvent/index.ts b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/index.ts new file mode 100644 index 0000000000..9219dff4b5 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendGroupEvent/index.ts @@ -0,0 +1,35 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common-fields' +import { perform, performBatch } from '../api' + +const action: ActionDefinition = { + title: 'Send Identify Group Event', + description: 'Inserts or updates a group record in Canvas', + defaultSubscription: 'type = "group"', + fields: { + group_id: { + label: 'Group ID', + type: 'string', + description: 'The unique identifier of the group.', + required: true, + default: { + '@path': '$.groupId' + } + }, + traits: { + label: 'Group Properties', + type: 'object', + description: 'The properties of the group.', + default: { + '@path': '$.traits' + } + }, + ...commonFields + }, + perform: perform('group'), + performBatch: performBatch('group') +} + +export default action diff --git a/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..2c9af1f1dc --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Canvas's sendIdentifyEvent destination action: all fields 1`] = ` +Array [ + Object { + "anonymous_id": "R%Pk7RmnaN", + "context": Object { + "testType": "R%Pk7RmnaN", + }, + "enable_batching": true, + "message_id": "R%Pk7RmnaN", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "R%Pk7RmnaN", + "traits": Object { + "testType": "R%Pk7RmnaN", + }, + "user_id": "R%Pk7RmnaN", + }, +] +`; + +exports[`Testing snapshot for Canvas's sendIdentifyEvent destination action: required fields 1`] = ` +Array [ + Object { + "anonymous_id": "R%Pk7RmnaN", + "enable_batching": true, + "message_id": "R%Pk7RmnaN", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "R%Pk7RmnaN", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..947b8af49e --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/index.test.ts @@ -0,0 +1,104 @@ +import { createTestEvent } from '@segment/actions-core' +import { testAction, testBatchAction, userAgent } from '../../testing' + +const actionName = 'sendIdentifyEvent' + +describe('Canvas', () => { + describe(actionName, () => { + it('should submit event on Identify event', async () => { + const event = createTestEvent({ + type: 'identify', + traits: { + name: 'Peter Gibbons', + email: 'peter@example.com', + plan: 'premium', + logins: 5 + }, + context: { + userAgent, + page: { + url: 'https://example.com', + referrer: 'https://example.com/other' + } + } + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + timestamp: event.timestamp, + context: { + userAgent, + page: { + url: 'https://example.com', + referrer: 'https://example.com/other' + } + }, + traits: { + name: 'Peter Gibbons', + email: 'peter@example.com', + plan: 'premium', + logins: 5 + } + } + ]) + }) + + it('should submit event on Identify event with all optional fields omitted', async () => { + const event = createTestEvent({ + type: 'identify', + traits: { + email: 'peter@example.com' + } + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + timestamp: event.timestamp, + traits: { + email: 'peter@example.com' + } + } + ]) + }) + + it('should submit event batch', async () => { + const events = [ + createTestEvent({ + type: 'identify', + traits: { + email: 'peter@example.com' + } + }), + createTestEvent({ + type: 'identify', + traits: { + email: 'frank@example.com' + } + }) + ] + const request = await testBatchAction(actionName, events) + expect(request).toMatchObject([ + { + user_id: events[0].userId, + anonymous_id: events[0].anonymousId, + traits: { + email: 'peter@example.com' + }, + timestamp: events[0].timestamp + }, + { + user_id: events[1].userId, + anonymous_id: events[1].anonymousId, + traits: { + email: 'frank@example.com' + }, + timestamp: events[1].timestamp + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..a14436229d --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendIdentifyEvent' +const destinationSlug = 'Canvas' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/generated-types.ts b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/generated-types.ts new file mode 100644 index 0000000000..1a6db999b4 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/generated-types.ts @@ -0,0 +1,44 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The properties of the user. + */ + traits?: { + [k: string]: unknown + } + /** + * Sends events in bulk to Canvas. Highly recommended. + */ + enable_batching: boolean + /** + * Event context as it appears in Segment + */ + context?: { + [k: string]: unknown + } + /** + * The anonymous ID associated with the user + */ + anonymous_id?: string + /** + * The Segment messageId + */ + message_id?: string + /** + * A timestamp of when the event took place. Default is current date and time. + */ + timestamp?: string + /** + * When the event was received. + */ + received_at: string | number + /** + * Device-time when the event was sent. + */ + sent_at: string | number + /** + * The user's id + */ + user_id?: string +} diff --git a/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/index.ts b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/index.ts new file mode 100644 index 0000000000..7f95955bc5 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendIdentifyEvent/index.ts @@ -0,0 +1,26 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common-fields' +import { perform, performBatch } from '../api' + +const action: ActionDefinition = { + title: 'Send Identify User Event', + description: 'Inserts or updates an Identify record', + defaultSubscription: 'type = "identify"', + fields: { + traits: { + label: 'User Properties', + type: 'object', + description: 'The properties of the user.', + default: { + '@path': '$.traits' + } + }, + ...commonFields + }, + perform: perform('identify'), + performBatch: performBatch('identify') +} + +export default action diff --git a/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..e26b3d8aa4 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Canvas's sendPageEvent destination action: all fields 1`] = ` +Array [ + Object { + "anonymous_id": "NLd$*@Dav7TW3", + "context": Object { + "testType": "NLd$*@Dav7TW3", + }, + "enable_batching": false, + "message_id": "NLd$*@Dav7TW3", + "name": "NLd$*@Dav7TW3", + "properties": Object { + "testType": "NLd$*@Dav7TW3", + }, + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "NLd$*@Dav7TW3", + "user_id": "NLd$*@Dav7TW3", + }, +] +`; + +exports[`Testing snapshot for Canvas's sendPageEvent destination action: required fields 1`] = ` +Array [ + Object { + "anonymous_id": "NLd$*@Dav7TW3", + "enable_batching": false, + "message_id": "NLd$*@Dav7TW3", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "NLd$*@Dav7TW3", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..98f710f7cb --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/index.test.ts @@ -0,0 +1,129 @@ +import { createTestEvent } from '@segment/actions-core' +import { testAction, testBatchAction, userAgent } from '../../testing' + +const actionName = 'sendPageEvent' + +describe('Canvas', () => { + describe(actionName, () => { + it('should submit event on Page event', async () => { + const event = createTestEvent({ + type: 'page', + properties: { + url: 'https://example.com', + referrer: 'https://example.com/other' + }, + context: { + userAgent + } + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + properties: { + url: 'https://example.com', + referrer: 'https://example.com/other' + }, + context: { + userAgent + }, + timestamp: event.timestamp + } + ]) + }) + + it('should submit event on Page event with all optional fields omitted', async () => { + const event = createTestEvent({ + type: 'page', + properties: { + url: 'https://example.com' + } + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + timestamp: event.timestamp + } + ]) + }) + + it('should not skip an event with userId only', async () => { + const event = createTestEvent({ + type: 'page', + properties: { + url: 'https://example.com' + }, + anonymousId: undefined + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + properties: { + url: 'https://example.com' + }, + timestamp: event.timestamp + } + ]) + }) + + it('should not skip an event with anonymousId only', async () => { + const event = createTestEvent({ + type: 'page', + properties: { + url: 'https://example.com' + }, + userId: undefined + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + anonymous_id: event.anonymousId, + properties: { + url: 'https://example.com' + }, + timestamp: event.timestamp + } + ]) + }) + + it('should submit event batch', async () => { + const events = [ + createTestEvent({ + type: 'page', + context: { + page: { url: 'https://example.com/01' } + } + }), + createTestEvent({ + type: 'page', + context: { + page: { url: 'https://example.com/02' } + } + }) + ] + const request = await testBatchAction(actionName, events) + expect(request).toMatchObject([ + { + user_id: events[0].userId, + anonymous_id: events[0].anonymousId, + timestamp: events[0].timestamp, + context: { + page: { url: 'https://example.com/01' } + } + }, + { + user_id: events[1].userId, + anonymous_id: events[1].anonymousId, + context: { + page: { url: 'https://example.com/02' } + }, + timestamp: events[1].timestamp + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..ad1e5cdfc2 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendPageEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendPageEvent' +const destinationSlug = 'Canvas' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendPageEvent/generated-types.ts b/packages/destination-actions/src/destinations/canvas/sendPageEvent/generated-types.ts new file mode 100644 index 0000000000..a807685e31 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendPageEvent/generated-types.ts @@ -0,0 +1,48 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Page name + */ + name?: string + /** + * Properties to associate with the page view + */ + properties?: { + [k: string]: unknown + } + /** + * Sends events in bulk to Canvas. Highly recommended. + */ + enable_batching: boolean + /** + * Event context as it appears in Segment + */ + context?: { + [k: string]: unknown + } + /** + * The anonymous ID associated with the user + */ + anonymous_id?: string + /** + * The Segment messageId + */ + message_id?: string + /** + * A timestamp of when the event took place. Default is current date and time. + */ + timestamp?: string + /** + * When the event was received. + */ + received_at: string | number + /** + * Device-time when the event was sent. + */ + sent_at: string | number + /** + * The user's id + */ + user_id?: string +} diff --git a/packages/destination-actions/src/destinations/canvas/sendPageEvent/index.ts b/packages/destination-actions/src/destinations/canvas/sendPageEvent/index.ts new file mode 100644 index 0000000000..995446101a --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendPageEvent/index.ts @@ -0,0 +1,32 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common-fields' +import { perform, performBatch } from '../api' + +const action: ActionDefinition = { + title: 'Send Page Event', + description: 'Adds a page event record in Canvas', + defaultSubscription: 'type = "page"', + fields: { + name: { + type: 'string', + label: 'Name', + description: 'Page name', + required: false, + default: { '@path': '$.name' } + }, + properties: { + type: 'object', + label: 'Properties', + description: 'Properties to associate with the page view', + required: false, + default: { '@path': '$.properties' } + }, + ...commonFields + }, + perform: perform('page'), + performBatch: performBatch('page') +} + +export default action diff --git a/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..a90f8d04b2 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Canvas's sendScreenEvent destination action: all fields 1`] = ` +Array [ + Object { + "anonymous_id": "y8xZd9khuTHc7zE", + "context": Object { + "testType": "y8xZd9khuTHc7zE", + }, + "enable_batching": false, + "message_id": "y8xZd9khuTHc7zE", + "name": "y8xZd9khuTHc7zE", + "properties": Object { + "testType": "y8xZd9khuTHc7zE", + }, + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "y8xZd9khuTHc7zE", + "user_id": "y8xZd9khuTHc7zE", + }, +] +`; + +exports[`Testing snapshot for Canvas's sendScreenEvent destination action: required fields 1`] = ` +Array [ + Object { + "anonymous_id": "y8xZd9khuTHc7zE", + "enable_batching": false, + "message_id": "y8xZd9khuTHc7zE", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "y8xZd9khuTHc7zE", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..d69e341f15 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/index.test.ts @@ -0,0 +1,120 @@ +import { createTestEvent } from '@segment/actions-core' +import { testAction, testBatchAction, userAgent } from '../../testing' + +const actionName = 'sendScreenEvent' + +describe('Canvas', () => { + describe(actionName, () => { + it('should submit event on Screen event', async () => { + const event = createTestEvent({ + type: 'screen', + name: 'Home', + properties: { + 'Random prop': 'cool guy' + }, + context: { + userAgent, + page: { + url: 'https://example.com', + referrer: 'https://example.com/other' + } + } + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + name: 'Home', + timestamp: event.timestamp, + properties: { + 'Random prop': 'cool guy' + }, + context: { + userAgent, + page: { + url: 'https://example.com', + referrer: 'https://example.com/other' + } + } + } + ]) + }) + + it('should submit event on Screen event with all optional fields omitted', async () => { + const event = createTestEvent({ + type: 'screen', + name: 'Home' + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + name: 'Home', + timestamp: event.timestamp + } + ]) + }) + + it('should not skip an event with userId only', async () => { + const event = createTestEvent({ + type: 'screen', + name: 'Home', + anonymousId: undefined + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + name: 'Home', + timestamp: event.timestamp + } + ]) + }) + + it('should not skip an event with anonymousId only', async () => { + const event = createTestEvent({ + type: 'screen', + name: 'Home', + userId: undefined + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + anonymous_id: event.anonymousId, + name: 'Home', + timestamp: event.timestamp + } + ]) + }) + + it('should submit event batch', async () => { + const events = [ + createTestEvent({ + type: 'screen', + name: 'Home' + }), + createTestEvent({ + type: 'screen', + name: 'Orders' + }) + ] + const request = await testBatchAction(actionName, events) + expect(request).toMatchObject([ + { + user_id: events[0].userId, + anonymous_id: events[0].anonymousId, + name: 'Home', + timestamp: events[0].timestamp + }, + { + user_id: events[1].userId, + anonymous_id: events[1].anonymousId, + name: 'Orders', + timestamp: events[1].timestamp + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..e5c8b9a3fc --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendScreenEvent' +const destinationSlug = 'Canvas' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendScreenEvent/generated-types.ts b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/generated-types.ts new file mode 100644 index 0000000000..37de59b8f4 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/generated-types.ts @@ -0,0 +1,48 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Screen name + */ + name?: string + /** + * Properties to associate with the screen + */ + properties?: { + [k: string]: unknown + } + /** + * Sends events in bulk to Canvas. Highly recommended. + */ + enable_batching: boolean + /** + * Event context as it appears in Segment + */ + context?: { + [k: string]: unknown + } + /** + * The anonymous ID associated with the user + */ + anonymous_id?: string + /** + * The Segment messageId + */ + message_id?: string + /** + * A timestamp of when the event took place. Default is current date and time. + */ + timestamp?: string + /** + * When the event was received. + */ + received_at: string | number + /** + * Device-time when the event was sent. + */ + sent_at: string | number + /** + * The user's id + */ + user_id?: string +} diff --git a/packages/destination-actions/src/destinations/canvas/sendScreenEvent/index.ts b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/index.ts new file mode 100644 index 0000000000..901eaf9d20 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendScreenEvent/index.ts @@ -0,0 +1,32 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common-fields' +import { perform, performBatch } from '../api' + +const action: ActionDefinition = { + title: 'Send Screen Event', + description: 'Adds a screen event record in Canvas', + defaultSubscription: 'type = "screen"', + fields: { + name: { + type: 'string', + label: 'Name', + description: 'Screen name', + required: false, + default: { '@path': '$.name' } + }, + properties: { + type: 'object', + label: 'Properties', + description: 'Properties to associate with the screen', + required: false, + default: { '@path': '$.properties' } + }, + ...commonFields + }, + perform: perform('screen'), + performBatch: performBatch('screen') +} + +export default action diff --git a/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..2f1a16a5cc --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Canvas's sendTrackEvent destination action: all fields 1`] = ` +Array [ + Object { + "anonymous_id": "RGBF)J@&xDlluJcefcN9", + "context": Object { + "testType": "RGBF)J@&xDlluJcefcN9", + }, + "enable_batching": false, + "event": "RGBF)J@&xDlluJcefcN9", + "message_id": "RGBF)J@&xDlluJcefcN9", + "properties": Object { + "testType": "RGBF)J@&xDlluJcefcN9", + }, + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "RGBF)J@&xDlluJcefcN9", + "user_id": "RGBF)J@&xDlluJcefcN9", + }, +] +`; + +exports[`Testing snapshot for Canvas's sendTrackEvent destination action: required fields 1`] = ` +Array [ + Object { + "anonymous_id": "RGBF)J@&xDlluJcefcN9", + "enable_batching": false, + "event": "RGBF)J@&xDlluJcefcN9", + "message_id": "RGBF)J@&xDlluJcefcN9", + "received_at": "2021-02-01T00:00:00.000Z", + "sent_at": "2021-02-01T00:00:00.000Z", + "user_id": "RGBF)J@&xDlluJcefcN9", + }, +] +`; diff --git a/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..2ab9f6899a --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/index.test.ts @@ -0,0 +1,168 @@ +import { createTestEvent } from '@segment/actions-core' +import { testAction, testBatchAction, userAgent } from '../../testing' + +const actionName = 'sendTrackEvent' + +describe('Canvas', () => { + describe(actionName, () => { + it('should submit event on Track event', async () => { + const event = createTestEvent({ + type: 'track', + event: 'User Registered', + properties: { + plan: 'Pro Annual', + accountType: 'Facebook' + }, + context: { + userAgent, + page: { + url: 'https://example.com', + referrer: 'https://example.com/other' + } + } + }) + const request = await testAction(actionName, event) + expect(request).toEqual([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + enable_batching: true, + properties: { + plan: 'Pro Annual', + accountType: 'Facebook' + }, + context: { + userAgent, + page: { + url: 'https://example.com', + referrer: 'https://example.com/other' + } + }, + event: 'User Registered', + timestamp: event.timestamp, + sent_at: event.sentAt, + received_at: event.receivedAt, + message_id: event.messageId + } + ]) + }) + + it('should submit event on Track event with all optional fields omitted', async () => { + const event = createTestEvent({ + type: 'track', + event: 'User Registered' + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + timestamp: event.timestamp + } + ]) + }) + + it('should submit event on Track event with email in properties', async () => { + const event = createTestEvent({ + type: 'track', + event: 'User Registered', + properties: { + email: 'peter@example.com' + } + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + anonymous_id: event.anonymousId, + properties: { + email: 'peter@example.com' + }, + timestamp: event.timestamp + } + ]) + }) + + it('should submit event on Track event with email in properties and without ids', async () => { + const event = createTestEvent({ + type: 'track', + event: 'User Registered', + properties: { + email: 'peter@example.com' + }, + userId: undefined, + anonymousId: undefined + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + properties: { + email: 'peter@example.com' + }, + event: 'User Registered', + timestamp: event.timestamp + } + ]) + }) + + it('should not skip an event with userId only', async () => { + const event = createTestEvent({ + type: 'track', + event: 'User Registered', + anonymousId: undefined + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + user_id: event.userId, + event: 'User Registered', + timestamp: event.timestamp + } + ]) + }) + + it('should not skip an event with anonymousId only', async () => { + const event = createTestEvent({ + type: 'track', + event: 'User Registered', + userId: undefined + }) + const request = await testAction(actionName, event) + expect(request).toMatchObject([ + { + anonymous_id: event.anonymousId, + event: 'User Registered', + timestamp: event.timestamp + } + ]) + }) + + it('should submit event batch', async () => { + const events = [ + createTestEvent({ + type: 'track', + event: 'User Registered' + }), + createTestEvent({ + type: 'track', + event: 'Order Completed' + }) + ] + const request = await testBatchAction(actionName, events) + expect(request).toMatchObject([ + { + user_id: events[0].userId, + anonymous_id: events[0].anonymousId, + event: 'User Registered', + timestamp: events[0].timestamp + }, + { + user_id: events[1].userId, + anonymous_id: events[1].anonymousId, + event: 'Order Completed', + timestamp: events[1].timestamp + } + ]) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..e956b2e688 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendTrackEvent' +const destinationSlug = 'Canvas' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/canvas/sendTrackEvent/generated-types.ts b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/generated-types.ts new file mode 100644 index 0000000000..8c6695d227 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/generated-types.ts @@ -0,0 +1,48 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the event. + */ + event: string + /** + * A JSON object containing the properties of the event. + */ + properties?: { + [k: string]: unknown + } + /** + * Sends events in bulk to Canvas. Highly recommended. + */ + enable_batching: boolean + /** + * Event context as it appears in Segment + */ + context?: { + [k: string]: unknown + } + /** + * The anonymous ID associated with the user + */ + anonymous_id?: string + /** + * The Segment messageId + */ + message_id?: string + /** + * A timestamp of when the event took place. Default is current date and time. + */ + timestamp?: string + /** + * When the event was received. + */ + received_at: string | number + /** + * Device-time when the event was sent. + */ + sent_at: string | number + /** + * The user's id + */ + user_id?: string +} diff --git a/packages/destination-actions/src/destinations/canvas/sendTrackEvent/index.ts b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/index.ts new file mode 100644 index 0000000000..4fa9d15d73 --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/sendTrackEvent/index.ts @@ -0,0 +1,36 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common-fields' +import { perform, performBatch } from '../api' + +const action: ActionDefinition = { + title: 'Send Track Event', + description: 'Adds a track event in Canvas', + defaultSubscription: 'type = "track"', + fields: { + event: { + description: 'The name of the event.', + label: 'Event name', + required: true, + type: 'string', + default: { + '@path': '$.event' + } + }, + properties: { + label: 'Event Properties', + description: 'A JSON object containing the properties of the event.', + required: false, + type: 'object', + default: { + '@path': '$.properties' + } + }, + ...commonFields + }, + perform: perform('track'), + performBatch: performBatch('track') +} + +export default action diff --git a/packages/destination-actions/src/destinations/canvas/testing.ts b/packages/destination-actions/src/destinations/canvas/testing.ts new file mode 100644 index 0000000000..b9dea2984d --- /dev/null +++ b/packages/destination-actions/src/destinations/canvas/testing.ts @@ -0,0 +1,50 @@ +import { createTestIntegration, SegmentEvent } from '@segment/actions-core' +import nock from 'nock' +import { EventType, getEventUrl } from './api' +import destination from './index' +import { Settings } from './generated-types' + +const testDestination = createTestIntegration(destination) + +export const settings: Settings = { apiToken: 'testApiToken' } + +export const testAction = async (actionName: string, event: SegmentEvent): Promise => { + nock(getEventUrl(eventType(event))) + .post('') + .reply(200, {}) + const input = { event, settings, useDefaultMappings: true } + const responses = await testDestination.testAction(actionName, input) + expect(responses.length).toBe(1) + const request = responses[0].request + expect(request.headers.get('X-Auth-Token')).toBe(settings.apiToken) + const rawBody = await request.text() + return JSON.parse(rawBody) +} + +export const testBatchAction = async (actionName: string, events: SegmentEvent[]): Promise => { + nock(getEventUrl(eventType(events[0]))) + .post('') + .reply(200, {}) + const input = { events, settings, useDefaultMappings: true } + const responses = await testDestination.testBatchAction(actionName, input) + expect(responses.length).toBe(1) + const request = responses[0].request + expect(request.headers.get('X-Auth-Token')).toBe(settings.apiToken) + const rawBody = await request.text() + return JSON.parse(rawBody) +} + +export const userAgent = + '"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + +const eventType = (event: SegmentEvent): EventType => { + if ( + event.type == 'track' || + event.type == 'identify' || + event.type == 'screen' || + event.type == 'page' || + event.type == 'group' + ) + return event.type + throw new Error(`Not supported event type for tests: ${event.type}`) +} diff --git a/packages/destination-actions/src/destinations/chartmogul/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/chartmogul/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..6c3c3b2114 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-chartmogul destination: sendContact action - all fields 1`] = ` +Object { + "anonymous_id": "sCFIdyQr", + "company": Object { + "id": "sCFIdyQr", + "name": "sCFIdyQr", + }, + "email": "puh@bak.bn", + "first_name": "sCFIdyQr", + "last_name": "sCFIdyQr", + "linked_in": "http://li.jo/se", + "message_id": "sCFIdyQr", + "name": "sCFIdyQr", + "phone": "sCFIdyQr", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "title": "sCFIdyQr", + "twitter": "sCFIdyQr", + "type": "sCFIdyQr", + "user_id": "sCFIdyQr", +} +`; + +exports[`Testing snapshot for actions-chartmogul destination: sendContact action - required fields 1`] = ` +Object { + "anonymous_id": "sCFIdyQr", + "message_id": "sCFIdyQr", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "type": "sCFIdyQr", + "user_id": "sCFIdyQr", +} +`; + +exports[`Testing snapshot for actions-chartmogul destination: sendCustomer action - all fields 1`] = ` +Object { + "address": Object { + "city": "jbl@p6u", + "country": "jbl@p6u", + "postal_code": "jbl@p6u", + "state": "jbl@p6u", + "street": "jbl@p6u", + }, + "created_at": "2021-02-01T00:00:00.000Z", + "description": "jbl@p6u", + "email": "el@di.as", + "group_id": "jbl@p6u", + "message_id": "jbl@p6u", + "name": "jbl@p6u", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "type": "jbl@p6u", + "user_id": "jbl@p6u", + "website": "jbl@p6u", +} +`; + +exports[`Testing snapshot for actions-chartmogul destination: sendCustomer action - required fields 1`] = ` +Object { + "group_id": "jbl@p6u", + "message_id": "jbl@p6u", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "type": "jbl@p6u", + "user_id": "jbl@p6u", +} +`; diff --git a/packages/destination-actions/src/destinations/chartmogul/__tests__/index.test.ts b/packages/destination-actions/src/destinations/chartmogul/__tests__/index.test.ts new file mode 100644 index 0000000000..4efd455937 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/__tests__/index.test.ts @@ -0,0 +1,37 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Chart Mogul', () => { + describe('testAuthentication', () => { + it('should validate that chartmogul_webhook_url starts with https://', async () => { + try { + await testDestination.testAuthentication({ chartmogul_webhook_url: 'httpp://wh.endpoint' }) + } catch (err: any) { + expect(err.message).toContain('Please configure the ChartMogul webhook URL') + } + }), + it('should test that authentication works', async () => { + nock('https://chartmogul.webhook.endpoint').post('/').reply(200, {}) + + const authData = { chartmogul_webhook_url: 'https://chartmogul.webhook.endpoint' } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }), + it('should test that authentication fails', async () => { + nock('https://wrong.chartmogul.webhook.endpoint') + .post('/') + .reply(403, { errors: [{ field: null, message: 'access forbidden' }] }) + + const authData = { chartmogul_webhook_url: 'https://wrong.chartmogul.webhook.endpoint' } + + try { + await testDestination.testAuthentication(authData) + } catch (err: any) { + expect(err.message).toContain('Credentials are invalid') + } + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/chartmogul/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/chartmogul/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..557201e7c6 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/__tests__/snapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-chartmogul' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + // set the chartmogul_webhook_url to a valid URL + settingsData.chartmogul_webhook_url = 'https://chartmogul.webhook.endpoint' + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + // set the chartmogul_webhook_url to a valid URL + settingsData.chartmogul_webhook_url = 'https://chartmogul.webhook.endpoint' + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/chartmogul/common_fields.ts b/packages/destination-actions/src/destinations/chartmogul/common_fields.ts new file mode 100644 index 0000000000..c2ea3624e3 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/common_fields.ts @@ -0,0 +1,52 @@ +import { InputField } from '@segment/actions-core/destination-kit/types' + +export const message_id: InputField = { + label: 'MessageId', + description: 'The Segment message id', + type: 'string', + required: true, + default: { '@path': '$.messageId' } +} + +export const timestamp: InputField = { + label: 'Event Timestamp', + description: 'The timestamp at which the event was created', + type: 'datetime', + required: true, + default: { '@path': '$.timestamp' } +} + +export const sent_at: InputField = { + label: 'Sent At', + description: 'When the event was sent', + type: 'datetime', + required: true, + default: { '@path': '$.sentAt' } +} + +export const event_type: InputField = { + label: 'Event Type', + description: 'The type of event', + type: 'string', + default: 'Send ...', + required: true, + unsafe_hidden: true +} + +export const user_id: InputField = { + label: 'User Id', + description: 'Segment User Id', + type: 'string', + readOnly: true, + required: false, + default: { '@path': '$.userId' } +} + +export const anonymous_id: InputField = { + label: 'Anonymous Id', + description: 'Segment Anonymous Id', + type: 'string', + readOnly: true, + required: false, + default: { '@path': '$.anonymousId' } +} diff --git a/packages/destination-actions/src/destinations/chartmogul/generated-types.ts b/packages/destination-actions/src/destinations/chartmogul/generated-types.ts new file mode 100644 index 0000000000..786ba1f6a7 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Copy the webhook URL from ChartMogul and paste it here + */ + chartmogul_webhook_url: string +} diff --git a/packages/destination-actions/src/destinations/chartmogul/index.ts b/packages/destination-actions/src/destinations/chartmogul/index.ts new file mode 100644 index 0000000000..015c5cee26 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/index.ts @@ -0,0 +1,44 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { InvalidAuthenticationError } from '@segment/actions-core' + +import sendContact from './sendContact' + +import sendCustomer from './sendCustomer' + +const destination: DestinationDefinition = { + name: 'Chart Mogul', + slug: 'actions-chartmogul', + mode: 'cloud', + description: 'Send Contacts and Customers to ChartMogul.', + authentication: { + scheme: 'custom', + fields: { + chartmogul_webhook_url: { + label: 'ChartMogul webhook URL', + description: 'Copy the webhook URL from ChartMogul and paste it here', + type: 'string', + format: 'uri', + required: true + } + }, + testAuthentication: (request, auth) => { + const targetUrl = auth?.settings?.chartmogul_webhook_url + if (!targetUrl || !targetUrl.startsWith('https://')) { + throw new InvalidAuthenticationError('Please configure the ChartMogul webhook URL.') + } + + return request(targetUrl, { + method: 'post', + json: {} + }) + } + }, + + actions: { + sendContact, + sendCustomer + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..47b0b53bbb --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Chartmogul's sendContact destination action: all fields 1`] = ` +Object { + "anonymous_id": "xd0rHoH^PTr", + "company": Object { + "id": "xd0rHoH^PTr", + "name": "xd0rHoH^PTr", + }, + "email": "vuha@telwozok.in", + "first_name": "xd0rHoH^PTr", + "last_name": "xd0rHoH^PTr", + "linked_in": "http://cona.ir/tenromco", + "message_id": "xd0rHoH^PTr", + "name": "xd0rHoH^PTr", + "phone": "xd0rHoH^PTr", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "title": "xd0rHoH^PTr", + "twitter": "xd0rHoH^PTr", + "type": "xd0rHoH^PTr", + "user_id": "xd0rHoH^PTr", +} +`; + +exports[`Testing snapshot for Chartmogul's sendContact destination action: required fields 1`] = ` +Object { + "anonymous_id": "xd0rHoH^PTr", + "message_id": "xd0rHoH^PTr", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "type": "xd0rHoH^PTr", + "user_id": "xd0rHoH^PTr", +} +`; diff --git a/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/index.test.ts new file mode 100644 index 0000000000..e99740d54e --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/index.test.ts @@ -0,0 +1,71 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const CHARTMOGUL_WEBHOOK_URL = 'https://chartmogul.webhook.endpoint' +const MINIMAL_MAPPING = { + type: 'Send Contact', + message_id: '1', + timestamp: '2024-01-01T10:00:00Z', + sent_at: '2024-01-01T10:01:00Z' +} + +const testDestination = createTestIntegration(Destination) + +describe('Chartmogul.sendContact', () => { + it('validates action fields', async () => { + try { + await testDestination.testAction('sendContact', { + settings: { chartmogul_webhook_url: CHARTMOGUL_WEBHOOK_URL } + }) + } catch (err: any) { + expect(err.message).toContain("missing the required field 'type'.") + expect(err.message).toContain("missing the required field 'message_id'.") + expect(err.message).toContain("missing the required field 'timestamp'.") + expect(err.message).toContain("missing the required field 'sent_at'.") + } + }) + + it('requires user_id or anonymous_id', async () => { + try { + await testDestination.testAction('sendContact', { + mapping: { ...MINIMAL_MAPPING }, + settings: { chartmogul_webhook_url: CHARTMOGUL_WEBHOOK_URL } + }) + } catch (err: any) { + expect(err.message).toContain('The user_id and/or anonymous_id must be present.') + } + }) + + it('requires more than the required fields and the user_id', async () => { + try { + await testDestination.testAction('sendContact', { + mapping: { ...MINIMAL_MAPPING, user_id: 'u1' }, + settings: { chartmogul_webhook_url: CHARTMOGUL_WEBHOOK_URL } + }) + } catch (err: any) { + expect(err.message).toContain('The event contains no information of interest to Chartmogul.') + } + }) + + it('requires more than the required fields and the anonymous_id', async () => { + try { + await testDestination.testAction('sendContact', { + mapping: { ...MINIMAL_MAPPING, anonymous_id: 'a1' }, + settings: { chartmogul_webhook_url: CHARTMOGUL_WEBHOOK_URL } + }) + } catch (err: any) { + expect(err.message).toContain('The event contains no information of interest to Chartmogul.') + } + }) + + it('accepts the required fields, the user_id and the anonymous_id', async () => { + const mapping = { ...MINIMAL_MAPPING, user_id: 'u1', anonymous_id: 'a1' } + nock(CHARTMOGUL_WEBHOOK_URL).post('/', mapping).reply(200, {}) + + await testDestination.testAction('sendContact', { + mapping: mapping, + settings: { chartmogul_webhook_url: CHARTMOGUL_WEBHOOK_URL } + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..dccdb0afd4 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendContact/__tests__/snapshot.test.ts @@ -0,0 +1,79 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendContact' +const destinationSlug = 'Chartmogul' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + // set the chartmogul_webhook_url to a valid URL + settingsData.chartmogul_webhook_url = 'https://chartmogul.webhook.endpoint' + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + // set the chartmogul_webhook_url to a valid URL + settingsData.chartmogul_webhook_url = 'https://chartmogul.webhook.endpoint' + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/chartmogul/sendContact/generated-types.ts b/packages/destination-actions/src/destinations/chartmogul/sendContact/generated-types.ts new file mode 100644 index 0000000000..20d226d330 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendContact/generated-types.ts @@ -0,0 +1,67 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The type of event + */ + type: string + /** + * The Segment message id + */ + message_id: string + /** + * The timestamp at which the event was created + */ + timestamp: string | number + /** + * When the event was sent + */ + sent_at: string | number + /** + * Segment User Id + */ + user_id?: string + /** + * Segment Anonymous Id + */ + anonymous_id?: string + /** + * The user's email + */ + email?: string + /** + * The contact's first name + */ + first_name?: string + /** + * The contact's last name + */ + last_name?: string + /** + * The contact's full name. It is used if first_name and last_name are not provided. + */ + name?: string + /** + * The contact's job or personal title + */ + title?: string + /** + * The contact's phone number + */ + phone?: string + /** + * The contact's LinkedIn URL + */ + linked_in?: string + /** + * The contact's Twitter (X) URL or handle + */ + twitter?: string + /** + * The contact's Company. It creates a Customer in ChartMogul if the company id is present. + */ + company?: { + id: string + name?: string + } +} diff --git a/packages/destination-actions/src/destinations/chartmogul/sendContact/index.ts b/packages/destination-actions/src/destinations/chartmogul/sendContact/index.ts new file mode 100644 index 0000000000..0dab1b7298 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendContact/index.ts @@ -0,0 +1,112 @@ +import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { event_type, message_id, timestamp, sent_at, user_id, anonymous_id } from '../common_fields' + +const action: ActionDefinition = { + title: 'Send Contact', + description: 'Send a Contact to ChartMogul CRM', + defaultSubscription: 'type = "identify"', + fields: { + type: { ...event_type, default: 'Send Contact' }, + message_id, + timestamp, + sent_at, + user_id, + anonymous_id, + email: { + label: 'Email', + description: "The user's email", + type: 'string', + format: 'email', + default: { '@path': '$.traits.email' } + }, + first_name: { + label: 'First Name', + description: `The contact's first name`, + type: 'string', + default: { '@path': '$.traits.first_name' } + }, + last_name: { + label: 'Last Name', + description: "The contact's last name", + type: 'string', + default: { '@path': '$.traits.last_name' } + }, + name: { + label: 'Full Name', + description: "The contact's full name. It is used if first_name and last_name are not provided.", + type: 'string', + default: { '@path': '$.traits.name' } + }, + title: { + label: 'Title', + description: `The contact's job or personal title`, + type: 'string', + default: { '@path': '$.traits.title' } + }, + phone: { + label: 'Phone Number', + description: "The contact's phone number", + type: 'string', + default: { '@path': '$.traits.phone' } + }, + linked_in: { + label: 'LinkedIn', + description: "The contact's LinkedIn URL", + type: 'string', + format: 'uri', + default: { '@path': '$.traits.linkedIn' } + }, + twitter: { + label: 'Twitter (X)', + description: "The contact's Twitter (X) URL or handle", + type: 'string', + default: { '@path': '$.traits.twitter' } + }, + company: { + label: 'Company', + description: "The contact's Company. It creates a Customer in ChartMogul if the company id is present.", + type: 'object', + additionalProperties: false, + defaultObjectUI: 'keyvalue', + properties: { + id: { + label: 'Company Id', + type: 'string', + required: true + }, + name: { + label: 'Company Name', + type: 'string' + } + }, + default: { + id: { '@path': '$.traits.company.id' }, + name: { '@path': '$.traits.company.name' } + } + } + }, + perform: (request, data) => { + if (!data.payload.user_id && !data.payload.anonymous_id) { + throw new PayloadValidationError(`The user_id and/or anonymous_id must be present.`) + } + + if (data.payload.company && !data.payload.company.id) { + delete data.payload.company + } + + // we definitely map type, message_id, timestamp, sent_at, and (user_id or anonymous_id) + // A mapping containing only these fields is not useful. + if (Object.keys(data.payload).length <= 5) { + throw new PayloadValidationError('The event contains no information of interest to Chartmogul.') + } + + return request(data.settings.chartmogul_webhook_url, { + method: 'post', + json: data.payload + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..976d796b2b --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,35 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Chartmogul's sendCustomer destination action: all fields 1`] = ` +Object { + "address": Object { + "city": "nX10Ilka9K6F", + "country": "nX10Ilka9K6F", + "postal_code": "nX10Ilka9K6F", + "state": "nX10Ilka9K6F", + "street": "nX10Ilka9K6F", + }, + "created_at": "2021-02-01T00:00:00.000Z", + "description": "nX10Ilka9K6F", + "email": "fiwhaebi@jemtu.ki", + "group_id": "nX10Ilka9K6F", + "message_id": "nX10Ilka9K6F", + "name": "nX10Ilka9K6F", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "type": "nX10Ilka9K6F", + "user_id": "nX10Ilka9K6F", + "website": "nX10Ilka9K6F", +} +`; + +exports[`Testing snapshot for Chartmogul's sendCustomer destination action: required fields 1`] = ` +Object { + "group_id": "nX10Ilka9K6F", + "message_id": "nX10Ilka9K6F", + "sent_at": "2021-02-01T00:00:00.000Z", + "timestamp": "2021-02-01T00:00:00.000Z", + "type": "nX10Ilka9K6F", + "user_id": "nX10Ilka9K6F", +} +`; diff --git a/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/index.test.ts b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/index.test.ts new file mode 100644 index 0000000000..dbb31de6a5 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/index.test.ts @@ -0,0 +1,42 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const CHARTMOGUL_WEBHOOK_URL = 'https://chartmogul.webhook.endpoint' +const testDestination = createTestIntegration(Destination) + +describe('Chartmogul.sendCustomer', () => { + it('validates action fields', async () => { + try { + await testDestination.testAction('sendCustomer', { + settings: { chartmogul_webhook_url: CHARTMOGUL_WEBHOOK_URL } + }) + } catch (err: any) { + expect(err.message).toContain("missing the required field 'type'.") + expect(err.message).toContain("missing the required field 'message_id'.") + expect(err.message).toContain("missing the required field 'timestamp'.") + expect(err.message).toContain("missing the required field 'sent_at'.") + expect(err.message).toContain("missing the required field 'group_id'.") + expect(err.message).toContain("missing the required field 'user_id'.") + } + }) + + it('processes valid input', async () => { + const mapping = { + type: 'Send Customer', + message_id: '1', + timestamp: '2024-01-01T10:00:00Z', + sent_at: '2024-01-01T10:01:00Z', + group_id: 'g1', + user_id: 'u1', + name: 'Soft Tech' + } + + nock(CHARTMOGUL_WEBHOOK_URL).post('/', mapping).reply(200, {}) + + await testDestination.testAction('sendCustomer', { + mapping: mapping, + settings: { chartmogul_webhook_url: CHARTMOGUL_WEBHOOK_URL } + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..26849d4f28 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/__tests__/snapshot.test.ts @@ -0,0 +1,79 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendCustomer' +const destinationSlug = 'Chartmogul' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + // set the chartmogul_webhook_url to a valid URL + settingsData.chartmogul_webhook_url = 'https://chartmogul.webhook.endpoint' + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + // set the chartmogul_webhook_url to a valid URL + settingsData.chartmogul_webhook_url = 'https://chartmogul.webhook.endpoint' + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/chartmogul/sendCustomer/generated-types.ts b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/generated-types.ts new file mode 100644 index 0000000000..017211d7ec --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/generated-types.ts @@ -0,0 +1,73 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The type of event + */ + type: string + /** + * The Segment message id + */ + message_id: string + /** + * The timestamp at which the event was created + */ + timestamp: string | number + /** + * When the event was sent + */ + sent_at: string | number + /** + * Segment User Id + */ + user_id: string + /** + * Segment Group Id + */ + group_id: string + /** + * The company's name + */ + name?: string + /** + * The company's name + */ + description?: string + /** + * The company's email + */ + email?: string + /** + * The company's website URL + */ + website?: string + /** + * Date the group’s account was first created + */ + created_at?: string | number + /** + * The company’s address details + */ + address?: { + /** + * The company’s street address + */ + street?: string + /** + * The company’s city + */ + city?: string + /** + * The company’s state or region + */ + state?: string + /** + * The company’s zip or postal code + */ + postal_code?: string + /** + * The company’s country + */ + country?: string + } +} diff --git a/packages/destination-actions/src/destinations/chartmogul/sendCustomer/index.ts b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/index.ts new file mode 100644 index 0000000000..a5b383bce9 --- /dev/null +++ b/packages/destination-actions/src/destinations/chartmogul/sendCustomer/index.ts @@ -0,0 +1,104 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { event_type, message_id, timestamp, sent_at, user_id } from '../common_fields' + +const action: ActionDefinition = { + title: 'Send Customer', + description: 'Send a Customer (company) to ChartMogul CRM', + defaultSubscription: 'type = "group"', + fields: { + type: { ...event_type, default: 'Send Customer' }, + message_id, + timestamp, + sent_at, + user_id: { ...user_id, required: true }, + group_id: { + label: 'Group Id', + description: 'Segment Group Id', + type: 'string', + required: true, + default: { '@path': '$.groupId' } + }, + name: { + label: 'Name', + description: "The company's name", + type: 'string', + default: { '@path': '$.traits.name' } + }, + description: { + label: 'Description', + description: "The company's name", + type: 'string', + default: { '@path': '$.traits.description' } + }, + email: { + label: 'Email', + description: "The company's email", + type: 'string', + format: 'email', + default: { '@path': '$.traits.email' } + }, + website: { + label: 'Website', + description: "The company's website URL", + type: 'string', + format: 'uri-reference', + default: { '@path': '$.traits.website' } + }, + created_at: { + label: 'Created at', + description: 'Date the group’s account was first created', + type: 'datetime', + default: { '@path': '$.traits.createdAt' } + }, + address: { + label: 'Address', + type: 'object', + description: 'The company’s address details', + defaultObjectUI: 'keyvalue', + properties: { + street: { + label: 'Street', + type: 'string', + description: 'The company’s street address' + }, + city: { + label: 'City', + type: 'string', + description: 'The company’s city' + }, + state: { + label: 'State', + type: 'string', + description: 'The company’s state or region' + }, + postal_code: { + label: 'Postal code', + type: 'string', + description: 'The company’s zip or postal code' + }, + country: { + label: 'Country', + type: 'string', + description: 'The company’s country' + } + }, + default: { + street: { '@path': '$.traits.address.street' }, + city: { '@path': '$.traits.address.city' }, + state: { '@path': '$.traits.address.state' }, + postal_code: { '@path': '$.traits.address.postalCode' }, + country: { '@path': '$.traits.address.country' } + } + } + }, + perform: (request, data) => { + return request(data.settings.chartmogul_webhook_url, { + method: 'post', + json: data.payload + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/cordial/identities-fields.ts b/packages/destination-actions/src/destinations/cordial/identities-fields.ts index 1e84debb1c..897fb5c07e 100644 --- a/packages/destination-actions/src/destinations/cordial/identities-fields.ts +++ b/packages/destination-actions/src/destinations/cordial/identities-fields.ts @@ -1,17 +1,19 @@ -import {InputField} from "@segment/actions-core"; +import { InputField } from '@segment/actions-core' -export const userIdentityFields : Record = { +export const userIdentityFields: Record = { segmentId: { label: 'Segment User ID', description: 'Segment User ID value', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@path': '$.userId' } }, anonymousId: { label: 'Segment Anonymous ID', description: 'Segment Anonymous ID value', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@path': '$.anonymousId' } }, @@ -22,7 +24,7 @@ export const userIdentityFields : Record = { type: 'object', required: false, defaultObjectUI: 'keyvalue:only' - }, + } } -export default userIdentityFields; +export default userIdentityFields diff --git a/packages/destination-actions/src/destinations/courier/generated-types.ts b/packages/destination-actions/src/destinations/courier/generated-types.ts new file mode 100644 index 0000000000..d90838ed6a --- /dev/null +++ b/packages/destination-actions/src/destinations/courier/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Courier API Key from Segment integration page in the Courier Designer. + */ + apiKey: string + /** + * Courier Region (US or EU) + */ + region: string +} diff --git a/packages/destination-actions/src/destinations/courier/index.ts b/packages/destination-actions/src/destinations/courier/index.ts new file mode 100644 index 0000000000..86a28416a9 --- /dev/null +++ b/packages/destination-actions/src/destinations/courier/index.ts @@ -0,0 +1,44 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import postToCourier from './postToCourier' + +const destination: DestinationDefinition = { + name: 'Courier (Actions)', + slug: 'actions-courier', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Courier API Key from Segment integration page in the Courier Designer.', + type: 'password', + required: true + }, + region: { + label: 'Region', + description: 'Courier Region (US or EU)', + type: 'string', + default: 'US', + choices: [ + { + value: 'US', + label: 'US' + }, + { + value: 'EU', + label: 'EU' + } + ], + required: true + } + } + }, + actions: { + postToCourier + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/courier/postToCourier/__tests__/index.test.ts b/packages/destination-actions/src/destinations/courier/postToCourier/__tests__/index.test.ts new file mode 100644 index 0000000000..c1e49ab959 --- /dev/null +++ b/packages/destination-actions/src/destinations/courier/postToCourier/__tests__/index.test.ts @@ -0,0 +1,68 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const timestamp = '2023-02-22T15:21:15.449Z' + +describe('Courier.post', () => { + it('Posts an event succesfully to US', async () => { + const event = createTestEvent({ + timestamp, + type: 'track', + event: 'test-event', + userId: 'test-user-id', + anonymousId: 'test-anonymous-id', + properties: { 'test-property': 'test-value', 'test-property-2': 'test-value-2' } + }) + + nock('https://api.courier.com').post('/inbound/segment').reply(202, { + messageId: 'message-1' + }) + + const response = await testDestination.testAction('postToCourier', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key', + region: 'US' + } + }) + + expect(response[0].status).toBe(202) + expect(response[0].data).toMatchObject({ + messageId: 'message-1' + }) + expect(response.length).toBe(1) + }) + + it('Posts an event succesfully to EU', async () => { + const event = createTestEvent({ + timestamp, + type: 'track', + event: 'test-event', + userId: 'test-user-id', + anonymousId: 'test-anonymous-id', + properties: { 'test-property': 'test-value', 'test-property-2': 'test-value-2' } + }) + + nock('https://api.eu.courier.com').post('/inbound/segment').reply(202, { + messageId: 'message-1' + }) + + const response = await testDestination.testAction('postToCourier', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key', + region: 'EU' + } + }) + + expect(response[0].status).toBe(202) + expect(response[0].data).toMatchObject({ + messageId: 'message-1' + }) + expect(response.length).toBe(1) + }) +}) diff --git a/packages/destination-actions/src/destinations/courier/postToCourier/generated-types.ts b/packages/destination-actions/src/destinations/courier/postToCourier/generated-types.ts new file mode 100644 index 0000000000..d4cde64b46 --- /dev/null +++ b/packages/destination-actions/src/destinations/courier/postToCourier/generated-types.ts @@ -0,0 +1,10 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * All payload data + */ + data: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/courier/postToCourier/index.ts b/packages/destination-actions/src/destinations/courier/postToCourier/index.ts new file mode 100644 index 0000000000..4b131b19d9 --- /dev/null +++ b/packages/destination-actions/src/destinations/courier/postToCourier/index.ts @@ -0,0 +1,38 @@ +import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Forward to Courier', + description: 'Forward track, group and identify events to Courier', + defaultSubscription: `type = "track" or type = "identify" or type = "group"`, + fields: { + data: { + label: 'Payload', + description: 'All payload data', + type: 'object', + required: true, + default: { '@path': '$.' }, + unsafe_hidden: true + } + }, + perform: (request, { settings, payload }) => { + if (!['track', 'group', 'identify'].includes(payload.data.type as string)) { + throw new PayloadValidationError('Event type must be either track, group or identify') + } + + const domain = `https://api.${settings.region === 'EU' ? 'eu.' : ''}courier.com` + const headers = { + Authorization: `Bearer ${settings.apiKey}`, + 'Content-Type': 'application/json' + } + + return request(`${domain}/inbound/segment`, { + method: 'POST', + headers, + json: payload.data + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/__tests__/index.test.ts index 7821b1ea07..bd0f85fae7 100644 --- a/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/__tests__/index.test.ts @@ -31,6 +31,11 @@ const VALID_SETTINGS = { } const ADVERTISER_AUDIENCES = { + "meta": { + "totalItems": 1, + "limit": 0, + "offset": 0 + }, "data": [ { "id": "1234", @@ -42,11 +47,40 @@ const ADVERTISER_AUDIENCES = { ] } -const AUDIENCE_CREATION_RESPONSE = { - "data": { - "id": "5678", - "type": "Audience" +const ADVERTISER_AUDIENCES_KEY_DOES_NOT_EXIST = { + "meta": { + "totalItems": 1, + "limit": 0, + "offset": 0 }, + "data": [ + { + "id": "1234", + "attributes": { + "advertiserId": VALID_ADVERTISER_ID, + "name": "Other audience name" + } + } + ] +} + +const AUDIENCE_CREATION_RESPONSE = { + "data": [ + { + "attributes": { + "name": AUDIENCE_KEY, + "description": AUDIENCE_KEY, + "type": "ContactList", + "advertiserId": VALID_ADVERTISER_ID, + "contactList": { + "file": null, + "isFromPublicApi": true + } + }, + "id": "5678", + "type": "AudienceSegment" + } + ], "errors": [], "warnings": [] } @@ -55,9 +89,9 @@ const DUPLICATE_AUDIENCE_ERROR = { "errors": [ { "type": "validation", - "code": "invalid-audience-name-duplicated", - "title": "Duplicate name", - "detail": "Audience name test_audience already exists for advertiser x on audience 1234" + "code": "name-must-be-unique", + "title": "Segment name must be unique", + "detail": "Another Segment exists with the name: ABCD" } ] } @@ -66,7 +100,7 @@ describe('addUserToAudience', () => { it('should throw error if no access to the audiences of the advertiser', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(403) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(403) await expect( testDestination.testAction('addUserToAudience', { event, @@ -97,21 +131,10 @@ describe('addUserToAudience', () => { it('should not throw an error if the audience creation and the patch requests succeed', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(200, { - "data": [ - { - "id": "5678", - "attributes": { - "advertiserId": VALID_ADVERTISER_ID, - "name": "OTHER AUDIENCE NAME" - } - } - ] - } - ) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES_KEY_DOES_NOT_EXIST) // The audience key is not present in the list of the advertiser's audiences so a new audience needs to be created - nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/audiences$/).reply(200, AUDIENCE_CREATION_RESPONSE) - nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/audiences\/\d+\/contactlist$/).reply(200) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/create$/).reply(200, AUDIENCE_CREATION_RESPONSE) + nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/\d+\/contact-list$/).reply(200) await expect( testDestination.testAction('addUserToAudience', { @@ -125,8 +148,8 @@ describe('addUserToAudience', () => { it('should not throw an error if the audience already exists and the patch requests succeeds', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(200, ADVERTISER_AUDIENCES) - nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/audiences\/\d+\/contactlist$/).reply(200) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES) + nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/\d+\/contact-list$/).reply(200) await expect( testDestination.testAction('addUserToAudience', { @@ -140,20 +163,10 @@ describe('addUserToAudience', () => { it('should not throw an error in case of concurrent audience creation attempt', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').persist().get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(200, { - "data": [ - { - "id": "5678", - "attributes": { - "advertiserId": VALID_ADVERTISER_ID, - "name": "OTHER AUDIENCE NAME" - } - } - ] - } - ) - nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/audiences$/).reply(400, DUPLICATE_AUDIENCE_ERROR) - nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/audiences\/\d+\/contactlist$/).reply(200) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES_KEY_DOES_NOT_EXIST) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/create$/).reply(400, DUPLICATE_AUDIENCE_ERROR) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES) + nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/\d+\/contact-list$/).reply(200) await expect( testDestination.testAction('addUserToAudience', { @@ -164,4 +177,3 @@ describe('addUserToAudience', () => { ).resolves.not.toThrowError() }) }) - diff --git a/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/index.ts b/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/index.ts index cddde1750b..6e897d258b 100644 --- a/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/index.ts +++ b/packages/destination-actions/src/destinations/criteo-audiences/addUserToAudience/index.ts @@ -1,5 +1,5 @@ import { ActionDefinition } from '@segment/actions-core' -import { getAudienceId, patchAudience, hash } from '../criteo-audiences' +import { getContactListId, patchContactList, hash } from '../criteo-audiences' import type { Operation, ClientCredentials } from '../criteo-audiences' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -29,10 +29,10 @@ const getOperationFromPayload = async ( if (email) add_user_list.push(email) } - const audience_id = await getAudienceId(request, advertiser_id, audience_key, credentials) + const contactlist_id = await getContactListId(request, advertiser_id, audience_key, credentials) const operation: Operation = { operation_type: "add", - audience_id: audience_id, + contactlist_id: contactlist_id, user_list: add_user_list, } return operation; @@ -48,7 +48,7 @@ const processPayload = async ( client_secret: settings.client_secret } const operation: Operation = await getOperationFromPayload(request, settings.advertiser_id, payload, credentials); - return await patchAudience(request, operation, credentials) + return await patchContactList(request, operation, credentials) } const action: ActionDefinition = { diff --git a/packages/destination-actions/src/destinations/criteo-audiences/criteo-audiences.ts b/packages/destination-actions/src/destinations/criteo-audiences/criteo-audiences.ts index e7574066f3..e029b8019e 100644 --- a/packages/destination-actions/src/destinations/criteo-audiences/criteo-audiences.ts +++ b/packages/destination-actions/src/destinations/criteo-audiences/criteo-audiences.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto' import { IntegrationError, RetryableError } from '@segment/actions-core' import type { RequestClient } from '@segment/actions-core' -const BASE_API_URL = 'https://api.criteo.com/2023-01' +const BASE_API_URL = 'https://api.criteo.com/2023-10' export const hash = (value: string | undefined): string | undefined => { if (value === undefined) return @@ -23,7 +23,7 @@ class CriteoAPIError extends IntegrationError { export type Operation = { operation_type: string - audience_id: string + contactlist_id: string user_list: string[] } @@ -78,15 +78,19 @@ export const criteoAuthenticate = async ( return credentials } -export const patchAudience = async ( +export const patchContactList = async ( request: RequestClient, operation: Operation, credentials: ClientCredentials ): Promise => { - if (isNaN(+operation.audience_id)) - throw new IntegrationError(`The Audience ID should be a number (${operation.audience_id})`, 'Invalid input', 400) + if (isNaN(+operation.contactlist_id)) + throw new IntegrationError( + `The Audience Segment ID should be a number (${operation.contactlist_id})`, + 'Invalid input', + 400 + ) - const endpoint = `${BASE_API_URL}/audiences/${operation.audience_id}/contactlist` + const endpoint = `${BASE_API_URL}/marketing-solutions/audience-segments/${operation.contactlist_id}/contact-list` const headers = await getRequestHeaders(request, credentials) const payload = { data: { @@ -105,95 +109,125 @@ export const patchAudience = async ( }) } -export const getAdvertiserAudiences = async ( +export const getContactListIdByName = async ( request: RequestClient, advertiser_id: string, + audience_segment_name: string, credentials: ClientCredentials -): Promise>> => { +): Promise => { if (isNaN(+advertiser_id)) throw new IntegrationError('The Advertiser ID should be a number', 'Invalid input', 400) - const endpoint = `${BASE_API_URL}/audiences?advertiser-id=${advertiser_id}` + const LIMIT = 100 const headers = await getRequestHeaders(request, credentials) - const response = await request(endpoint, { method: 'GET', headers: headers }) + const payload = { + data: { + attributes: { + audienceSegmentTypes: ['ContactList'], + advertiserIds: [advertiser_id] + } + } + } - const body = await response.json() + let continue_search = true + let offset = 0 + interface AudienceSegment { + attributes: { + [key: string]: unknown + } + id: string + type: string + } - if (response.status !== 200) - // Centrifuge will automatically retry the batch if there's - // an issue fetching the Advertiser's audiences from Criteo. - throw new RetryableError("Error while fetching the Advertiser's audiences") + interface ApiResponse { + data: AudienceSegment[] + meta: { + totalItems: number + } + } - return body.data -} + let body: ApiResponse -export const getAudienceIdByName = async ( - request: RequestClient, - advertiser_id: string, - audience_name: string, - credentials: ClientCredentials -): Promise => { - const advertiser_audiences = await getAdvertiserAudiences(request, advertiser_id, credentials) - for (const audience of advertiser_audiences) { - if (audience.attributes.name === audience_name) return audience.id - } + do { + const endpoint = `${BASE_API_URL}/marketing-solutions/audience-segments/search?limit=${LIMIT}&offset=${offset}` + + const response = await request(endpoint, { + method: 'POST', + skipResponseCloning: true, + headers: headers, + json: payload + }) + + body = response.data as ApiResponse + + if (response.status !== 200) + // Centrifuge will automatically retry the batch if there's + // an issue fetching the Advertiser's audiences from Criteo. + throw new RetryableError("Error while fetching the Advertiser's audiences") + + // If the contact list is found, return the corresponding ID + for (const contactlist of body.data) { + if (contactlist.attributes.name === audience_segment_name) return contactlist.id + } + + // Else, continue searching + offset += LIMIT + continue_search = body.meta.totalItems > offset + } while (continue_search) } -export const getAudienceId = async ( +export const getContactListId = async ( request: RequestClient, advertiser_id: string, - audience_name: string, + name: string, credentials: ClientCredentials ): Promise => { - let audience_id = undefined + let contactlist_id = undefined - if (!audience_name) throw new IntegrationError(`Invalid Audience Name: ${audience_name}`, 'Invalid input', 400) + if (!name) throw new IntegrationError(`Invalid Audience Segment Name: ${name}`, 'Invalid input', 400) - // Loop through the advertiser's audiences. If the audience name is found, return the corresponding ID. - audience_id = await getAudienceIdByName(request, advertiser_id, audience_name, credentials) - if (audience_id) return audience_id + contactlist_id = await getContactListIdByName(request, advertiser_id, name, credentials) + if (contactlist_id && !isNaN(+contactlist_id)) return contactlist_id - // If the audience is not found, create it + // If the contact list is not found, create it try { - return await createAudience(request, advertiser_id, audience_name, credentials) + return await createContactList(request, advertiser_id, name, credentials) } catch (e) { if (e instanceof CriteoAPIError) { // If the audience was created in the meantime - if (e.error && e.error.code === 'invalid-audience-name-duplicated') { - // Return the audience ID from the error message - audience_id = e.error.detail.split(' ').pop() - if (audience_id && !isNaN(+audience_id)) return audience_id - - // If no audience ID found in the error message, loop through the advertiser's audiences - audience_id = await getAudienceIdByName(request, advertiser_id, audience_name, credentials) - if (audience_id && !isNaN(+audience_id)) return audience_id + if (e.error && e.error.code === 'name-must-be-unique') { + // Loop through the advertiser's contact lists to find the contact list ID + contactlist_id = await getContactListIdByName(request, advertiser_id, name, credentials) + if (contactlist_id && !isNaN(+contactlist_id)) return contactlist_id } } - // If no audience ID was found, throw the error. Because the status code is 400, + // If no contact list ID was found, throw the error. Because the status code is 400, // Centrifuge will not automatically retry the batch, hence the batch has failed permanently. throw e } } -export const createAudience = async ( +export const createContactList = async ( request: RequestClient, advertiser_id: string, - audience_name: string, + name: string, credentials: ClientCredentials ): Promise => { - if (!audience_name) throw new IntegrationError(`Invalid Audience Name: ${audience_name}`, 'Invalid audience', 400) + if (!name) throw new IntegrationError(`Invalid Contact List Name: ${name}`, 'Invalid audience', 400) if (isNaN(+advertiser_id)) throw new IntegrationError('The Advertiser ID should be a number', 'Invalid input', 400) - const endpoint = `${BASE_API_URL}/audiences` + const endpoint = `${BASE_API_URL}/marketing-solutions/audience-segments/create` const headers = await getRequestHeaders(request, credentials) const payload = { - data: { - attributes: { - advertiserId: advertiser_id, - name: audience_name, - description: audience_name - }, - type: 'Audience' - } + data: [ + { + attributes: { + advertiserId: advertiser_id, + name: name, + description: name, + contactList: {} + } + } + ] } const response = await request(endpoint, { method: 'POST', headers: headers, json: payload, throwHttpErrors: false }) @@ -201,8 +235,32 @@ export const createAudience = async ( if (response.status !== 200) { const err = body.errors && body.errors[0] ? body.errors[0] : undefined - throw new CriteoAPIError(`Error while creating the Audience`, 'Criteo audience creation error', 400, err) + throw new CriteoAPIError(`Error while creating the Contact List`, 'Criteo contact list creation error', 400, err) + } + + if (!Array.isArray(body.data)) { + throw new CriteoAPIError( + `Error while creating the Contact List. data[] not returned`, + 'Criteo contact list creation error', + 403 + ) + } + + if (body.data.length === 0) { + throw new CriteoAPIError( + `Error while creating the Contact List. data[] is empty`, + 'Criteo contact list creation error', + 403 + ) + } + + if (body.data[0].id === undefined) { + throw new CriteoAPIError( + `Error while creating the Contact List. data[0].id is undefined`, + 'Criteo contact list creation error', + 403 + ) } - return body.data.id + return body.data[0].id } diff --git a/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/__tests__/index.test.ts index 935d2c1a59..bc722d6d26 100644 --- a/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/__tests__/index.test.ts @@ -31,6 +31,11 @@ const VALID_SETTINGS = { } const ADVERTISER_AUDIENCES = { + "meta": { + "totalItems": 1, + "limit": 0, + "offset": 0 + }, "data": [ { "id": "1234", @@ -42,11 +47,40 @@ const ADVERTISER_AUDIENCES = { ] } -const AUDIENCE_CREATION_RESPONSE = { - "data": { - "id": "5678", - "type": "Audience" +const ADVERTISER_AUDIENCES_KEY_DOES_NOT_EXIST = { + "meta": { + "totalItems": 1, + "limit": 0, + "offset": 0 }, + "data": [ + { + "id": "1234", + "attributes": { + "advertiserId": VALID_ADVERTISER_ID, + "name": "Other audience name" + } + } + ] +} + +const AUDIENCE_CREATION_RESPONSE = { + "data": [ + { + "attributes": { + "name": AUDIENCE_KEY, + "description": AUDIENCE_KEY, + "type": "ContactList", + "advertiserId": VALID_ADVERTISER_ID, + "contactList": { + "file": null, + "isFromPublicApi": true + } + }, + "id": "5678", + "type": "AudienceSegment" + } + ], "errors": [], "warnings": [] } @@ -55,9 +89,9 @@ const DUPLICATE_AUDIENCE_ERROR = { "errors": [ { "type": "validation", - "code": "invalid-audience-name-duplicated", - "title": "Duplicate name", - "detail": "Audience name test_audience already exists for advertiser x on audience 1234" + "code": "name-must-be-unique", + "title": "Segment name must be unique", + "detail": "Another Segment exists with the name: ABCD" } ] } @@ -66,7 +100,7 @@ describe('removeUserFromAudience', () => { it('should throw error if no access to the audiences of the advertiser', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(403) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(403) await expect( testDestination.testAction('removeUserFromAudience', { event, @@ -97,21 +131,10 @@ describe('removeUserFromAudience', () => { it('should not throw an error if the audience creation and the patch requests succeed', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(200, { - "data": [ - { - "id": "5678", - "attributes": { - "advertiserId": VALID_ADVERTISER_ID, - "name": "OTHER AUDIENCE NAME" - } - } - ] - } - ) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES_KEY_DOES_NOT_EXIST) // The audience key is not present in the list of the advertiser's audiences so a new audience needs to be created - nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/audiences$/).reply(200, AUDIENCE_CREATION_RESPONSE) - nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/audiences\/\d+\/contactlist$/).reply(200) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/create$/).reply(200, AUDIENCE_CREATION_RESPONSE) + nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/\d+\/contact-list$/).reply(200) await expect( testDestination.testAction('removeUserFromAudience', { @@ -125,8 +148,8 @@ describe('removeUserFromAudience', () => { it('should not throw an error if the audience already exists and the patch requests succeeds', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(200, ADVERTISER_AUDIENCES) - nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/audiences\/\d+\/contactlist$/).reply(200) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES) + nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/\d+\/contact-list$/).reply(200) await expect( testDestination.testAction('removeUserFromAudience', { @@ -140,20 +163,10 @@ describe('removeUserFromAudience', () => { it('should not throw an error in case of concurrent audience creation attempt', async () => { const settings = VALID_SETTINGS; nock('https://api.criteo.com').persist().post('/oauth2/token').reply(200, MOCK_TOKEN_RESPONSE) - nock('https://api.criteo.com').persist().get(/^\/\d{4}-\d{2}\/audiences$/).query({ "advertiser-id": settings.advertiser_id }).reply(200, { - "data": [ - { - "id": "5678", - "attributes": { - "advertiserId": VALID_ADVERTISER_ID, - "name": "OTHER AUDIENCE NAME" - } - } - ] - } - ) - nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/audiences$/).reply(400, DUPLICATE_AUDIENCE_ERROR) - nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/audiences\/\d+\/contactlist$/).reply(200) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES_KEY_DOES_NOT_EXIST) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/create$/).reply(400, DUPLICATE_AUDIENCE_ERROR) + nock('https://api.criteo.com').post(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/search$/).query(true).reply(200, ADVERTISER_AUDIENCES) + nock('https://api.criteo.com').patch(/^\/\d{4}-\d{2}\/marketing-solutions\/audience-segments\/\d+\/contact-list$/).reply(200) await expect( testDestination.testAction('removeUserFromAudience', { @@ -163,4 +176,4 @@ describe('removeUserFromAudience', () => { }) ).resolves.not.toThrowError() }) -}) \ No newline at end of file +}) diff --git a/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/index.ts b/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/index.ts index 97c19e2a67..17e1bc1cc0 100644 --- a/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/index.ts +++ b/packages/destination-actions/src/destinations/criteo-audiences/removeUserFromAudience/index.ts @@ -1,5 +1,5 @@ import type { ActionDefinition } from '@segment/actions-core' -import { getAudienceId, patchAudience, hash } from '../criteo-audiences' +import { getContactListId, patchContactList, hash } from '../criteo-audiences' import type { Operation, ClientCredentials } from '../criteo-audiences' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -25,11 +25,11 @@ const getOperationFromPayload = async ( if (email) remove_user_list.push(email) } - const audience_id = await getAudienceId(request, advertiser_id, audience_key, credentials) + const contactlist_id = await getContactListId(request, advertiser_id, audience_key, credentials) const operation: Operation = { operation_type: "remove", - audience_id: audience_id, + contactlist_id: contactlist_id, user_list: remove_user_list, } return operation; @@ -46,7 +46,7 @@ const processPayload = async ( client_secret: settings.client_secret } const operation: Operation = await getOperationFromPayload(request, settings.advertiser_id, payload, credentials); - return await patchAudience(request, operation, credentials) + return await patchContactList(request, operation, credentials) } const action: ActionDefinition = { diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateDevice.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateDevice.test.ts index e4074da029..f3ac638cd6 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateDevice.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateDevice.test.ts @@ -1,221 +1,199 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' +import { createTestEvent } from '@segment/actions-core' import { Settings } from '../generated-types' import dayjs from '../../../lib/dayjs' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackService = nock('https://track.customer.io/api/v1') +import { getDefaultMappings, testRunner } from '../test-helper' describe('CustomerIO', () => { describe('createUpdateDevice', () => { - it('should work with default mappings when userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const deviceId = 'device_123' - const deviceType = 'ios' - const timestamp = dayjs.utc().toISOString() - trackService.put(`/customers/${userId}/devices`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - timestamp, - context: { + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when userId is supplied', async () => { + const userId = 'abc123' + const deviceId = 'device_123' + const deviceType = 'ios' + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + userId, + timestamp, + context: { + device: { + token: deviceId, + type: deviceType + } + } + }) + const response = await action('createUpdateDevice', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'add_device', device: { + attributes: {}, token: deviceId, - type: deviceType - } - } - }) - const responses = await testDestination.testAction('createUpdateDevice', { - event, - settings, - useDefaultMappings: true + platform: deviceType, + last_used: dayjs.utc(timestamp).unix() + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - device: { - id: deviceId, - platform: deviceType, - last_used: dayjs.utc(timestamp).unix() - } - }) - }) + it('should work if `attributes` is unmapped', async () => { + const userId = 'abc123' + const deviceId = 'device_123' + const deviceType = 'ios' + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + userId, + timestamp, + context: { + device: { + token: deviceId, + type: deviceType + } + } + }) - it("should not convert last_used if it's invalid", async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const deviceId = 'device_123' - const deviceType = 'ios' - const timestamp = '2018-03-04T12:08:56.235 PDT' - trackService.put(`/customers/${userId}/devices`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - timestamp, - context: { + const mapping = getDefaultMappings('createUpdateDevice') + + // Ensure attributes is not mapped, such as for previous customers who have not updated their mappings. + delete mapping.attributes + + const response = await action('createUpdateDevice', { event, mapping, settings }) + + expect(response).toStrictEqual({ + action: 'add_device', device: { + attributes: {}, token: deviceId, - type: deviceType - } - } - }) - const responses = await testDestination.testAction('createUpdateDevice', { - event, - settings, - useDefaultMappings: true + platform: deviceType, + last_used: dayjs.utc(timestamp).unix() + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - device: { - id: deviceId, - platform: deviceType, - last_used: timestamp - } - }) - }) + it('should include app_version', async () => { + const userId = 'abc123' + const deviceId = 'device_123' + const deviceType = 'ios' + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + userId, + timestamp, + context: { + app: { + version: '1.23' + }, + device: { + token: deviceId, + type: deviceType + } + } + }) + const response = await action('createUpdateDevice', { + event, + settings, + useDefaultMappings: true + }) - it('should not convert last_used to a unix timestamp when convert_timestamp is false', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const deviceId = 'device_123' - const deviceType = 'ios' - const timestamp = dayjs.utc().toISOString() - trackService.put(`/customers/${userId}/devices`).reply(200, {}) - const event = createTestEvent({ - userId, - timestamp, - context: { + expect(response).toEqual({ + action: 'add_device', device: { + attributes: { + app_version: '1.23' + }, token: deviceId, - type: deviceType - } - } - }) - const responses = await testDestination.testAction('createUpdateDevice', { - event, - settings, - mapping: { - convert_timestamp: false - }, - useDefaultMappings: true + platform: deviceType, + last_used: dayjs.utc(timestamp).unix() + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - device: { - id: deviceId, - platform: deviceType, - last_used: timestamp - } - }) - }) + it("should not convert last_used if it's invalid", async () => { + const userId = 'abc123' + const deviceId = 'device_123' + const deviceType = 'ios' + const timestamp = '2018-03-04T12:08:56.235 PDT' + const event = createTestEvent({ + userId, + timestamp, + context: { + device: { + token: deviceId, + type: deviceType + } + } + }) + const response = await action('createUpdateDevice', { + event, + settings, + useDefaultMappings: true + }) - it('should work with the EU account region', async () => { - const trackEUService = nock('https://track-eu.customer.io/api/v1') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const deviceId = 'device_123' - const deviceType = 'ios' - const timestamp = dayjs.utc().toISOString() - trackEUService.put(`/customers/${userId}/devices`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - userId, - timestamp, - context: { + expect(response).toEqual({ + action: 'add_device', device: { + attributes: {}, token: deviceId, - type: deviceType - } - } - }) - const responses = await testDestination.testAction('createUpdateDevice', { - event, - settings, - useDefaultMappings: true + platform: deviceType, + last_used: timestamp + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - device: { - id: deviceId, - platform: deviceType, - last_used: dayjs.utc(timestamp).unix() - } - }) - }) + it('should not convert last_used to a unix timestamp when convert_timestamp is false', async () => { + const userId = 'abc123' + const deviceId = 'device_123' + const deviceType = 'ios' + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + userId, + timestamp, + context: { + device: { + token: deviceId, + type: deviceType + } + } + }) + const response = await action('createUpdateDevice', { + event, + settings, + mapping: { + convert_timestamp: false + }, + useDefaultMappings: true + }) - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const deviceId = 'device_123' - const deviceType = 'ios' - const timestamp = dayjs.utc().toISOString() - trackService.put(`/customers/${userId}/devices`).reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - userId, - timestamp, - context: { + expect(response).toEqual({ + action: 'add_device', device: { + attributes: {}, token: deviceId, - type: deviceType - } - } - }) - const responses = await testDestination.testAction('createUpdateDevice', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - device: { - id: deviceId, - platform: deviceType, - last_used: dayjs.utc(timestamp).unix() - } + platform: deviceType, + last_used: timestamp + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts index 38daf97099..be10d2dd18 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdateObject.test.ts @@ -1,390 +1,597 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' +import { createTestEvent } from '@segment/actions-core' import { Settings } from '../generated-types' import dayjs from '../../../lib/dayjs' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackObjectService = nock('https://track.customer.io') +import { getDefaultMappings, testRunner } from '../test-helper' describe('CustomerIO', () => { describe('createUpdateObject', () => { - it('should work with default mappings when userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'grp123' - const traits = { - name: 'Sales', - industry: 'Technology', - created_at: timestamp, - object_type_id: '1' - } - - const attributes = { - name: 'Sales', - industry: 'Technology', - created_at: dayjs.utc(timestamp).unix() - } - trackObjectService.post(`/api/v2/entity`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - groupId - }) - const responses = await testDestination.testAction('createUpdateObject', { - event, - settings, - useDefaultMappings: true - }) + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when userId is supplied', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + object_type_id: '1', + objectAttributes: { + name: 'Sales', + industry: 'Technology', + created_at: timestamp + } + } - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - attributes: attributes, - created_at: dayjs.utc(timestamp).unix(), - type: 'object', - action: 'identify', - identifiers: { - object_type_id: traits.object_type_id, - object_id: groupId - }, - cio_relationships: [{ identifiers: { id: userId } }] - }) - }) + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + industry: 'Technology', + created_at: dayjs.utc(timestamp).unix() + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) - it('should work with the EU account region', async () => { - const trackEUObjectService = nock('https://track-eu.customer.io') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'grp123' - const traits = { - name: 'Sales', - industry: 'Technology', - created_at: timestamp, - object_type_id: '1' - } - const attributes = { - name: 'Sales', - industry: 'Technology', - created_at: dayjs.utc(timestamp).unix() - } - trackEUObjectService.post(`/api/v2/entity`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - groupId - }) - const responses = await testDestination.testAction('createUpdateObject', { - event, - settings, - useDefaultMappings: true + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: traits.object_type_id, + object_id: groupId + }, + cio_relationships: [{ identifiers: { id: userId } }] + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - attributes: attributes, - created_at: dayjs.utc(timestamp).unix(), - type: 'object', - action: 'identify', - identifiers: { - object_type_id: traits.object_type_id, - object_id: groupId - }, - cio_relationships: [{ identifiers: { id: userId } }] - }) - }) + it('should work with anonymous id when userId is not supplied', async () => { + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + object_type_id: '1', + objectAttributes: { + name: 'Sales', + created_at: timestamp + } + } - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'grp123' - const traits = { - name: 'Sales', - industry: 'Technology', - created_at: timestamp, - object_type_id: '1' - } - const attributes = { - name: 'Sales', - industry: 'Technology', - created_at: dayjs.utc(timestamp).unix() - } - trackObjectService.post(`/api/v2/entity`).reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - groupId - }) - const responses = await testDestination.testAction('createUpdateObject', { - event, - settings, - useDefaultMappings: true - }) + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + created_at: dayjs.utc(timestamp).unix() + } + const event = createTestEvent({ + userId: undefined, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify_anonymous', + identifiers: { + object_type_id: traits.object_type_id, + object_id: groupId + }, + cio_relationships: [{ identifiers: { anonymous_id: anonymousId } }] + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - attributes: attributes, - created_at: dayjs.utc(timestamp).unix(), - type: 'object', - action: 'identify', - identifiers: { - object_type_id: traits.object_type_id, - object_id: groupId - }, - cio_relationships: [{ identifiers: { id: userId } }] - }) - }) - it('should work with anonymous id when userId is not supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'grp123' - const traits = { - name: 'Sales', - created_at: timestamp, - object_type_id: '1' - } - - const attributes = { - name: 'Sales', - created_at: dayjs.utc(timestamp).unix() - } - trackObjectService.post(`/api/v2/entity`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId: undefined, - anonymousId, - timestamp, - traits, - groupId - }) - const responses = await testDestination.testAction('createUpdateObject', { - event, - settings, - useDefaultMappings: true - }) + it('should work with object_type_id given in the traits', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + object_type_id: '2', + objectAttributes: { + name: 'Sales', + created_at: timestamp + } + } - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - attributes: attributes, - created_at: dayjs.utc(timestamp).unix(), - type: 'object', - action: 'identify_anonymous', - identifiers: { - object_type_id: traits.object_type_id, - object_id: groupId - }, - cio_relationships: [{ identifiers: { anonymous_id: anonymousId } }] - }) - }) + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + created_at: dayjs.utc(timestamp).unix() + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) - it('should work with object_type_id given in the traits', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'grp123' - const traits = { - name: 'Sales', - created_at: timestamp, - object_type_id: '2' - } - - const attributes = { - name: 'Sales', - created_at: dayjs.utc(timestamp).unix() - } - trackObjectService.post(`/api/v2/entity`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - groupId - }) - const responses = await testDestination.testAction('createUpdateObject', { - event, - settings, - useDefaultMappings: true + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: '2', + object_id: groupId + }, + cio_relationships: [{ identifiers: { id: userId } }] + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' + it('should work with default object_type_id when object_type_id is not supplied', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + objectAttributes: { + name: 'Sales', + created_at: timestamp + } + } + + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + created_at: dayjs.utc(timestamp).unix() + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: '1', + object_id: groupId + }, + cio_relationships: [{ identifiers: { id: userId } }] + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - attributes: attributes, - created_at: dayjs.utc(timestamp).unix(), - type: 'object', - action: 'identify', - identifiers: { - object_type_id: '2', - object_id: groupId - }, - cio_relationships: [{ identifiers: { id: userId } }] + + it('should work with default object_type_id if traits are not supplied', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const attributes = { + anonymous_id: anonymousId + } + + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + groupId, + traits: undefined + }) + + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: '1', + object_id: groupId + }, + cio_relationships: [{ identifiers: { id: userId } }] + }) }) - }) - it('should work with default object_type_id when object_type_id is not supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'grp123' - const traits = { - name: 'Sales', - created_at: timestamp - } - - const attributes = { - name: 'Sales', - created_at: dayjs.utc(timestamp).unix() - } - trackObjectService.post(`/api/v2/entity`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - groupId + it('should work if userId starts with `cio_`', async () => { + const userId = 'cio_abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const typeId = '1' + const attributes = { + anonymous_id: anonymousId + } + + const event = createTestEvent({ + userId, + anonymousId, + groupId, + traits: { + objectTypeId: typeId + }, + timestamp + }) + + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: '1', + object_id: groupId + }, + cio_relationships: [{ identifiers: { cio_id: 'abc123' } }] + }) }) - const responses = await testDestination.testAction('createUpdateObject', { - event, - settings, - useDefaultMappings: true + + it('should work if userId is an email', async () => { + const userId = 'foo@bar.com' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const typeId = '1' + const attributes = { + anonymous_id: anonymousId + } + + const event = createTestEvent({ + userId, + anonymousId, + groupId, + traits: { + objectTypeId: typeId + }, + timestamp + }) + + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: '1', + object_id: groupId + }, + cio_relationships: [{ identifiers: { email: 'foo@bar.com' } }] + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' + it('should work when no created_at is given', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const typeId = '1' + const traits = { + object_type_id: '1', + objectAttributes: { + name: 'Sales' + } + } + + const attributes = { + anonymous_id: anonymousId, + name: 'Sales' + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: typeId, + object_id: groupId + }, + cio_relationships: [{ identifiers: { id: userId } }] + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - attributes: attributes, - created_at: dayjs.utc(timestamp).unix(), - type: 'object', - action: 'identify', - identifiers: { + + it('should work with anonymous id when userId is not supplied', async () => { + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { object_type_id: '1', - object_id: groupId - }, - cio_relationships: [{ identifiers: { id: userId } }] + objectAttributes: { + name: 'Sales', + createdAt: timestamp + } + } + + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + createdAt: dayjs.utc(timestamp).unix() + } + const event = createTestEvent({ + userId: undefined, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify_anonymous', + identifiers: { + object_type_id: traits.object_type_id, + object_id: groupId + }, + cio_relationships: [{ identifiers: { anonymous_id: anonymousId } }] + }) }) - }) - it('should work when no created_at is given', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'grp123' - const typeId = '1' - const traits = { - name: 'Sales', - object_type_id: '1' - } - - const attributes = { - name: 'Sales' - } - trackObjectService.post(`/api/v2/entity`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - groupId + it('should work with relationship traits', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + object_type_id: '1', + objectAttributes: { + name: 'Sales', + createdAt: timestamp + }, + relationshipAttributes: { + role: 'admin', + prefix: 'Mr.' + } + } + + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + createdAt: dayjs.utc(timestamp).unix() + } + + const relationship = { + identifiers: { id: userId }, + relationship_attributes: { + role: 'admin', + prefix: 'Mr.' + } + } + + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: traits.object_type_id, + object_id: groupId + }, + cio_relationships: [relationship] + }) }) - const responses = await testDestination.testAction('createUpdateObject', { - event, - settings, - useDefaultMappings: true + + it('should work with relationship traits having timestamp attributes', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + object_type_id: '1', + objectAttributes: { + name: 'Sales', + createdAt: timestamp + }, + relationshipAttributes: { + role: 'admin', + createdAt: timestamp + } + } + + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + createdAt: dayjs.utc(timestamp).unix() + } + + const relationship = { + identifiers: { id: userId }, + relationship_attributes: { + role: 'admin', + createdAt: dayjs.utc(timestamp).unix() + } + } + + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: traits.object_type_id, + object_id: groupId + }, + cio_relationships: [relationship] + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' + it('should work with relationship traits and anonymous user', async () => { + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + object_type_id: '1', + objectAttributes: { + name: 'Sales', + createdAt: timestamp + }, + relationshipAttributes: { + role: 'admin', + createdAt: timestamp + } + } + + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + createdAt: dayjs.utc(timestamp).unix() + } + + const relationship = { + identifiers: { anonymous_id: anonymousId }, + relationship_attributes: { + role: 'admin', + createdAt: dayjs.utc(timestamp).unix() + } + } + + const event = createTestEvent({ + userId: undefined, + anonymousId, + timestamp, + traits, + groupId + }) + const response = await action('createUpdateObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify_anonymous', + identifiers: { + object_type_id: traits.object_type_id, + object_id: groupId + }, + cio_relationships: [relationship] + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - attributes: attributes, - type: 'object', - action: 'identify', - identifiers: { - object_type_id: typeId, - object_id: groupId - }, - cio_relationships: [{ identifiers: { id: userId } }] + + it('should work if `relationship_attributes` is unmapped', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'grp123' + const traits = { + object_type_id: '1', + objectAttributes: { + name: 'Sales', + createdAt: timestamp + }, + relationshipAttributes: { + role: 'admin', + prefix: 'Mr.' + } + } + + const attributes = { + anonymous_id: anonymousId, + name: 'Sales', + createdAt: dayjs.utc(timestamp).unix() + } + + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + groupId + }) + + const mapping = getDefaultMappings('createUpdateObject') + + // Ensure event_id is not mapped, such as for previous customers who have not updated their mappings. + delete mapping.relationship_attributes + + const response = await action('createUpdateObject', { event, mapping, settings }) + + expect(response).toEqual({ + attributes: attributes, + type: 'object', + action: 'identify', + identifiers: { + object_type_id: traits.object_type_id, + object_id: groupId + }, + cio_relationships: [ + { + identifiers: { id: userId } + } + ] + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdatePerson.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdatePerson.test.ts index bb7950c5d7..3bc259f5af 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/createUpdatePerson.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/createUpdatePerson.test.ts @@ -1,573 +1,708 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' +import { createTestEvent } from '@segment/actions-core' import { Settings } from '../generated-types' import dayjs from '../../../lib/dayjs' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackDeviceService = nock('https://track.customer.io/api/v1') +import { getDefaultMappings, testRunner } from '../test-helper' describe('CustomerIO', () => { describe('createUpdatePerson', () => { - it('should work with default mappings when userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const traits = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp, - person: { - over18: true, - identification: 'valid', - birthdate + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when userId is supplied', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const traits = { + full_name: 'Test User', + email: 'test@example.com', + created_at: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + } } - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true - }) + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + created_at: dayjs.utc(timestamp).unix(), + email: traits.email, + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - created_at: dayjs.utc(timestamp).unix(), - anonymous_id: anonymousId, - person: { - ...traits.person, - birthdate: dayjs.utc(birthdate).unix() - } - }) - }) - it('should use email as the identifier if userId is not present', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const traits = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp, - person: { - over18: true, - identification: 'valid', - birthdate + it('should use email as the identifier if userId is an email', async () => { + const anonymousId = 'unknown_123' + const userId = 'foo@bar.com' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const traits = { + full_name: 'Test User', + created_at: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + } } - } - trackDeviceService.put(`/customers/${traits.email}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId: null, - anonymousId, - timestamp, - traits - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true - }) + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + created_at: dayjs.utc(timestamp).unix(), + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + identifiers: { + email: userId + }, + type: 'person' + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - created_at: dayjs.utc(timestamp).unix(), - anonymous_id: anonymousId, - person: { - ...traits.person, - birthdate: dayjs.utc(birthdate).unix() + + it('should use email as the identifier if userId is not present', async () => { + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const traits = { + full_name: 'Test User', + email: 'test@example.com', + created_at: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + } } - }) - }) + const event = createTestEvent({ + userId: null, + anonymousId, + timestamp, + traits + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - it('should convert only ISO-8601 strings', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const timestamp = dayjs.utc().toISOString() - const testTimestamps = { - created_at: timestamp, - date01: '25 Mar 2015', - date02: 'Mar 25 2015', - date03: '01/01/2019', - date04: '2019-02-01', - date05: '2007-01-02T18:04:07', - date06: '2006-01-02T18:04:07Z', - date07: '2006-01-02T18:04:07+01:00', - date08: '2006-01-02T15:04:05.007', - date09: '2006-01-02T15:04:05.007Z', - date10: '2006-01-02T15:04:05.007+01:00', - date11: '2018-03-04T12:08:56 PDT', - date12: '2018-03-04T12:08:56.235 PDT', - date13: '15/MAR/18', - date14: '11-Jan-18', - date15: '2006-01-02T15:04:05-0800', - date16: '2006-01-02T15:04:05.07-0800', - date17: '2006-01-02T15:04:05.007-0800' - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - timestamp, - traits: testTimestamps - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + email: traits.email, + created_at: dayjs.utc(timestamp).unix(), + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + identifiers: { + email: traits.email + }, + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - created_at: dayjs(timestamp).unix(), - date01: testTimestamps.date01, - date02: testTimestamps.date02, - date03: testTimestamps.date03, - date04: dayjs(testTimestamps.date04).unix(), - date05: dayjs(testTimestamps.date05).unix(), - date06: dayjs(testTimestamps.date06).unix(), - date07: dayjs(testTimestamps.date07).unix(), - date08: dayjs(testTimestamps.date08).unix(), - date09: dayjs(testTimestamps.date09).unix(), - date10: dayjs(testTimestamps.date10).unix(), - date11: testTimestamps.date11, - date12: testTimestamps.date12, - date13: testTimestamps.date13, - date14: testTimestamps.date14, - date15: dayjs(testTimestamps.date15).unix(), - date16: dayjs(testTimestamps.date16).unix(), - date17: dayjs(testTimestamps.date17).unix() - }) - }) + it('should add anonymous_id to identifiers if supplied', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const traits = { + full_name: 'Test User', + email: 'test@example.com', + created_at: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + } + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - it("should not convert created_at if it's invalid", async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const timestamp = dayjs.utc().toISOString() - const testTimestamps = { - created_at: '2018-03-04T12:08:56.235 PDT' - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - timestamp, - traits: testTimestamps - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + created_at: dayjs.utc(timestamp).unix(), + email: traits.email, + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - created_at: testTimestamps.created_at - }) - }) + it('should convert only ISO-8601 strings', async () => { + const userId = 'abc123' + const timestamp = dayjs.utc().toISOString() + const testTimestamps = { + created_at: timestamp, + date01: '25 Mar 2015', + date02: 'Mar 25 2015', + date03: '01/01/2019', + date04: '2019-02-01', + date05: '2007-01-02T18:04:07', + date06: '2006-01-02T18:04:07Z', + date07: '2006-01-02T18:04:07+01:00', + date08: '2006-01-02T15:04:05.007', + date09: '2006-01-02T15:04:05.007Z', + date10: '2006-01-02T15:04:05.007+01:00', + date11: '2018-03-04T12:08:56 PDT', + date12: '2018-03-04T12:08:56.235 PDT', + date13: '15/MAR/18', + date14: '11-Jan-18', + date15: '2006-01-02T15:04:05-0800', + date16: '2006-01-02T15:04:05.07-0800', + date17: '2006-01-02T15:04:05.007-0800' + } + const event = createTestEvent({ + userId, + timestamp, + traits: testTimestamps + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - it("should not convert created_at if it's already a number", async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const timestamp = dayjs.utc().toISOString() - const testTimestamps = { - created_at: dayjs.utc(timestamp).unix().toString() - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - timestamp, - traits: testTimestamps - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true + expect(response).toEqual({ + action: 'identify', + attributes: { + anonymous_id: event.anonymousId, + created_at: dayjs(timestamp).unix(), + date01: testTimestamps.date01, + date02: testTimestamps.date02, + date03: testTimestamps.date03, + date04: dayjs(testTimestamps.date04).unix(), + date05: dayjs(testTimestamps.date05).unix(), + date06: dayjs(testTimestamps.date06).unix(), + date07: dayjs(testTimestamps.date07).unix(), + date08: dayjs(testTimestamps.date08).unix(), + date09: dayjs(testTimestamps.date09).unix(), + date10: dayjs(testTimestamps.date10).unix(), + date11: testTimestamps.date11, + date12: testTimestamps.date12, + date13: testTimestamps.date13, + date14: testTimestamps.date14, + date15: dayjs(testTimestamps.date15).unix(), + date16: dayjs(testTimestamps.date16).unix(), + date17: dayjs(testTimestamps.date17).unix() + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - created_at: testTimestamps.created_at + it("should not convert created_at if it's invalid", async () => { + const userId = 'abc123' + const timestamp = dayjs.utc().toISOString() + const testTimestamps = { + created_at: '2018-03-04T12:08:56.235 PDT' + } + const event = createTestEvent({ + userId, + timestamp, + traits: testTimestamps + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'identify', + attributes: { + anonymous_id: event.anonymousId, + created_at: testTimestamps.created_at + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - }) - it('should not convert attributes to unix timestamps when convert_timestamp is false', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const traits = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp, - person: { - over18: true, - identification: 'valid', - birthdate + it("should not convert created_at if it's already a number", async () => { + const userId = 'abc123' + const timestamp = dayjs.utc().toISOString() + const testTimestamps = { + created_at: dayjs.utc(timestamp).unix().toString() } - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - mapping: { - convert_timestamp: false - }, - useDefaultMappings: true - }) + const event = createTestEvent({ + userId, + timestamp, + traits: testTimestamps + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - created_at: timestamp, - anonymous_id: anonymousId + expect(response).toEqual({ + action: 'identify', + attributes: { + anonymous_id: event.anonymousId, + created_at: testTimestamps.created_at + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - }) - it("should not add created_at if it's not a trait", async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const traits = { - full_name: 'Test User', - email: 'test@example.com' - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - mapping: { - convert_timestamp: false - }, - useDefaultMappings: true - }) + it('should not convert attributes to unix timestamps when convert_timestamp is false', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const traits = { + full_name: 'Test User', + email: 'test@example.com', + created_at: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + } + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits + }) + const response = await action('createUpdatePerson', { + event, + settings, + mapping: { + convert_timestamp: false + }, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - anonymous_id: anonymousId + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + email: traits.email, + created_at: timestamp + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - }) - it('should work with the EU account region', async () => { - const trackEUDeviceService = nock('https://track-eu.customer.io/api/v1') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const traits = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp - } - trackEUDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true - }) + it("should not add created_at if it's not a trait", async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const traits = { + full_name: 'Test User', + email: 'test@example.com' + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits + }) + const response = await action('createUpdatePerson', { + event, + settings, + mapping: { + convert_timestamp: false + }, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - created_at: dayjs.utc(timestamp).unix(), - anonymous_id: anonymousId + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + email: traits.email + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - }) - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const traits = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true - }) + it('should work with default mappings when userId and groupId are supplied', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const groupId = 'g12345' + const traits = { + full_name: 'Test User', + email: 'test@example.com', + created_at: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + } + } + const context = { + groupId: groupId + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + context + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + email: traits.email, + created_at: dayjs.utc(timestamp).unix(), + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + cio_relationships: [ + { + identifiers: { object_type_id: '1', object_id: groupId } + } + ], + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - created_at: dayjs.utc(timestamp).unix(), - anonymous_id: anonymousId - }) - }) - it('should work with default mappings when userId and groupId are supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const groupId = 'g12345' - const traits = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp, - person: { - over18: true, - identification: 'valid', - birthdate + it('should work with object_type_id from traits when given', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const groupId = 'g12345' + const traits: { + full_name: string + email: string + created_at: string + object_type_id?: string + } = { + full_name: 'Test User', + email: 'test@example.com', + created_at: timestamp, + object_type_id: '2' } - } - const context = { - groupId: groupId - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - context - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true + const context = { + groupId: groupId + } + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + context + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) + + delete traits.object_type_id + expect(response).toEqual({ + action: 'identify', + attributes: { + ...traits, + anonymous_id: anonymousId, + email: traits.email, + created_at: dayjs.utc(timestamp).unix() + }, + identifiers: { + id: userId + }, + cio_relationships: [ + { + identifiers: { object_type_id: '2', object_id: groupId } + } + ], + type: 'person' + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' + it('should work if created_at is not given', async () => { + const userId = 'abc123' + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + userId, + timestamp, + traits: { + created_at: '' + } + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'identify', + attributes: { + anonymous_id: event.anonymousId + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - created_at: dayjs.utc(timestamp).unix(), - anonymous_id: anonymousId, - person: { - ...traits.person, - birthdate: dayjs.utc(birthdate).unix() - }, - cio_relationships: { - action: 'add_relationships', - relationships: [{ identifiers: { object_type_id: '1', object_id: groupId } }] + + it('should work `traits.createdAt`', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const traits = { + full_name: 'Test User', + email: 'test@example.com', + createdAt: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + } } - }) - }) + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) - it('should work with object_type_id from traits when given', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const groupId = 'g12345' - const traits: { - full_name: string - email: string - created_at: string - object_type_id?: string - } = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp, - object_type_id: '2' - } - const context = { - groupId: groupId - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits, - context - }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - useDefaultMappings: true + expect(response).toEqual({ + action: 'identify', + attributes: { + anonymous_id: anonymousId, + created_at: dayjs.utc(timestamp).unix(), + email: traits.email, + full_name: traits.full_name, + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + identifiers: { + id: userId + }, + type: 'person' + }) }) - delete traits.object_type_id - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - ...traits, - email: traits.email, - created_at: dayjs.utc(timestamp).unix(), - anonymous_id: anonymousId, - cio_relationships: { - action: 'add_relationships', - relationships: [{ identifiers: { object_type_id: '2', object_id: groupId } }] + it('should work with relationship traits', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const groupId = 'g12345' + const relationshipAttributes = { + role: 'admin', + prefix: 'Mr.' + } + const traits = { + full_name: 'Test User', + email: 'test@example.com', + createdAt: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + }, + relationshipAttributes } - }) - }) - it('should success with mapping of preset and `identify` call', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const anonymousId = 'unknown_123' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const traits = { - full_name: 'Test User', - email: 'test@example.com', - created_at: timestamp, - person: { - over18: true, - identification: 'valid', - birthdate + const context = { + groupId: groupId } - } - trackDeviceService.put(`/customers/${userId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - anonymousId, - timestamp, - traits + + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + context + }) + const response = await action('createUpdatePerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'identify', + attributes: { + anonymous_id: anonymousId, + created_at: dayjs.utc(timestamp).unix(), + email: traits.email, + full_name: traits.full_name, + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + cio_relationships: [ + { + identifiers: { object_type_id: '1', object_id: groupId }, + relationship_attributes: relationshipAttributes + } + ], + identifiers: { + id: userId + }, + type: 'person' + }) }) - const responses = await testDestination.testAction('createUpdatePerson', { - event, - settings, - // Using the mapping of presets with event type 'track' - mapping: { - custom_attributes: { - '@path': '$.traits' + it('should work if `relationship_attributes` is unmapped', async () => { + const userId = 'abc123' + const anonymousId = 'unknown_123' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const groupId = 'g12345' + const traits = { + full_name: 'Test User', + email: 'test@example.com', + createdAt: timestamp, + person: { + over18: true, + identification: 'valid', + birthdate + }, + relationshipAttributes: { + role: 'admin', + prefix: 'Mr.' } - }, - useDefaultMappings: true - }) + } + + const context = { + groupId: groupId + } + + const event = createTestEvent({ + userId, + anonymousId, + timestamp, + traits, + context + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) + const mapping = getDefaultMappings('createUpdatePerson') + + // Ensure event_id is not mapped, such as for previous customers who have not updated their mappings. + delete mapping.relationship_attributes + + const response = await action('createUpdatePerson', { event, mapping, settings }) + + expect(response).toStrictEqual({ + action: 'identify', + attributes: { + anonymous_id: anonymousId, + created_at: dayjs.utc(timestamp).unix(), + email: traits.email, + full_name: traits.full_name, + person: { + ...traits.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + cio_relationships: [ + { + identifiers: { object_type_id: '1', object_id: groupId } + } + ], + identifiers: { + id: userId + }, + type: 'person' + }) + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/delete.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/delete.test.ts deleted file mode 100644 index 1e41cac701..0000000000 --- a/packages/destination-actions/src/destinations/customerio/__tests__/delete.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' -import { Settings } from '../generated-types' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackService = nock('https://track.customer.io/api/v1') - -describe('CustomerIO', () => { - describe('deleteDevice', () => { - it('should work with default mappings when userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const deviceId = 'device_123' - trackService.delete(`/customers/${userId}/devices/${deviceId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - context: { - device: { - token: deviceId - } - } - }) - const responses = await testDestination.testAction('deleteDevice', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toBeUndefined() - }) - - it('should work with the EU account region', async () => { - const trackEUService = nock('https://track-eu.customer.io/api/v1') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const deviceId = 'device_123' - trackEUService.delete(`/customers/${userId}/devices/${deviceId}`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - userId, - context: { - device: { - token: deviceId - } - } - }) - const responses = await testDestination.testAction('deleteDevice', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toBeUndefined() - }) - - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const deviceId = 'device_123' - trackService - .delete(`/customers/${userId}/devices/${deviceId}`) - .reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - userId, - context: { - device: { - token: deviceId - } - } - }) - const responses = await testDestination.testAction('deleteDevice', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toBeUndefined() - }) - }) -}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/deleteDevice.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/deleteDevice.test.ts new file mode 100644 index 0000000000..a412b2cf91 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/deleteDevice.test.ts @@ -0,0 +1,38 @@ +import { createTestEvent } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { testRunner } from '../test-helper' + +describe('CustomerIO', () => { + describe('deleteDevice', () => { + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when userId is supplied', async () => { + const userId = 'abc123' + const deviceId = 'device_123' + const event = createTestEvent({ + userId, + context: { + device: { + token: deviceId + } + } + }) + const response = await action('deleteDevice', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'delete_device', + device: { + token: deviceId + }, + identifiers: { + id: userId + }, + type: 'person' + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/deleteDevice.ts b/packages/destination-actions/src/destinations/customerio/__tests__/deleteDevice.ts deleted file mode 100644 index 1e41cac701..0000000000 --- a/packages/destination-actions/src/destinations/customerio/__tests__/deleteDevice.ts +++ /dev/null @@ -1,113 +0,0 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' -import { Settings } from '../generated-types' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackService = nock('https://track.customer.io/api/v1') - -describe('CustomerIO', () => { - describe('deleteDevice', () => { - it('should work with default mappings when userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const deviceId = 'device_123' - trackService.delete(`/customers/${userId}/devices/${deviceId}`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - context: { - device: { - token: deviceId - } - } - }) - const responses = await testDestination.testAction('deleteDevice', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toBeUndefined() - }) - - it('should work with the EU account region', async () => { - const trackEUService = nock('https://track-eu.customer.io/api/v1') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const deviceId = 'device_123' - trackEUService.delete(`/customers/${userId}/devices/${deviceId}`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - userId, - context: { - device: { - token: deviceId - } - } - }) - const responses = await testDestination.testAction('deleteDevice', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toBeUndefined() - }) - - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const deviceId = 'device_123' - trackService - .delete(`/customers/${userId}/devices/${deviceId}`) - .reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - userId, - context: { - device: { - token: deviceId - } - } - }) - const responses = await testDestination.testAction('deleteDevice', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toBeUndefined() - }) - }) -}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/deleteObject.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/deleteObject.test.ts new file mode 100644 index 0000000000..73a2762dab --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/deleteObject.test.ts @@ -0,0 +1,36 @@ +import { createTestEvent } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { testRunner } from '../test-helper' + +describe('CustomerIO', () => { + describe('deleteObject', () => { + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when groupId is supplied', async () => { + const groupId = 'group_123' + const objectTypeId = 'type_123' + const event = createTestEvent({ + context: { + groupId + }, + properties: { + objectTypeId + } + }) + const response = await action('deleteObject', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'delete', + identifiers: { + object_id: groupId, + object_type_id: objectTypeId + }, + type: 'object' + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/deletePerson.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/deletePerson.test.ts new file mode 100644 index 0000000000..126f4d486e --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/deletePerson.test.ts @@ -0,0 +1,49 @@ +import { createTestEvent } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { testRunner } from '../test-helper' + +describe('CustomerIO', () => { + describe('deletePerson', () => { + testRunner((settings: Settings, action: Function) => { + it('should work with user id', async () => { + const userId = 'user_123' + const event = createTestEvent({ + userId: userId + }) + const response = await action('deletePerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'delete', + identifiers: { + id: userId + }, + type: 'person' + }) + }) + + it('should work with email', async () => { + const email = 'foo@bar.com' + const event = createTestEvent({ + userId: email + }) + const response = await action('deletePerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'delete', + identifiers: { + email + }, + type: 'person' + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/deleteRelationship.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/deleteRelationship.test.ts new file mode 100644 index 0000000000..8e9562d610 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/deleteRelationship.test.ts @@ -0,0 +1,220 @@ +import { createTestEvent } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { testRunner } from '../test-helper' +import { resolveIdentifiers } from '../utils' + +describe('CustomerIO', () => { + describe('deleteRelationship', () => { + describe('when `groupId` and `objectTypeId` are present', () => { + testRunner((settings: Settings, action: Function) => { + it('should set type to `object`', async () => { + const objectId = 'object_id123' + const objectTypeId = 'object_type_id123' + const event = createTestEvent({ + context: { + groupId: objectId + }, + properties: { + objectTypeId: objectTypeId + } + }) + + const response = await action('deleteRelationship', { + event, + settings, + useDefaultMappings: true + }) + + const expectedIdentifiers = resolveIdentifiers({ + anonymous_id: event.anonymousId, + email: event.userId, + person_id: event.userId + }) + const expectedAttributes = (() => { + if (expectedIdentifiers && 'anonymous_id' in expectedIdentifiers) { + return {} + } + + return { anonymous_id: event.anonymousId } + })() + + expect(response).toEqual({ + action: 'delete_relationships', + attributes: expectedAttributes, + cio_relationships: [ + { + identifiers: expectedIdentifiers + } + ], + identifiers: { + object_id: objectId, + object_type_id: objectTypeId + }, + type: 'object' + }) + }) + + it('should add cio_relationships with `object_id` and `object_type_id', async () => { + const objectId = 'object_id123' + const objectTypeId = 'object_type_id123' + const event = createTestEvent({ + context: { + groupId: objectId + }, + properties: { + objectTypeId + } + }) + + const response = await action('deleteRelationship', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toMatchObject({ + action: 'delete_relationships', + cio_relationships: [ + { + identifiers: resolveIdentifiers({ + anonymous_id: event.anonymousId, + email: event.userId, + person_id: event.userId + }) + } + ] + }) + }) + }) + }) + + testRunner((settings: Settings, action: Function) => { + it('should throw an error if object_id is missing', async () => { + const event = createTestEvent({ groupId: null, traits: { objectTypeId: null } }) + + try { + await action('deleteRelationship', { + event, + settings, + useDefaultMappings: true + }) + + throw new Error('Expected an error to be thrown') + } catch (e: any) { + expect(e.message).toEqual(`The root value is missing the required field 'object_id'.`) + } + }) + + it('should work if `userId` is an id', async () => { + const event = createTestEvent({ + context: { + groupId: 'object_id123' + }, + properties: { + objectTypeId: 'object_type_id123' + }, + userId: '123' + }) + + const response = await action('deleteRelationship', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'delete_relationships', + attributes: { + anonymous_id: event.anonymousId + }, + cio_relationships: [ + { + identifiers: { + id: event.userId + } + } + ], + identifiers: { + object_id: event.context?.groupId, + object_type_id: event.properties?.objectTypeId + }, + type: 'object' + }) + }) + + it('should work if `userId` is an email', async () => { + const event = createTestEvent({ + context: { + groupId: 'object_id123' + }, + properties: { + objectTypeId: 'object_type_id123' + }, + userId: 'foo@bar.com' + }) + + const response = await action('deleteRelationship', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'delete_relationships', + attributes: { + anonymous_id: event.anonymousId + }, + cio_relationships: [ + { + identifiers: { + email: event.userId + } + } + ], + identifiers: { + object_id: event.context?.groupId, + object_type_id: event.properties?.objectTypeId + }, + type: 'object' + }) + }) + + it('should work if `userId` is a `cio_` identifier', async () => { + const event = createTestEvent({ + context: { + groupId: 'object_id123' + }, + properties: { + objectTypeId: 'object_type_id123' + }, + userId: 'cio_456' + }) + + const response = await action('deleteRelationship', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'delete_relationships', + attributes: { + anonymous_id: event.anonymousId + }, + cio_relationships: [ + { + identifiers: { + cio_id: '456' + } + } + ], + identifiers: { + object_id: event.context?.groupId, + object_type_id: event.properties?.objectTypeId + }, + type: 'object' + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/mergePeople.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/mergePeople.test.ts new file mode 100644 index 0000000000..fc1aacc4c1 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/mergePeople.test.ts @@ -0,0 +1,159 @@ +import { createTestEvent } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { testRunner } from '../test-helper' + +describe('CustomerIO', () => { + describe('mergePeople', () => { + testRunner((settings: Settings, action: Function) => { + it('should throw an error if `userId` or `previousId` are missing', async () => { + const event = createTestEvent({ + userId: null, + previousId: null + }) + + try { + await action('mergePeople', { + event, + settings, + useDefaultMappings: true + }) + + throw new Error('Expected an error to be thrown') + } catch (e: any) { + expect(e.message).toEqual( + `The root value is missing the required field 'primary'. The root value is missing the required field 'secondary'.` + ) + } + }) + + it('should not return `identifiers`', async () => { + const event = createTestEvent({ + userId: 'abc123', + previousId: 'def456' + }) + const response = await action('mergePeople', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).not.toHaveProperty('identifiers') + }) + + it('should work if `userId` and `previousId` are provided', async () => { + const event = createTestEvent({ + userId: 'abc123', + previousId: 'def456' + }) + const response = await action('mergePeople', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'merge', + type: 'person', + primary: { + id: event.userId + }, + secondary: { + id: event.previousId + } + }) + }) + + it('should work if `userId` is an email', async () => { + const event = createTestEvent({ + userId: 'foo@bar.com', + previousId: 'def456' + }) + const response = await action('mergePeople', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'merge', + type: 'person', + primary: { + email: event.userId + }, + secondary: { + id: event.previousId + } + }) + }) + + it('should work if `userId` is a `cio_` identifier', async () => { + const event = createTestEvent({ + userId: 'cio_123456', + previousId: 'def456' + }) + const response = await action('mergePeople', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'merge', + type: 'person', + primary: { + cio_id: '123456' + }, + secondary: { + id: event.previousId + } + }) + }) + + it('should work if `previousId` is an email', async () => { + const event = createTestEvent({ + userId: 'id123', + previousId: 'foo@bar.com' + }) + const response = await action('mergePeople', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'merge', + type: 'person', + primary: { + id: event.userId + }, + secondary: { + email: event.previousId + } + }) + }) + + it('should work if `previousId` is a `cio_` identifier', async () => { + const event = createTestEvent({ + userId: 'id123', + previousId: 'cio_123456' + }) + const response = await action('mergePeople', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'merge', + type: 'person', + primary: { + id: event.userId + }, + secondary: { + cio_id: '123456' + } + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/reportDeliveryEvent.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/reportDeliveryEvent.test.ts new file mode 100644 index 0000000000..feb4726475 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/reportDeliveryEvent.test.ts @@ -0,0 +1,98 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import CustomerIO from '../index' +import dayjs from '../../../lib/dayjs' +import { AccountRegion } from '../utils' +import { nockTrackInternalEndpoint } from '../test-helper' + +const trackService = nockTrackInternalEndpoint(AccountRegion.US) + +describe('CustomerIO', () => { + describe('reportDeliveryEvent', () => { + const testDestination = createTestIntegration(CustomerIO) + + type testCase = { + name: string + properties: { [key: string]: unknown } + expected: { [key: string]: unknown } + } + + const testCases: testCase[] = [ + { + name: 'should work with just delivery id and metric', + properties: { + deliveryId: 'delivery_123', + metric: 'delivered' + }, + expected: { + delivery_id: 'delivery_123', + metric: 'delivered' + } + }, + { + name: 'should nest in-app metadata fields', + properties: { + deliveryId: 'in-app-delivery', + metric: 'clicked', + actionName: 'score', + actionValue: '3' + }, + expected: { + delivery_id: 'in-app-delivery', + metric: 'clicked', + metadata: { + action_name: 'score', + action_value: '3' + } + } + }, + { + name: 'should ignore extra fields not part of the mappings', + properties: { + deliveryId: 'delivery_123', + metric: 'bounced', + recipient: 'test@example.com', + reason: 'mailbox not exists', + foo: 'bar', + test: 123 + }, + expected: { + delivery_id: 'delivery_123', + metric: 'bounced', + recipient: 'test@example.com', + reason: 'mailbox not exists' + } + } + ] + + testCases.forEach((testCase) => { + it(testCase.name, async () => { + trackService.post(`/api/v1/metrics`).reply(200, {}, { 'x-customerio-region': 'US' }) + + const now = dayjs.utc() + const event = createTestEvent({ + anonymousId: 'anon_123', + timestamp: now.toISOString(), + type: 'track', + event: 'Report Delivery Event', + properties: testCase.properties + }) + const responses = await testDestination.testAction('reportDeliveryEvent', { + event, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].headers.toJSON()).toMatchObject({ + 'x-customerio-region': 'US', + 'content-type': 'application/json' + }) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.json).toEqual({ + ...testCase.expected, + timestamp: now.unix() + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/suppressPerson.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/suppressPerson.test.ts new file mode 100644 index 0000000000..5ed1c3af92 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/suppressPerson.test.ts @@ -0,0 +1,49 @@ +import { createTestEvent } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { testRunner } from '../test-helper' + +describe('CustomerIO', () => { + describe('suppressPerson', () => { + testRunner((settings: Settings, action: Function) => { + it('should work with user id', async () => { + const userId = 'user_123' + const event = createTestEvent({ + userId: userId + }) + const response = await action('suppressPerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'suppress', + identifiers: { + id: userId + }, + type: 'person' + }) + }) + + it('should work with email', async () => { + const email = 'foo@bar.com' + const event = createTestEvent({ + userId: email + }) + const response = await action('suppressPerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'suppress', + identifiers: { + email + }, + type: 'person' + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/trackEvent.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/trackEvent.test.ts index 0239306e28..19097f58fc 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/trackEvent.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/trackEvent.test.ts @@ -1,351 +1,277 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' +import { createTestEvent } from '@segment/actions-core' import { Settings } from '../generated-types' import dayjs from '../../../lib/dayjs' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackEventService = nock('https://track.customer.io/api/v1') +import { getDefaultMappings, testRunner } from '../test-helper' describe('CustomerIO', () => { describe('trackEvent', () => { - it('should work with default mappings when a userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const name = 'testEvent' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const data = { - property1: 'this is a test', - person: { - over18: true, - identification: 'valid', - birthdate - } - } - trackEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - event: name, - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackEvent', { event, settings, useDefaultMappings: true }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name, - timestamp: dayjs.utc(timestamp).unix(), - data: { - ...data, + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when a userId is supplied', async () => { + const userId = 'abc123' + const name = 'testEvent' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const attributes = { + property1: 'this is a test', person: { - ...data.person, - birthdate: dayjs.utc(birthdate).unix() + over18: true, + identification: 'valid', + birthdate } } - }) - }) - - it('should work with default mappings when a anonymousId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const anonymousId = 'anonymous123' - const name = 'test event' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test' - } - trackEventService.post(`/events`).reply(200, {}) - const event = createTestEvent({ - event: name, - anonymousId, - properties: data, - userId: undefined, - timestamp - }) - - const responses = await testDestination.testAction('trackEvent', { event, settings, useDefaultMappings: true }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name, - data, - anonymous_id: anonymousId, - timestamp: dayjs.utc(timestamp).unix() - }) - }) + const event = createTestEvent({ + event: name, + userId, + properties: attributes, + timestamp + }) + const response = await action('trackEvent', { event, settings, useDefaultMappings: true }) - it('should error when the name field is not supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test' - } - trackEventService.post(`/events`).reply(200, {}) - const event = createTestEvent({ - event: undefined, - properties: data, - anonymousId: undefined, - userId: undefined, - timestamp + expect(response).toEqual({ + action: 'event', + id: event.messageId, + identifiers: { + id: userId + }, + name, + timestamp: dayjs.utc(timestamp).unix(), + attributes: { + ...attributes, + anonymous_id: event.anonymousId, + person: { + ...attributes.person, + birthdate: dayjs.utc(birthdate).unix() + } + }, + type: 'person' + }) }) - try { - await testDestination.testAction('trackEvent', { event, settings, useDefaultMappings: true }) - fail('This test should have thrown an error') - } catch (e) { - expect(e.message).toBe("The root value is missing the required field 'name'.") - } - }) + it('should work with default mappings when a anonymousId is supplied', async () => { + const anonymousId = 'anonymous123' + const name = 'test event' + const timestamp = dayjs.utc().toISOString() + const attributes = { + property1: 'this is a test' + } + const event = createTestEvent({ + event: name, + anonymousId, + properties: attributes, + userId: undefined, + timestamp + }) - it("should not convert timestamp if it's invalid", async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const name = 'testEvent' - const timestamp = '2018-03-04T12:08:56.235 PDT' - const data = { - property1: 'this is a test' - } - trackEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - event: name, - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackEvent', { - event, - settings, - useDefaultMappings: true - }) + const response = await action('trackEvent', { event, settings, useDefaultMappings: true }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - name, - data, - timestamp + expect(response).toEqual({ + action: 'event', + id: event.messageId, + name, + attributes, + identifiers: { + anonymous_id: anonymousId + }, + timestamp: dayjs.utc(timestamp).unix(), + type: 'person' + }) }) - }) - it('should not convert dates to unix timestamps when convert_timestamp is false', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const name = 'testEvent' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const data = { - property1: 'this is a test', - person: { - over18: true, - identification: 'valid', - birthdate + it('should error when the name field is not supplied', async () => { + const timestamp = dayjs.utc().toISOString() + const attributes = { + property1: 'this is a test' } - } - trackEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - event: name, - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackEvent', { - event, - settings, - useDefaultMappings: true, - mapping: { - convert_timestamp: false - } - }) + const event = createTestEvent({ + event: undefined, + properties: attributes, + anonymousId: undefined, + userId: undefined, + timestamp + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name, - data, - timestamp - }) - }) - - it('should work with the EU account region', async () => { - const trackEUEventService = nock('https://track-eu.customer.io/api/v1') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const name = 'testEvent' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test' - } - trackEUEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - event: name, - userId, - timestamp, - properties: data - }) - const responses = await testDestination.testAction('trackEvent', { event, settings, useDefaultMappings: true }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name, - data, - timestamp: dayjs.utc(timestamp).unix() + try { + await action('trackEvent', { event, settings, useDefaultMappings: true }) + fail('This test should have thrown an error') + } catch (e) { + expect((e as Error).message).toBe("The root value is missing the required field 'name'.") + } }) - }) - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const name = 'testEvent' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test' - } - trackEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - event: name, - userId, - timestamp, - properties: data - }) - const responses = await testDestination.testAction('trackEvent', { event, settings, useDefaultMappings: true }) + it("should not convert timestamp if it's invalid", async () => { + const userId = 'abc123' + const name = 'testEvent' + const timestamp = '2018-03-04T12:08:56.235 PDT' + const attributes = { + property1: 'this is a test' + } + const event = createTestEvent({ + event: name, + userId, + properties: attributes, + timestamp + }) + const response = await action('trackEvent', { + event, + settings, + useDefaultMappings: true + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name, - data, - timestamp: dayjs.utc(timestamp).unix() + expect(response).toEqual({ + action: 'event', + id: event.messageId, + identifiers: { + id: userId + }, + name, + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + timestamp, + type: 'person' + }) }) - }) - it('should map messageId to id in the payload', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const messageId = 'message123' - const userId = 'abc123' - const name = 'testEvent' - const data = { - property1: 'this is a test' - } - trackEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - event: name, - userId, - properties: data, - messageId - }) - const responses = await testDestination.testAction('trackEvent', { event, settings, useDefaultMappings: true }) + it('should not convert dates to unix timestamps when convert_timestamp is false', async () => { + const userId = 'abc123' + const name = 'testEvent' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const attributes = { + property1: 'this is a test', + person: { + over18: true, + identification: 'valid', + birthdate + } + } + const event = createTestEvent({ + event: name, + userId, + properties: attributes, + timestamp + }) + const response = await action('trackEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + convert_timestamp: false + } + }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' + expect(response).toEqual({ + action: 'event', + id: event.messageId, + identifiers: { + id: userId + }, + name, + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + timestamp, + type: 'person' + }) }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - id: messageId, - name, - data - }) - }) - it('should success with mapping of preset and Entity Added event(presets) ', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const name = 'Entity Added' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const data = { - property1: 'this is a test', - person: { - over18: true, - identification: 'valid', - birthdate + it('should map messageId to id in the payload', async () => { + const userId = 'abc123' + const name = 'testEvent' + const attributes = { + property1: 'this is a test' } - } - - trackEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + event: name, + userId, + properties: attributes, + timestamp + }) + const response = await action('trackEvent', { event, settings, useDefaultMappings: true }) - const event = createTestEvent({ - event: name, - userId, - properties: data, - timestamp + expect(response).toEqual({ + action: 'event', + id: event.messageId, + identifiers: { + id: userId + }, + name, + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + timestamp: dayjs.utc(timestamp).unix(), + type: 'person' + }) }) - const responses = await testDestination.testAction('trackEvent', { - event, - settings, - // Using the mapping of presets with event type 'track' - mapping: { + it.only('should succeed with mapping of preset and Journeys Step Transition event(presets)', async () => { + const userId = 'abc123' + const name = 'testEvent' + const data = { + journey_metadata: { + journey_id: 'test-journey-id', + journey_name: 'test-journey-name', + step_id: 'test-step-id', + step_name: 'test-step-name' + }, + journey_context: { + appointment_booked: { + type: 'track', + event: 'Appointment Booked', + timestamp: '2021-09-01T00:00:00.000Z', + properties: { + appointment_id: 'test-appointment-id', + appointment_date: '2021-09-01T00:00:00.000Z', + appointment_type: 'test-appointment-type' + } + }, + appointment_confirmed: { + type: 'track', + event: 'Appointment Confirmed', + timestamp: '2021-09-01T00:00:00.000Z', + properties: { + appointment_id: 'test-appointment-id', + appointment_date: '2021-09-01T00:00:00.000Z', + appointment_type: 'test-appointment-type' + } + } + } + } + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + event: name, + userId, + properties: data, + timestamp + }) + const mapping = { + ...getDefaultMappings('trackEvent'), + convert_timestamp: false, data: { '@path': '$.properties' } - }, - useDefaultMappings: true - }) + } + const response = await action('trackEvent', { event, mapping, settings }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) + expect(response).toEqual({ + action: 'event', + id: event.messageId, + identifiers: { + id: userId + }, + name, + attributes: { + ...data, + anonymous_id: event.anonymousId + }, + timestamp, + type: 'person' + }) + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/trackPageView.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/trackPageView.test.ts index fc780db247..1099772571 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/trackPageView.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/trackPageView.test.ts @@ -1,274 +1,209 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' +import { createTestEvent } from '@segment/actions-core' import { Settings } from '../generated-types' import dayjs from '../../../lib/dayjs' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackPageViewService = nock('https://track.customer.io/api/v1') -const type = 'page' +import { getDefaultMappings, testRunner } from '../test-helper' describe('CustomerIO', () => { describe('trackPageView', () => { - it('should work with default mappings when a userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const url = 'https://example.com/page-one' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const data = { - property1: 'this is a test', - url, - person: { - over18: true, - identification: 'valid', - birthdate - } - } - trackPageViewService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackPageView', { event, settings, useDefaultMappings: true }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: url, - type, - timestamp: dayjs.utc(timestamp).unix(), - data: { - ...data, + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when a userId is supplied', async () => { + const userId = 'abc123' + const url = 'https://example.com/page-one' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const attributes = { + property1: 'this is a test', + url, person: { - ...data.person, - birthdate: dayjs.utc(birthdate).unix() + over18: true, + identification: 'valid', + birthdate } } - }) - }) - - it('should work with default mappings when a anonymousId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const anonymousId = 'anonymous123' - const url = 'https://example.com/page-one' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test', - url - } - trackPageViewService.post(`/events`).reply(200, {}) - const event = createTestEvent({ - anonymousId, - properties: data, - userId: undefined, - timestamp - }) - - const responses = await testDestination.testAction('trackPageView', { event, settings, useDefaultMappings: true }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: url, - type, - data, - anonymous_id: anonymousId, - timestamp: dayjs.utc(timestamp).unix() - }) - }) - - it('should error when the url field is not supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const timestamp = dayjs.utc().toISOString() - trackPageViewService.post(`/events`).reply(200, {}) - const event = createTestEvent({ - anonymousId: undefined, - userId: undefined, - timestamp - }) - - try { - await testDestination.testAction('trackPageView', { event, settings, useDefaultMappings: true }) - fail('This test should have thrown an error') - } catch (e) { - expect(e.message).toBe("The root value is missing the required field 'url'.") - } - }) - - it("should not convert timestamp if it's invalid", async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const url = 'https://example.com/page-one' - const timestamp = '2018-03-04T12:08:56.235 PDT' - const data = { - property1: 'this is a test', - url - } - trackPageViewService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackPageView', { - event, - settings, - useDefaultMappings: true + const event = createTestEvent({ + userId, + properties: attributes, + timestamp + }) + const response = await action('trackPageView', { event, settings, useDefaultMappings: true }) + + expect(response).toEqual({ + action: 'page', + name: url, + type: 'person', + id: event.messageId, + identifiers: { + id: userId + }, + timestamp: dayjs.utc(timestamp).unix(), + attributes: { + ...attributes, + anonymous_id: event.anonymousId, + person: { + ...attributes.person, + birthdate: dayjs.utc(birthdate).unix() + } + } + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - name: url, - type, - data, - timestamp - }) - }) - - it('should not convert dates to unix timestamps when convert_timestamp is false', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const url = 'https://example.com/page-one' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const data = { - property1: 'this is a test', - url, - person: { - over18: true, - identification: 'valid', - birthdate + it('should work with default mappings when a anonymousId is supplied', async () => { + const anonymousId = 'anonymous123' + const url = 'https://example.com/page-one' + const timestamp = dayjs.utc().toISOString() + const attributes = { + property1: 'this is a test', + url } - } - trackPageViewService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackPageView', { - event, - settings, - useDefaultMappings: true, - mapping: { - convert_timestamp: false + const event = createTestEvent({ + anonymousId, + properties: attributes, + userId: undefined, + timestamp + }) + + const response = await action('trackPageView', { event, settings, useDefaultMappings: true }) + + expect(response).toEqual({ + action: 'page', + name: url, + type: 'person', + attributes, + id: event.messageId, + identifiers: { + anonymous_id: anonymousId + }, + timestamp: dayjs.utc(timestamp).unix() + }) + }) + + it('should work if `event_id` is unmapped', async () => { + const anonymousId = 'anonymous123' + const url = 'https://example.com/page-one' + const timestamp = dayjs.utc().toISOString() + const attributes = { + property1: 'this is a test', + url + } + const event = createTestEvent({ + anonymousId, + properties: attributes, + userId: undefined, + timestamp + }) + + const mapping = getDefaultMappings('trackPageView') + + // Ensure event_id is not mapped, such as for previous customers who have not updated their mappings. + delete mapping.event_id + + const response = await action('trackPageView', { event, mapping, settings }) + + expect(response).toStrictEqual({ + action: 'page', + name: url, + type: 'person', + attributes, + identifiers: { + anonymous_id: anonymousId + }, + timestamp: dayjs.utc(timestamp).unix() + }) + }) + + it('should error when the url field is not supplied', async () => { + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + anonymousId: undefined, + userId: undefined, + timestamp + }) + + try { + await action('trackPageView', { event, settings, useDefaultMappings: true }) + fail('This test should have thrown an error') + } catch (e) { + expect(e.message).toBe("The root value is missing the required field 'url'.") } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: url, - type, - data, - timestamp - }) - }) - - it('should work with the EU account region', async () => { - const trackEUEventService = nock('https://track-eu.customer.io/api/v1') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const url = 'https://example.com/page-one' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test', - url - } - trackEUEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - userId, - timestamp, - properties: data - }) - const responses = await testDestination.testAction('trackPageView', { event, settings, useDefaultMappings: true }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: url, - type, - data, - timestamp: dayjs.utc(timestamp).unix() - }) - }) - - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const url = 'https://example.com/page-one' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test', - url - } - trackPageViewService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - userId, - timestamp, - properties: data - }) - const responses = await testDestination.testAction('trackPageView', { event, settings, useDefaultMappings: true }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: url, - type, - data, - timestamp: dayjs.utc(timestamp).unix() + it("should not convert timestamp if it's invalid", async () => { + const userId = 'abc123' + const url = 'https://example.com/page-one' + const timestamp = '2018-03-04T12:08:56.235 PDT' + const attributes = { + property1: 'this is a test', + url + } + const event = createTestEvent({ + userId, + properties: attributes, + timestamp + }) + const response = await action('trackPageView', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'page', + name: url, + type: 'person', + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + id: event.messageId, + identifiers: { + id: userId + }, + timestamp + }) + }) + + it('should not convert dates to unix timestamps when convert_timestamp is false', async () => { + const userId = 'abc123' + const url = 'https://example.com/page-one' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const attributes = { + property1: 'this is a test', + url, + person: { + over18: true, + identification: 'valid', + birthdate + } + } + const event = createTestEvent({ + userId, + properties: attributes, + timestamp + }) + const response = await action('trackPageView', { + event, + settings, + useDefaultMappings: true, + mapping: { + convert_timestamp: false + } + }) + + expect(response).toEqual({ + action: 'page', + name: url, + type: 'person', + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + id: event.messageId, + identifiers: { + id: userId + }, + timestamp + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/trackScreenView.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/trackScreenView.test.ts index 59c5298594..9516be19f6 100644 --- a/packages/destination-actions/src/destinations/customerio/__tests__/trackScreenView.test.ts +++ b/packages/destination-actions/src/destinations/customerio/__tests__/trackScreenView.test.ts @@ -1,305 +1,230 @@ -import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import CustomerIO from '../index' +import { createTestEvent } from '@segment/actions-core' import { Settings } from '../generated-types' import dayjs from '../../../lib/dayjs' -import { AccountRegion } from '../utils' - -const testDestination = createTestIntegration(CustomerIO) -const trackScreenViewService = nock('https://track.customer.io/api/v1') -const type = 'screen' +import { getDefaultMappings, testRunner } from '../test-helper' describe('CustomerIO', () => { describe('trackScreenView', () => { - it('should work with default mappings when a userId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const screen = 'Page One' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const data = { - property1: 'this is a test', - screen, - person: { - over18: true, - identification: 'valid', - birthdate - } - } - trackScreenViewService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - type: 'screen', - name: screen, - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackScreenView', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: screen, - type, - timestamp: dayjs.utc(timestamp).unix(), - data: { - ...data, + testRunner((settings: Settings, action: Function) => { + it('should work with default mappings when a userId is supplied', async () => { + const userId = 'abc123' + const screen = 'Page One' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const attributes = { + property1: 'this is a test', + screen, person: { - ...data.person, - birthdate: dayjs.utc(birthdate).unix() + over18: true, + identification: 'valid', + birthdate } } - }) - }) - - it('should work with default mappings when a anonymousId is supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const anonymousId = 'anonymous123' - const screen = 'Page One' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test', - screen - } - trackScreenViewService.post(`/events`).reply(200, {}) - const event = createTestEvent({ - type: 'screen', - name: screen, - anonymousId, - properties: data, - userId: undefined, - timestamp - }) - - const responses = await testDestination.testAction('trackScreenView', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: screen, - type, - data, - anonymous_id: anonymousId, - timestamp: dayjs.utc(timestamp).unix() - }) - }) - - it('should error when the name field is not supplied', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const timestamp = dayjs.utc().toISOString() - trackScreenViewService.post(`/events`).reply(200, {}) - const event = createTestEvent({ - type: 'screen', - anonymousId: undefined, - userId: undefined, - timestamp - }) - - try { - await testDestination.testAction('trackScreenView', { event, settings, useDefaultMappings: true }) - fail('This test should have thrown an error') - } catch (e) { - expect(e.message).toBe("The root value is missing the required field 'name'.") - } - }) - - it("should not convert timestamp if it's invalid", async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const screen = 'Page One' - const timestamp = '2018-03-04T12:08:56.235 PDT' - const data = { - property1: 'this is a test', - screen - } - trackScreenViewService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - type: 'screen', - name: screen, - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackScreenView', { - event, - settings, - useDefaultMappings: true + const event = createTestEvent({ + type: 'screen', + name: screen, + userId, + properties: attributes, + timestamp + }) + const response = await action('trackScreenView', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + name: screen, + action: 'screen', + type: 'person', + id: event.messageId, + identifiers: { + id: userId + }, + timestamp: dayjs.utc(timestamp).unix(), + attributes: { + ...attributes, + anonymous_id: event.anonymousId, + person: { + ...attributes.person, + birthdate: dayjs.utc(birthdate).unix() + } + } + }) }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].options.json).toMatchObject({ - name: screen, - type, - data, - timestamp - }) - }) - - it('should not convert dates to unix timestamps when convert_timestamp is false', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.US - } - const userId = 'abc123' - const screen = 'Page One' - const timestamp = dayjs.utc().toISOString() - const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() - const data = { - property1: 'this is a test', - screen, - person: { - over18: true, - identification: 'valid', - birthdate + it('should work with default mappings when a anonymousId is supplied', async () => { + const anonymousId = 'anonymous123' + const screen = 'Page One' + const timestamp = dayjs.utc().toISOString() + const attributes = { + property1: 'this is a test', + screen } - } - trackScreenViewService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'US' }) - const event = createTestEvent({ - type: 'screen', - name: screen, - userId, - properties: data, - timestamp - }) - const responses = await testDestination.testAction('trackScreenView', { - event, - settings, - useDefaultMappings: true, - mapping: { - convert_timestamp: false + const event = createTestEvent({ + type: 'screen', + name: screen, + anonymousId, + properties: attributes, + userId: undefined, + timestamp + }) + + const response = await action('trackScreenView', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + name: screen, + action: 'screen', + type: 'person', + attributes, + identifiers: { + anonymous_id: anonymousId + }, + id: event.messageId, + timestamp: dayjs.utc(timestamp).unix() + }) + }) + + it('should error when the name field is not supplied', async () => { + const timestamp = dayjs.utc().toISOString() + const event = createTestEvent({ + type: 'screen', + anonymousId: undefined, + userId: undefined, + timestamp + }) + + try { + await action('trackScreenView', { event, settings, useDefaultMappings: true }) + fail('This test should have thrown an error') + } catch (e) { + expect(e.message).toBe("The root value is missing the required field 'name'.") } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: screen, - type, - data, - timestamp - }) - }) - - it('should work with the EU account region', async () => { - const trackEUEventService = nock('https://track-eu.customer.io/api/v1') - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde', - accountRegion: AccountRegion.EU - } - const userId = 'abc123' - const screen = 'Page One' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test', - screen - } - trackEUEventService.post(`/customers/${userId}/events`).reply(200, {}, { 'x-customerio-region': 'EU' }) - const event = createTestEvent({ - type: 'screen', - name: screen, - userId, - timestamp, - properties: data - }) - const responses = await testDestination.testAction('trackScreenView', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'EU', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: screen, - type, - data, - timestamp: dayjs.utc(timestamp).unix() - }) - }) - - it('should fall back to the US account region', async () => { - const settings: Settings = { - siteId: '12345', - apiKey: 'abcde' - } - const userId = 'abc123' - const screen = 'Page One' - const timestamp = dayjs.utc().toISOString() - const data = { - property1: 'this is a test', - screen - } - trackScreenViewService - .post(`/customers/${userId}/events`) - .reply(200, {}, { 'x-customerio-region': 'US-fallback' }) - const event = createTestEvent({ - type: 'screen', - name: screen, - userId, - timestamp, - properties: data - }) - const responses = await testDestination.testAction('trackScreenView', { - event, - settings, - useDefaultMappings: true - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toBe(200) - expect(responses[0].headers.toJSON()).toMatchObject({ - 'x-customerio-region': 'US-fallback', - 'content-type': 'application/json' - }) - expect(responses[0].data).toMatchObject({}) - expect(responses[0].options.json).toMatchObject({ - name: screen, - type, - data, - timestamp: dayjs.utc(timestamp).unix() + it('should work if `event_id` is unmapped', async () => { + const userId = 'abc123' + const screen = 'Page One' + const timestamp = '2018-03-04T12:08:56.235 PDT' + const attributes = { + property1: 'this is a test', + screen + } + const event = createTestEvent({ + type: 'screen', + name: screen, + userId, + properties: attributes, + timestamp + }) + + const mapping = getDefaultMappings('trackScreenView') + + // Ensure event_id is not mapped, such as for previous customers who have not updated their mappings. + delete mapping.event_id + + const response = await action('trackScreenView', { event, mapping, settings }) + + expect(response).toStrictEqual({ + name: screen, + action: 'screen', + type: 'person', + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + identifiers: { + id: userId + }, + timestamp + }) + }) + + it("should not convert timestamp if it's invalid", async () => { + const userId = 'abc123' + const screen = 'Page One' + const timestamp = '2018-03-04T12:08:56.235 PDT' + const attributes = { + property1: 'this is a test', + screen + } + const event = createTestEvent({ + type: 'screen', + name: screen, + userId, + properties: attributes, + timestamp + }) + const response = await action('trackScreenView', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + name: screen, + action: 'screen', + type: 'person', + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + id: event.messageId, + identifiers: { + id: userId + }, + timestamp + }) + }) + + it('should not convert dates to unix timestamps when convert_timestamp is false', async () => { + const userId = 'abc123' + const screen = 'Page One' + const timestamp = dayjs.utc().toISOString() + const birthdate = dayjs.utc('1990-01-01T00:00:00Z').toISOString() + const attributes = { + property1: 'this is a test', + screen, + person: { + over18: true, + identification: 'valid', + birthdate + } + } + const event = createTestEvent({ + type: 'screen', + name: screen, + userId, + properties: attributes, + timestamp + }) + const response = await action('trackScreenView', { + event, + settings, + useDefaultMappings: true, + mapping: { + convert_timestamp: false + } + }) + + expect(response).toEqual({ + name: screen, + action: 'screen', + type: 'person', + attributes: { + ...attributes, + anonymous_id: event.anonymousId + }, + id: event.messageId, + identifiers: { + id: userId + }, + timestamp + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/unsuppressPerson.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/unsuppressPerson.test.ts new file mode 100644 index 0000000000..07b7678293 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/unsuppressPerson.test.ts @@ -0,0 +1,49 @@ +import { createTestEvent } from '@segment/actions-core' +import { Settings } from '../generated-types' +import { testRunner } from '../test-helper' + +describe('CustomerIO', () => { + describe('unsuppressPerson', () => { + testRunner((settings: Settings, action: Function) => { + it('should work with user id', async () => { + const userId = 'user_123' + const event = createTestEvent({ + userId: userId + }) + const response = await action('unsuppressPerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'unsuppress', + identifiers: { + id: userId + }, + type: 'person' + }) + }) + + it('should work with email', async () => { + const email = 'foo@bar.com' + const event = createTestEvent({ + userId: email + }) + const response = await action('unsuppressPerson', { + event, + settings, + useDefaultMappings: true + }) + + expect(response).toEqual({ + action: 'unsuppress', + identifiers: { + email + }, + type: 'person' + }) + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts new file mode 100644 index 0000000000..e5fa58f99e --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/__tests__/utils.test.ts @@ -0,0 +1,43 @@ +import { resolveIdentifiers } from '../utils' + +describe('resolveIdentifiers', () => { + it('should return object_id and object_type_id if both are provided', () => { + const identifiers = { object_id: '123', object_type_id: '456' } + + expect(resolveIdentifiers(identifiers)).toEqual(identifiers) + }) + + it('should return cio_id if person_id starts with "cio_"', () => { + const identifiers = { person_id: 'cio_123' } + + expect(resolveIdentifiers(identifiers)).toEqual({ cio_id: '123' }) + }) + + it('should return email if person_id is a valid email', () => { + const identifiers = { person_id: 'test@example.com' } + + expect(resolveIdentifiers(identifiers)).toEqual({ email: 'test@example.com' }) + }) + + it('should return id if person_id is provided', () => { + const identifiers = { person_id: '123' } + + expect(resolveIdentifiers(identifiers)).toEqual({ id: '123' }) + }) + + it('should return email if email is provided', () => { + const identifiers = { email: 'test@example.com' } + + expect(resolveIdentifiers(identifiers)).toEqual({ email: 'test@example.com' }) + }) + + it('should return anonymous_id if anonymous_id is provided', () => { + const identifiers = { anonymous_id: '123' } + + expect(resolveIdentifiers(identifiers)).toEqual({ anonymous_id: '123' }) + }) + + it('should return undefined if no identifiers are provided', () => { + expect(resolveIdentifiers({})).toBeUndefined() + }) +}) diff --git a/packages/destination-actions/src/destinations/customerio/createUpdateDevice/generated-types.ts b/packages/destination-actions/src/destinations/customerio/createUpdateDevice/generated-types.ts index 35f1017614..8a3b2f0efb 100644 --- a/packages/destination-actions/src/destinations/customerio/createUpdateDevice/generated-types.ts +++ b/packages/destination-actions/src/destinations/customerio/createUpdateDevice/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * The device token of a customer's mobile device. */ device_id: string + /** + * The version of the App + */ + app_version?: string /** * The mobile device's platform. ("ios" or "android") */ @@ -17,6 +21,12 @@ export interface Payload { * The timestamp for when the mobile device was last used. Default is current date and time. */ last_used?: string + /** + * Optional data that you can reference to segment your audience, like a person's attributes, but specific to a device. + */ + attributes?: { + [k: string]: unknown + } /** * Convert dates to Unix timestamps (seconds since Epoch). */ diff --git a/packages/destination-actions/src/destinations/customerio/createUpdateDevice/index.ts b/packages/destination-actions/src/destinations/customerio/createUpdateDevice/index.ts index c459b6d92f..9313f8bc9c 100644 --- a/packages/destination-actions/src/destinations/customerio/createUpdateDevice/index.ts +++ b/packages/destination-actions/src/destinations/customerio/createUpdateDevice/index.ts @@ -1,11 +1,11 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { convertValidTimestamp, trackApiEndpoint } from '../utils' +import { sendBatch, sendSingle } from '../utils' const action: ActionDefinition = { title: 'Create or Update Device', - description: `Track an "Application Installed" or "Application Opened" event to create or update a person's device.`, + description: `Create or update a person's device.`, defaultSubscription: 'type = "track" and event = "Application Installed"', fields: { person_id: { @@ -26,6 +26,14 @@ const action: ActionDefinition = { '@path': '$.context.device.token' } }, + app_version: { + label: 'App Version', + description: 'The version of the App', + type: 'string', + default: { + '@path': '$.context.app.version' + } + }, platform: { label: 'Platform', description: `The mobile device's platform. ("ios" or "android")`, @@ -43,6 +51,14 @@ const action: ActionDefinition = { '@path': '$.timestamp' } }, + attributes: { + label: 'Event Attributes', + description: `Optional data that you can reference to segment your audience, like a person's attributes, but specific to a device.`, + type: 'object', + default: { + '@path': '$.properties' + } + }, convert_timestamp: { label: 'Convert Timestamps', description: 'Convert dates to Unix timestamps (seconds since Epoch).', @@ -51,23 +67,32 @@ const action: ActionDefinition = { } }, - perform: (request, { settings, payload }) => { - let lastUsed: string | number | undefined = payload.last_used + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ action: 'add_device', payload: mapPayload(payload), settings, type: 'person' })) + ) + }, - if (lastUsed && payload.convert_timestamp !== false) { - lastUsed = convertValidTimestamp(lastUsed) - } + perform: (request, { payload, settings }) => { + return sendSingle(request, { action: 'add_device', payload: mapPayload(payload), settings, type: 'person' }) + } +} + +function mapPayload(payload: Payload) { + const { app_version, device_id, platform, last_used, attributes, ...rest } = payload - return request(`${trackApiEndpoint(settings.accountRegion)}/api/v1/customers/${payload.person_id}/devices`, { - method: 'put', - json: { - device: { - id: payload.device_id, - platform: payload.platform, - last_used: lastUsed - } + return { + ...rest, + device: { + token: device_id, + platform, + last_used, + attributes: { + ...attributes, + ...(payload.app_version ? { app_version: payload.app_version } : {}) } - }) + } } } diff --git a/packages/destination-actions/src/destinations/customerio/createUpdateObject/generated-types.ts b/packages/destination-actions/src/destinations/customerio/createUpdateObject/generated-types.ts index c1dec3a34c..abd12dc79e 100644 --- a/packages/destination-actions/src/destinations/customerio/createUpdateObject/generated-types.ts +++ b/packages/destination-actions/src/destinations/customerio/createUpdateObject/generated-types.ts @@ -15,6 +15,12 @@ export interface Payload { custom_attributes?: { [k: string]: unknown } + /** + * Optional attributes for the relationship between the object and the user. When updating an relationship, attributes are added or updated, not removed. + */ + relationship_attributes?: { + [k: string]: unknown + } /** * The ID used to relate a user to an object in Customer.io. [Learn more](https://customer.io/docs/identifying-people/#identifiers). */ diff --git a/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts b/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts index e591eb4bbe..4346ead5c0 100644 --- a/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts +++ b/packages/destination-actions/src/destinations/customerio/createUpdateObject/index.ts @@ -1,7 +1,9 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { convertAttributeTimestamps, convertValidTimestamp, trackApiEndpoint } from '../utils' +import { convertAttributeTimestamps, sendSingle, sendBatch, resolveIdentifiers } from '../utils' + +type Action = 'identify' | 'identify_anonymous' const action: ActionDefinition = { title: 'Create or Update Object', @@ -23,7 +25,11 @@ const action: ActionDefinition = { description: 'A timestamp of when the object was created.', type: 'string', default: { - '@template': '{{traits.created_at}}' + '@if': { + exists: { '@path': '$.traits.created_at' }, + then: { '@path': '$.traits.created_at' }, + else: { '@path': '$.traits.createdAt' } + } } }, custom_attributes: { @@ -32,7 +38,16 @@ const action: ActionDefinition = { 'Optional attributes for the object. When updating an object, attributes are added or updated, not removed.', type: 'object', default: { - '@path': '$.traits' + '@path': '$.traits.objectAttributes' + } + }, + relationship_attributes: { + label: 'Relationship Attributes', + description: + 'Optional attributes for the relationship between the object and the user. When updating an relationship, attributes are added or updated, not removed.', + type: 'object', + default: { + '@path': '$.traits.relationshipAttributes' } }, user_id: { @@ -53,14 +68,17 @@ const action: ActionDefinition = { '@path': '$.anonymousId' } }, - object_type_id: { label: 'Object Type Id', description: 'The ID used to uniquely identify a custom object type in Customer.io. [Learn more](https://customer.io/docs/object-relationships).', type: 'string', default: { - '@path': '$.objectTypeId' + '@if': { + exists: { '@path': '$.traits.object_type_id' }, + then: { '@path': '$.traits.object_type_id' }, + else: { '@path': '$.traits.objectTypeId' } + } } }, convert_timestamp: { @@ -70,49 +88,100 @@ const action: ActionDefinition = { default: true } }, - perform: (request, { settings, payload }) => { - let createdAt: string | number | undefined = payload.created_at - let customAttributes = payload.custom_attributes - let objectTypeIDInTraits = null - const objectTypeID = payload.object_type_id - const userID = payload.user_id - const objectID = payload.id - const anonymousId = payload.anonymous_id - if (payload.convert_timestamp !== false) { - if (createdAt) { - createdAt = convertValidTimestamp(createdAt) - } - if (customAttributes) { - customAttributes = convertAttributeTimestamps(customAttributes) - if (customAttributes.object_type_id) { - objectTypeIDInTraits = customAttributes.object_type_id - delete customAttributes.object_type_id - } - } + performBatch: (request, { payload: payloads, settings }) => { + const payloadsByAction: Record[]> = { + identify: [], + identify_anonymous: [] } - const body: Record = {} - body.attributes = customAttributes - if (createdAt) { - body.created_at = createdAt - } - body.type = 'object' - body.identifiers = { object_type_id: objectTypeIDInTraits ?? objectTypeID ?? '1', object_id: objectID } - - if (userID) { - body.action = 'identify' - body.cio_relationships = [{ identifiers: { id: userID } }] - } else { - body.action = 'identify_anonymous' - body.cio_relationships = [{ identifiers: { anonymous_id: anonymousId } }] + for (const payload of payloads) { + const { action, body } = mapPayload(payload) + + payloadsByAction[action as Action].push(body) } - return request(`${trackApiEndpoint(settings.accountRegion)}/api/v2/entity`, { - method: 'post', - json: body - }) + return Promise.all([ + sendBatch( + request, + payloadsByAction.identify.map((payload) => ({ action: 'identify', payload, settings, type: 'object' })) + ), + sendBatch( + request, + payloadsByAction.identify_anonymous.map((payload) => ({ + action: 'identify_anonymous', + payload, + settings, + type: 'object' + })) + ) + ]) + }, + + perform: (request, { payload, settings }) => { + const { action, body } = mapPayload(payload) + + return sendSingle(request, { action, payload: body, settings, type: 'object' }) } } +function mapPayload(payload: Payload) { + const { + id, + convert_timestamp, + custom_attributes, + relationship_attributes, + user_id, + anonymous_id, + object_type_id, + ...rest + } = payload + let body: Record = { + ...rest, + anonymous_id, + person_id: user_id, + attributes: custom_attributes, + object_type_id: object_type_id ?? '1', + object_id: id + } + + let rel_attrs = relationship_attributes as Record + + if ('convert_timestamp' in payload && convert_timestamp !== false) { + body = convertAttributeTimestamps(body) + if (relationship_attributes) { + rel_attrs = convertAttributeTimestamps(rel_attrs) + } + } + + if ('created_at' in payload && !payload.created_at) { + delete body.created_at + } + + if (body.attributes && 'object_type_id' in (body.attributes as Record)) { + delete (body.attributes as Record).object_type_id + } + + let action = 'identify' + + if (user_id) { + action = 'identify' + const relationship: { [key: string]: unknown } = { identifiers: resolveIdentifiers({ person_id: user_id }) } + // Adding relationship attributes if they exist + if (relationship_attributes) { + relationship.relationship_attributes = rel_attrs + } + body.cio_relationships = [relationship] + } else if (anonymous_id) { + action = 'identify_anonymous' + const relationship: { [key: string]: unknown } = { identifiers: { anonymous_id } } + // Adding relationship attributes if they exist + if (relationship_attributes) { + relationship.relationship_attributes = rel_attrs + } + body.cio_relationships = [relationship] + } + + return { action, body } +} export default action diff --git a/packages/destination-actions/src/destinations/customerio/createUpdatePerson/generated-types.ts b/packages/destination-actions/src/destinations/customerio/createUpdatePerson/generated-types.ts index 878b24dc01..1cad452fbe 100644 --- a/packages/destination-actions/src/destinations/customerio/createUpdatePerson/generated-types.ts +++ b/packages/destination-actions/src/destinations/customerio/createUpdatePerson/generated-types.ts @@ -4,9 +4,9 @@ export interface Payload { /** * The ID used to uniquely identify a person in Customer.io. [Learn more](https://customer.io/docs/identifying-people/#identifiers). */ - id: string + id?: string /** - * An anonymous ID for when no Person ID exists. [Learn more](https://customer.io/docs/anonymous-events/). + * An optional anonymous ID. This is used to tie anonymous events to this person. [Learn more](https://customer.io/docs/anonymous-events/). */ anonymous_id?: string /** @@ -27,6 +27,12 @@ export interface Payload { custom_attributes?: { [k: string]: unknown } + /** + * Optional attributes for the relationship between the object and the user. When updating an object, attributes are added or updated, not removed. + */ + relationship_attributes?: { + [k: string]: unknown + } /** * Convert dates to Unix timestamps (seconds since Epoch). */ diff --git a/packages/destination-actions/src/destinations/customerio/createUpdatePerson/index.ts b/packages/destination-actions/src/destinations/customerio/createUpdatePerson/index.ts index d9900dfbcc..1545adf3fb 100644 --- a/packages/destination-actions/src/destinations/customerio/createUpdatePerson/index.ts +++ b/packages/destination-actions/src/destinations/customerio/createUpdatePerson/index.ts @@ -1,7 +1,7 @@ -import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { convertAttributeTimestamps, convertValidTimestamp, trackApiEndpoint } from '../utils' +import { ActionDefinition } from '@segment/actions-core' +import { sendBatch, sendSingle } from '../utils' const action: ActionDefinition = { title: 'Create or Update Person', @@ -13,7 +13,6 @@ const action: ActionDefinition = { description: 'The ID used to uniquely identify a person in Customer.io. [Learn more](https://customer.io/docs/identifying-people/#identifiers).', type: 'string', - required: true, default: { '@if': { exists: { '@path': '$.userId' }, @@ -25,7 +24,7 @@ const action: ActionDefinition = { anonymous_id: { label: 'Anonymous ID', description: - 'An anonymous ID for when no Person ID exists. [Learn more](https://customer.io/docs/anonymous-events/).', + 'An optional anonymous ID. This is used to tie anonymous events to this person. [Learn more](https://customer.io/docs/anonymous-events/).', type: 'string', default: { '@path': '$.anonymousId' @@ -44,7 +43,11 @@ const action: ActionDefinition = { description: 'A timestamp of when the person was created.', type: 'string', default: { - '@template': '{{traits.created_at}}' + '@if': { + exists: { '@path': '$.traits.created_at' }, + then: { '@path': '$.traits.created_at' }, + else: { '@path': '$.traits.createdAt' } + } } }, group_id: { @@ -65,6 +68,15 @@ const action: ActionDefinition = { '@path': '$.traits' } }, + relationship_attributes: { + label: 'Relationship Attributes', + description: + 'Optional attributes for the relationship between the object and the user. When updating an object, attributes are added or updated, not removed.', + type: 'object', + default: { + '@path': '$.traits.relationshipAttributes' + } + }, convert_timestamp: { label: 'Convert Timestamps', description: 'Convert dates to Unix timestamps (seconds since Epoch).', @@ -77,57 +89,63 @@ const action: ActionDefinition = { 'The ID used to uniquely identify a custom object type in Customer.io. [Learn more](https://customer.io/docs/object-relationships).', type: 'string', default: { - '@path': '$.objectTypeId' + '@if': { + exists: { '@path': '$.traits.object_type_id' }, + then: { '@path': '$.traits.object_type_id' }, + else: { '@path': '$.traits.objectTypeId' } + } } } }, - perform: (request, { settings, payload }) => { - let createdAt: string | number | undefined = payload.created_at - let customAttributes = payload.custom_attributes - let objectTypeIDInTraits = null - const objectId = payload.group_id - const objectTypeId = payload.object_type_id + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ action: 'identify', payload: mapPayload(payload), settings, type: 'person' })) + ) + }, - if (payload.convert_timestamp !== false) { - if (createdAt) { - createdAt = convertValidTimestamp(createdAt) - } + perform: (request, { payload, settings }) => { + return sendSingle(request, { action: 'identify', payload: mapPayload(payload), settings, type: 'person' }) + } +} - if (customAttributes) { - customAttributes = convertAttributeTimestamps(customAttributes) - if (customAttributes.object_type_id && objectId) { - objectTypeIDInTraits = customAttributes.object_type_id - delete customAttributes.object_type_id - } - } - } +function mapPayload(payload: Payload) { + const { id, custom_attributes = {}, relationship_attributes, created_at, group_id, object_type_id, ...rest } = payload - const body: Record = { - ...customAttributes, - email: payload.email, - anonymous_id: payload.anonymous_id - } + // This is mapped to a field below. + delete custom_attributes.createdAt + delete custom_attributes.created_at + delete custom_attributes?.object_type_id + delete custom_attributes?.relationshipAttributes - if (createdAt) { - body.created_at = createdAt - } + if (created_at) { + custom_attributes.created_at = created_at + } - // Adding Object Person relationship if group_id exists in the call. If the object_type_id is not given, default it to "1" - if (objectId) { - body.cio_relationships = { - action: 'add_relationships', - relationships: [ - { identifiers: { object_type_id: objectTypeIDInTraits ?? objectTypeId ?? '1', object_id: objectId } } - ] - } - } + if (payload.email) { + custom_attributes.email = payload.email + } + + const body: Record = { + ...rest, + person_id: id, + attributes: custom_attributes + } - return request(`${trackApiEndpoint(settings.accountRegion)}/api/v1/customers/${payload.id}`, { - method: 'put', - json: body - }) + // Adding Object Person relationship if group_id exists in the call. If the object_type_id is not given, default it to "1" + if (group_id) { + const relationship: { [key: string]: unknown } = { + identifiers: { object_type_id: object_type_id ?? '1', object_id: group_id } + } + // Adding relationship attributes if they exist + if (relationship_attributes) { + relationship.relationship_attributes = relationship_attributes + } + body.cio_relationships = [relationship] } + + return body } export default action diff --git a/packages/destination-actions/src/destinations/customerio/deleteDevice/index.ts b/packages/destination-actions/src/destinations/customerio/deleteDevice/index.ts index a5ba7feae9..84609007f7 100644 --- a/packages/destination-actions/src/destinations/customerio/deleteDevice/index.ts +++ b/packages/destination-actions/src/destinations/customerio/deleteDevice/index.ts @@ -1,11 +1,11 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' -import { trackApiEndpoint } from '../utils' import type { Payload } from './generated-types' +import { sendBatch, sendSingle } from '../utils' const action: ActionDefinition = { title: 'Delete Device', - description: `Track an "Application Uninstalled" event to delete a person's device.`, + description: `Delete a person's device.`, defaultSubscription: 'event = "Application Uninstalled"', fields: { person_id: { @@ -27,13 +27,27 @@ const action: ActionDefinition = { } } }, - perform: (request, { settings, payload }) => { - return request( - `${trackApiEndpoint(settings.accountRegion)}/api/v1/customers/${payload.person_id}/devices/${payload.device_id}`, - { - method: 'delete' - } + + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ action: 'delete_device', payload: mapPayload(payload), settings, type: 'person' })) ) + }, + + perform: (request, { payload, settings }) => { + return sendSingle(request, { action: 'delete_device', payload: mapPayload(payload), settings, type: 'person' }) + } +} + +function mapPayload(payload: Payload) { + const { device_id, ...rest } = payload + + return { + ...rest, + device: { + token: device_id + } } } diff --git a/packages/destination-actions/src/destinations/customerio/deleteObject/generated-types.ts b/packages/destination-actions/src/destinations/customerio/deleteObject/generated-types.ts new file mode 100644 index 0000000000..f168a7365b --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/deleteObject/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * An object ID used to identify an object. + */ + object_id?: string + /** + * An object ID type used to identify the type of object. + */ + object_type_id?: string +} diff --git a/packages/destination-actions/src/destinations/customerio/deleteObject/index.ts b/packages/destination-actions/src/destinations/customerio/deleteObject/index.ts new file mode 100644 index 0000000000..aa0ef250a5 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/deleteObject/index.ts @@ -0,0 +1,55 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { sendBatch, sendSingle } from '../utils' + +const action: ActionDefinition = { + title: 'Delete Object', + description: 'Delete an object in Customer.io.', + defaultSubscription: 'event = "Object Deleted"', + fields: { + object_id: { + label: 'Object ID', + description: 'An object ID used to identify an object.', + type: 'string', + default: { + '@path': '$.context.groupId' + } + }, + object_type_id: { + label: 'Object Type ID', + description: 'An object ID type used to identify the type of object.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.object_type_id' }, + then: { '@path': '$.properties.object_type_id' }, + else: { '@path': '$.properties.objectTypeId' } + } + } + } + }, + + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ + action: 'delete', + payload, + settings, + type: 'object' + })) + ) + }, + + perform: (request, { payload, settings }) => { + return sendSingle(request, { + action: 'delete', + payload, + settings, + type: 'object' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/customerio/deletePerson/generated-types.ts b/packages/destination-actions/src/destinations/customerio/deletePerson/generated-types.ts new file mode 100644 index 0000000000..378d503661 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/deletePerson/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the person that this mobile device belongs to. + */ + person_id: string +} diff --git a/packages/destination-actions/src/destinations/customerio/deletePerson/index.ts b/packages/destination-actions/src/destinations/customerio/deletePerson/index.ts new file mode 100644 index 0000000000..b8c385584c --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/deletePerson/index.ts @@ -0,0 +1,44 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { sendBatch, sendSingle } from '../utils' + +const action: ActionDefinition = { + title: 'Delete Person', + description: 'Delete a person in Customer.io.', + defaultSubscription: 'event = "User Deleted"', + fields: { + person_id: { + label: 'Person ID', + description: 'The ID of the person that this mobile device belongs to.', + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + } + }, + + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ + action: 'delete', + payload, + settings, + type: 'person' + })) + ) + }, + + perform: (request, { payload, settings }) => { + return sendSingle(request, { + action: 'delete', + payload, + settings, + type: 'person' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/customerio/deleteRelationship/generated-types.ts b/packages/destination-actions/src/destinations/customerio/deleteRelationship/generated-types.ts new file mode 100644 index 0000000000..f4463ec5e9 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/deleteRelationship/generated-types.ts @@ -0,0 +1,20 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the person that this mobile device belongs to. + */ + person_id: string + /** + * An optional anonymous ID. This is used to tie anonymous events to this person. [Learn more](https://customer.io/docs/anonymous-events/). + */ + anonymous_id?: string + /** + * An object ID used to identify an object. + */ + object_id: string + /** + * An object ID type used to identify the type of object. + */ + object_type_id?: string +} diff --git a/packages/destination-actions/src/destinations/customerio/deleteRelationship/index.ts b/packages/destination-actions/src/destinations/customerio/deleteRelationship/index.ts new file mode 100644 index 0000000000..cac481d252 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/deleteRelationship/index.ts @@ -0,0 +1,88 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { resolveIdentifiers, sendBatch, sendSingle } from '../utils' + +const action: ActionDefinition = { + title: 'Delete Relationship', + description: `Delete a relationship between a person and an object in Customer.io.`, + defaultSubscription: 'event = "Relationship Deleted"', + fields: { + person_id: { + label: 'Person ID', + description: 'The ID of the person that this mobile device belongs to.', + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + }, + anonymous_id: { + label: 'Anonymous ID', + description: + 'An optional anonymous ID. This is used to tie anonymous events to this person. [Learn more](https://customer.io/docs/anonymous-events/).', + type: 'string', + default: { + '@path': '$.anonymousId' + } + }, + object_id: { + label: 'Object ID', + description: 'An object ID used to identify an object.', + type: 'string', + default: { + '@path': '$.context.groupId' + }, + required: true + }, + object_type_id: { + label: 'Object Type ID', + description: 'An object ID type used to identify the type of object.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.object_type_id' }, + then: { '@path': '$.properties.object_type_id' }, + else: { '@path': '$.properties.objectTypeId' } + } + } + } + }, + + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ + action: 'delete_relationships', + settings, + ...mapPayload(payload) + })) + ) + }, + + perform: (request, { payload, settings }) => { + return sendSingle(request, { + action: 'delete_relationships', + settings, + ...mapPayload(payload) + }) + } +} + +function mapPayload(payload: Payload) { + const { anonymous_id, person_id } = payload + + return { + type: 'object', + payload: { + ...payload, + cio_relationships: [ + { + identifiers: resolveIdentifiers({ anonymous_id, person_id }) + } + ] + } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/customerio/index.ts b/packages/destination-actions/src/destinations/customerio/index.ts index 4aada1c2d4..24df7da154 100644 --- a/packages/destination-actions/src/destinations/customerio/index.ts +++ b/packages/destination-actions/src/destinations/customerio/index.ts @@ -1,11 +1,18 @@ import { defaultValues } from '@segment/actions-core' import createUpdateDevice from './createUpdateDevice' -import deleteDevice from './deleteDevice' +import createUpdateObject from './createUpdateObject' import createUpdatePerson from './createUpdatePerson' +import deleteDevice from './deleteDevice' +import deleteRelationship from './deleteRelationship' +import deleteObject from './deleteObject' +import deletePerson from './deletePerson' +import mergePeople from './mergePeople' +import reportDeliveryEvent from './reportDeliveryEvent' +import suppressPerson from './suppressPerson' +import unsuppressPerson from './unsuppressPerson' import trackEvent from './trackEvent' import trackPageView from './trackPageView' import trackScreenView from './trackScreenView' -import createUpdateObject from './createUpdateObject' import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import { AccountRegion, trackApiEndpoint } from './utils' @@ -21,14 +28,14 @@ const destination: DestinationDefinition = { description: 'Customer.io site ID. This can be found on your [API Credentials page](https://fly.customer.io/settings/api_credentials).', label: 'Site ID', - type: 'string', + type: 'password', required: true }, apiKey: { description: 'Customer.io API key. This can be found on your [API Credentials page](https://fly.customer.io/settings/api_credentials).', label: 'API Key', - type: 'string', + type: 'password', required: true }, accountRegion: { @@ -54,11 +61,18 @@ const destination: DestinationDefinition = { actions: { createUpdateDevice, deleteDevice, + deleteRelationship, + deletePerson, + deleteObject, createUpdatePerson, trackEvent, trackPageView, trackScreenView, - createUpdateObject + createUpdateObject, + mergePeople, + suppressPerson, + unsuppressPerson, + reportDeliveryEvent }, presets: [ @@ -76,16 +90,20 @@ const destination: DestinationDefinition = { mapping: defaultValues(createUpdateDevice.fields), type: 'automatic' }, - { - name: 'Delete Device', - subscribe: 'event = "Application Uninstalled"', - partnerAction: 'deleteDevice', - mapping: defaultValues(deleteDevice.fields), - type: 'automatic' - }, { name: 'Track Event', - subscribe: 'type = "track"', + subscribe: ` + type = "track" + and event != "Application Installed" + and event != "Application Opened" + and event != "Application Uninstalled" + and event != "Relationship Deleted" + and event != "User Deleted" + and event != "User Suppressed" + and event != "User Unsuppressed" + and event != "Object Deleted" + and event != "Report Delivery Event" + `, partnerAction: 'trackEvent', mapping: defaultValues(trackEvent.fields), type: 'automatic' @@ -111,6 +129,13 @@ const destination: DestinationDefinition = { mapping: defaultValues(createUpdateObject.fields), type: 'automatic' }, + { + name: 'Report Delivery Event', + subscribe: 'event = "Report Delivery Event"', + partnerAction: 'reportDeliveryEvent', + mapping: defaultValues(reportDeliveryEvent.fields), + type: 'automatic' + }, { name: 'Associated Entity Added', partnerAction: 'trackEvent', @@ -170,14 +195,25 @@ const destination: DestinationDefinition = { }, type: 'specificEvent', eventSlug: 'warehouse_audience_membership_changed_identify' + }, + { + name: 'Journeys Step Transition Track', + partnerAction: 'trackEvent', + mapping: { + ...defaultValues(trackEvent.fields), + data: { + '@path': '$.properties' + } + }, + type: 'specificEvent', + eventSlug: 'journeys_step_entered_track' } ], onDelete(request, { settings, payload }) { - const { accountRegion } = settings const { userId } = payload - const url = `${trackApiEndpoint(accountRegion)}/api/v1/customers/${userId}` + const url = `${trackApiEndpoint(settings)}/api/v1/customers/${userId}` return request(url, { method: 'DELETE' diff --git a/packages/destination-actions/src/destinations/customerio/mergePeople/generated-types.ts b/packages/destination-actions/src/destinations/customerio/mergePeople/generated-types.ts new file mode 100644 index 0000000000..872e022709 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/mergePeople/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The person that you want to remain after the merge, identified by id, email or cio_id. This person receives information from the secondary person in the merge. + */ + primary: string + /** + * The person that you want to delete after the merge, identified by id, email or cio_id. This person's information is merged into the primary person's profile and then it is deleted. + */ + secondary: string +} diff --git a/packages/destination-actions/src/destinations/customerio/mergePeople/index.ts b/packages/destination-actions/src/destinations/customerio/mergePeople/index.ts new file mode 100644 index 0000000000..04e758bf22 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/mergePeople/index.ts @@ -0,0 +1,60 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { sendSingle, sendBatch, resolveIdentifiers } from '../utils' + +const action: ActionDefinition = { + title: 'Merge People', + description: 'Merge two customer profiles together.', + defaultSubscription: 'type = "alias"', + fields: { + primary: { + label: 'Primary User', + description: `The person that you want to remain after the merge, identified by id, email or cio_id. This person receives information from the secondary person in the merge.`, + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + }, + secondary: { + label: 'Secondary User', + description: `The person that you want to delete after the merge, identified by id, email or cio_id. This person's information is merged into the primary person's profile and then it is deleted.`, + type: 'string', + required: true, + default: { + '@path': '$.previousId' + } + } + }, + + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ + action: 'merge', + payload: mapPayload(payload), + settings, + type: 'person' + })) + ) + }, + + perform: (request, { payload, settings }) => { + return sendSingle(request, { + action: 'merge', + payload: mapPayload(payload), + settings, + type: 'person' + }) + } +} + +function mapPayload(payload: Payload) { + return { + primary: resolveIdentifiers({ person_id: payload.primary }), + secondary: resolveIdentifiers({ person_id: payload.secondary }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/customerio/reportDeliveryEvent/generated-types.ts b/packages/destination-actions/src/destinations/customerio/reportDeliveryEvent/generated-types.ts new file mode 100644 index 0000000000..9000b7bc9b --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/reportDeliveryEvent/generated-types.ts @@ -0,0 +1,36 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The CIO-Delivery-ID from the message that you want to associate the metric with. + */ + delivery_id: string + /** + * The metric you want to report back to Customer.io. Not all metrics are available for all channels. Please refer to the [documentation](https://customer.io/docs/api/track/#operation/metrics) for more information. + */ + metric: string + /** + * Information about who the message was delivered to. For email, SMS and mobile push this is the email address, phone number and device token, respectively. + */ + recipient?: string + /** + * For metrics indicating a failure, this field provides information for the failure. + */ + reason?: string + /** + * For click metrics, this is the link that was clicked. + */ + href?: string + /** + * For In-App messages, this is the name of the action that was clicked. + */ + action_name?: string + /** + * For In-App messages, this is the value of the action that was clicked. + */ + action_value?: string + /** + * A timestamp of when the metric event took place. Default is when the event was triggered. + */ + timestamp?: string | number +} diff --git a/packages/destination-actions/src/destinations/customerio/reportDeliveryEvent/index.ts b/packages/destination-actions/src/destinations/customerio/reportDeliveryEvent/index.ts new file mode 100644 index 0000000000..63fc33e64e --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/reportDeliveryEvent/index.ts @@ -0,0 +1,147 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { convertValidTimestamp, trackApiEndpoint } from '../utils' + +const action: ActionDefinition = { + title: 'Report Delivery Event', + description: 'Report delivery metrics for a message sent from the Customer.io Journeys product.', + defaultSubscription: 'event = "Report Delivery Event"', + fields: { + delivery_id: { + label: 'Delivery ID', + description: 'The CIO-Delivery-ID from the message that you want to associate the metric with.', + type: 'string', + default: { + '@path': '$.properties.deliveryId' + }, + required: true + }, + metric: { + label: 'Metric', + description: `The metric you want to report back to Customer.io. Not all metrics are available for all channels. Please refer to the [documentation](https://customer.io/docs/api/track/#operation/metrics) for more information.`, + type: 'string', + default: { + '@path': '$.properties.metric' + }, + required: true, + choices: [ + { label: 'Delivered', value: 'delivered' }, + { label: 'Opened', value: 'opened' }, + { label: 'Clicked', value: 'clicked' }, + { label: 'Converted', value: 'converted' }, + { label: 'Marked as Spam', value: 'spammed' }, + { label: 'Bounced', value: 'bounced' }, + { label: 'Suppressed', value: 'dropped' }, + { label: 'Deferred', value: 'deferred' } + ] + }, + recipient: { + label: 'Recipient', + description: `Information about who the message was delivered to. For email, SMS and mobile push this is the email address, phone number and device token, respectively.`, + type: 'string', + default: { + '@path': '$.properties.recipient' + } + }, + reason: { + label: 'Reason', + description: 'For metrics indicating a failure, this field provides information for the failure.', + type: 'string', + default: { + '@path': '$.properties.reason' + } + }, + href: { + label: 'Href', + description: 'For click metrics, this is the link that was clicked.', + type: 'string', + default: { + '@path': '$.properties.href' + } + }, + action_name: { + label: 'Action Name', + description: 'For In-App messages, this is the name of the action that was clicked.', + type: 'string', + default: { + '@path': '$.properties.actionName' + } + }, + action_value: { + label: 'Action Value', + description: 'For In-App messages, this is the value of the action that was clicked.', + type: 'string', + default: { + '@path': '$.properties.actionValue' + } + }, + timestamp: { + label: 'Timestamp', + description: 'A timestamp of when the metric event took place. Default is when the event was triggered.', + type: 'datetime', + format: 'date-time', + default: { + '@path': '$.timestamp' + } + } + }, + perform: (request, { payload, settings }) => { + const metricsRequest: MetricsV1Payload = { + delivery_id: payload.delivery_id, + metric: payload.metric, + timestamp: Math.floor(Date.now() / 1000) + } + + const unix_timestamp = Number(convertValidTimestamp(payload.timestamp)) + if (!isNaN(unix_timestamp)) { + metricsRequest.timestamp = unix_timestamp + } + + if (payload.recipient) { + metricsRequest.recipient = payload.recipient + } + + if (payload.reason) { + metricsRequest.reason = payload.reason + } + + if (payload.href) { + metricsRequest.href = payload.href + } + + if (payload.action_name) { + metricsRequest.metadata = metricsRequest.metadata || {} + metricsRequest.metadata.action_name = payload.action_name + } + if (payload.action_value) { + metricsRequest.metadata = metricsRequest.metadata || {} + metricsRequest.metadata.action_value = payload.action_value + } + + return request(trackApiEndpoint(settings) + '/api/v1/metrics', { + json: metricsRequest, + method: 'post' + }) + } +} + +interface MetricsV1Payload { + // common fields + delivery_id: string + metric: string + timestamp: number + // optional fields + recipient?: string + // optional fields for message clicks + href?: string + // optional fields for failures + reason?: string + // optional fields for in-app clicks + metadata?: { + action_name?: string + action_value?: string + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/customerio/suppressPerson/generated-types.ts b/packages/destination-actions/src/destinations/customerio/suppressPerson/generated-types.ts new file mode 100644 index 0000000000..378d503661 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/suppressPerson/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the person that this mobile device belongs to. + */ + person_id: string +} diff --git a/packages/destination-actions/src/destinations/customerio/suppressPerson/index.ts b/packages/destination-actions/src/destinations/customerio/suppressPerson/index.ts new file mode 100644 index 0000000000..de7f450363 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/suppressPerson/index.ts @@ -0,0 +1,44 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { sendBatch, sendSingle } from '../utils' + +const action: ActionDefinition = { + title: 'Suppress Person', + description: `Suppress a person in Customer.io. This will prevent the person from receiving any messages.`, + defaultSubscription: 'event = "User Suppressed"', + fields: { + person_id: { + label: 'Person ID', + description: 'The ID of the person that this mobile device belongs to.', + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + } + }, + + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ + action: 'suppress', + payload, + settings, + type: 'person' + })) + ) + }, + + perform: (request, { payload, settings }) => { + return sendSingle(request, { + action: 'suppress', + payload, + settings, + type: 'person' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/customerio/test-helper.ts b/packages/destination-actions/src/destinations/customerio/test-helper.ts new file mode 100644 index 0000000000..0eedcbbaec --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/test-helper.ts @@ -0,0 +1,94 @@ +import nock from 'nock' +import mapValues from 'lodash/mapValues' +import { DecoratedResponse, createTestIntegration } from '@segment/actions-core' +import CustomerIO from './index' +import { Settings } from './generated-types' +import { AccountRegion } from './utils' + +const testDestination = createTestIntegration(CustomerIO) + +enum EndpointType { + BATCH = 'batch', + SINGLE = 'entity' +} +const endpointByType = { + [EndpointType.BATCH]: 'batch', + [EndpointType.SINGLE]: 'entity' +} +const trackServiceByRegion = { + [AccountRegion.US]: nock('https://track.customer.io'), + [AccountRegion.EU]: nock('https://track-eu.customer.io') +} + +function wrapFn(fn: Function, type: string, region: AccountRegion) { + return () => { + const settings: Settings = { + siteId: '12345', + apiKey: 'abcde', + accountRegion: region + } + + const action = async (name: string, args: Record) => { + const actionFn = { + [EndpointType.BATCH]: testDestination.testBatchAction, + [EndpointType.SINGLE]: testDestination.testAction + }[type] + + if (type === EndpointType.BATCH) { + args.events = [args.event] + } + + const responses = (await actionFn?.call(testDestination, name, args)) as DecoratedResponse[] + + if (!responses.length) { + // Batch events do not throw errors when payloads are invalid (they're just dropped) + // @see https://github.com/segmentio/action-destinations/blob/1f6de570caa28267dfb1b0113286e6b50c26feb0/packages/core/src/destination-kit/action.ts#L182 + if (type === EndpointType.BATCH) { + await testDestination.testAction(name, args) + } + + throw new Error(`No responses received.`) + } + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].headers.toJSON()).toMatchObject({ + 'content-type': 'application/json' + }) + expect(responses[0].data).toMatchObject({}) + + if (type === EndpointType.BATCH) { + return (responses[0].options.json as { batch: unknown[] }).batch[0] + } + + return responses[0].options.json + } + + return fn(settings, action) + } +} + +export const nockTrackInternalEndpoint = (region: AccountRegion) => trackServiceByRegion[region] + +export function getDefaultMappings(action: string) { + const fields = testDestination.definition.actions[action].fields + const defaultMappings = mapValues(fields, 'default') + + return defaultMappings +} + +export function testRunner(fn: Function) { + describe.each(Object.values(endpointByType))(`when using %s requests`, (type) => { + describe.each(Object.values(AccountRegion))(`when using the %s region`, (region) => { + beforeEach(() => { + trackServiceByRegion[region].post(`/api/v2/${type}`).reply(200, {}) + }) + + afterEach(() => { + nock.cleanAll() + }) + + return wrapFn(fn, type, region)() + }) + }) +} diff --git a/packages/destination-actions/src/destinations/customerio/trackEvent/index.ts b/packages/destination-actions/src/destinations/customerio/trackEvent/index.ts index 7236e51fe5..38444e1f3a 100644 --- a/packages/destination-actions/src/destinations/customerio/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/customerio/trackEvent/index.ts @@ -1,22 +1,23 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' -import { convertAttributeTimestamps, convertValidTimestamp, trackApiEndpoint } from '../utils' +import { sendBatch, sendSingle } from '../utils' import type { Payload } from './generated-types' -interface TrackEventPayload { - name: string - type?: string - timestamp?: string | number - data?: Record - id?: string - // Required for anonymous events - anonymous_id?: string -} - const action: ActionDefinition = { title: 'Track Event', description: 'Track an event for a known or anonymous person.', - defaultSubscription: 'type = "track"', + defaultSubscription: ` + type = "track" + and event != "Application Installed" + and event != "Application Opened" + and event != "Application Uninstalled" + and event != "Relationship Deleted" + and event != "User Deleted" + and event != "User Suppressed" + and event != "User Unsuppressed" + and event != "Group Deleted" + and event != "Report Delivery Event" + `, fields: { id: { label: 'Person ID', @@ -47,7 +48,8 @@ const action: ActionDefinition = { }, event_id: { label: 'Event ID', - description: 'An optional identifier used to deduplicate events. [Learn more](https://customer.io/docs/api/#operation/track).', + description: + 'An optional identifier used to deduplicate events. [Learn more](https://customer.io/docs/api/#operation/track).', type: 'string', default: { '@path': '$.messageId' @@ -77,43 +79,26 @@ const action: ActionDefinition = { } }, - perform: (request, { settings, payload }) => { - let timestamp: string | number | undefined = payload.timestamp - let data = payload.data - - if (payload.convert_timestamp !== false) { - if (timestamp) { - timestamp = convertValidTimestamp(timestamp) - } - - if (data) { - data = convertAttributeTimestamps(data) - } - } - - const body: TrackEventPayload = { - name: payload.name, - data, - timestamp - } - - if (payload.event_id) { - body.id = payload.event_id - } + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ action: 'event', payload: mapPayload(payload), settings, type: 'person' })) + ) + }, - let url: string + perform: (request, { payload, settings }) => { + return sendSingle(request, { action: 'event', payload: mapPayload(payload), settings, type: 'person' }) + } +} - if (payload.id) { - url = `${trackApiEndpoint(settings.accountRegion)}/api/v1/customers/${payload.id}/events` - } else { - url = `${trackApiEndpoint(settings.accountRegion)}/api/v1/events` - body.anonymous_id = payload.anonymous_id - } +function mapPayload(payload: Payload) { + const { id, event_id, data, ...rest } = payload - return request(url, { - method: 'post', - json: body - }) + return { + ...rest, + person_id: id, + id: event_id, + attributes: data } } diff --git a/packages/destination-actions/src/destinations/customerio/trackPageView/generated-types.ts b/packages/destination-actions/src/destinations/customerio/trackPageView/generated-types.ts index 5136f23e6e..a8a9f0bfc3 100644 --- a/packages/destination-actions/src/destinations/customerio/trackPageView/generated-types.ts +++ b/packages/destination-actions/src/destinations/customerio/trackPageView/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * An anonymous ID for when no Person ID exists. [Learn more](https://customer.io/docs/anonymous-events/). */ anonymous_id?: string + /** + * An optional identifier used to deduplicate events. [Learn more](https://customer.io/docs/api/#operation/track). + */ + event_id?: string /** * The URL of the page visited. */ diff --git a/packages/destination-actions/src/destinations/customerio/trackPageView/index.ts b/packages/destination-actions/src/destinations/customerio/trackPageView/index.ts index 4f840cc0c5..dad5ba6f1f 100644 --- a/packages/destination-actions/src/destinations/customerio/trackPageView/index.ts +++ b/packages/destination-actions/src/destinations/customerio/trackPageView/index.ts @@ -1,16 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' -import { convertAttributeTimestamps, convertValidTimestamp, trackApiEndpoint } from '../utils' import type { Payload } from './generated-types' - -interface TrackPageViewPayload { - name: string - type: 'page' - timestamp?: string | number - data?: Record - // Required for anonymous events - anonymous_id?: string -} +import { sendBatch, sendSingle } from '../utils' const action: ActionDefinition = { title: 'Track Page View', @@ -35,6 +26,15 @@ const action: ActionDefinition = { '@path': '$.anonymousId' } }, + event_id: { + label: 'Event ID', + description: + 'An optional identifier used to deduplicate events. [Learn more](https://customer.io/docs/api/#operation/track).', + type: 'string', + default: { + '@path': '$.messageId' + } + }, url: { label: 'Page URL', description: 'The URL of the page visited.', @@ -67,41 +67,38 @@ const action: ActionDefinition = { default: true } }, - perform: (request, { settings, payload }) => { - let timestamp: string | number | undefined = payload.timestamp - let data = payload.data - if (payload.convert_timestamp !== false) { - if (timestamp) { - timestamp = convertValidTimestamp(timestamp) - } - - if (data) { - data = convertAttributeTimestamps(data) - } - } - - const body: TrackPageViewPayload = { - name: payload.url, - type: 'page', - data, - timestamp - } + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ action: 'page', payload: mapPayload(payload), settings, type: 'person' })) + ) + }, - let url: string + perform: (request, { payload, settings }) => { + return sendSingle(request, { action: 'page', payload: mapPayload(payload), settings, type: 'person' }) + } +} - if (payload.id) { - url = `${trackApiEndpoint(settings.accountRegion)}/api/v1/customers/${payload.id}/events` - } else { - url = `${trackApiEndpoint(settings.accountRegion)}/api/v1/events` - body.anonymous_id = payload.anonymous_id - } +function mapPayload(payload: Payload) { + const { id, event_id, url, data, ...rest } = payload + const result: { + id?: string + person_id?: string + name: string + attributes?: Record + } = { + ...rest, + person_id: id, + name: url, + attributes: data + } - return request(url, { - method: 'post', - json: body - }) + if (event_id) { + result.id = event_id } + + return result } export default action diff --git a/packages/destination-actions/src/destinations/customerio/trackScreenView/generated-types.ts b/packages/destination-actions/src/destinations/customerio/trackScreenView/generated-types.ts index 10008eebfc..167322742f 100644 --- a/packages/destination-actions/src/destinations/customerio/trackScreenView/generated-types.ts +++ b/packages/destination-actions/src/destinations/customerio/trackScreenView/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * An anonymous ID for when no Person ID exists. [Learn more](https://customer.io/docs/anonymous-events/). */ anonymous_id?: string + /** + * An optional identifier used to deduplicate events. [Learn more](https://customer.io/docs/api/#operation/track). + */ + event_id?: string /** * The name of the screen visited. */ diff --git a/packages/destination-actions/src/destinations/customerio/trackScreenView/index.ts b/packages/destination-actions/src/destinations/customerio/trackScreenView/index.ts index ad1d783452..0bba9b147a 100644 --- a/packages/destination-actions/src/destinations/customerio/trackScreenView/index.ts +++ b/packages/destination-actions/src/destinations/customerio/trackScreenView/index.ts @@ -1,16 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' -import { convertAttributeTimestamps, convertValidTimestamp, trackApiEndpoint } from '../utils' import type { Payload } from './generated-types' - -interface TrackScreenViewPayload { - name: string - type: 'screen' - timestamp?: string | number - data?: Record - // Required for anonymous events - anonymous_id?: string -} +import { sendBatch, sendSingle } from '../utils' const action: ActionDefinition = { title: 'Track Screen View', @@ -35,6 +26,15 @@ const action: ActionDefinition = { '@path': '$.anonymousId' } }, + event_id: { + label: 'Event ID', + description: + 'An optional identifier used to deduplicate events. [Learn more](https://customer.io/docs/api/#operation/track).', + type: 'string', + default: { + '@path': '$.messageId' + } + }, name: { label: 'Screen name', description: 'The name of the screen visited.', @@ -67,41 +67,36 @@ const action: ActionDefinition = { default: true } }, - perform: (request, { settings, payload }) => { - let timestamp: string | number | undefined = payload.timestamp - let data = payload.data - if (payload.convert_timestamp !== false) { - if (timestamp) { - timestamp = convertValidTimestamp(timestamp) - } - - if (data) { - data = convertAttributeTimestamps(data) - } - } - - const body: TrackScreenViewPayload = { - name: payload.name, - type: 'screen', - data, - timestamp - } + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ action: 'screen', payload: mapPayload(payload), settings, type: 'person' })) + ) + }, - let url: string + perform: (request, { payload, settings }) => { + return sendSingle(request, { action: 'screen', payload: mapPayload(payload), settings, type: 'person' }) + } +} - if (payload.id) { - url = `${trackApiEndpoint(settings.accountRegion)}/api/v1/customers/${payload.id}/events` - } else { - url = `${trackApiEndpoint(settings.accountRegion)}/api/v1/events` - body.anonymous_id = payload.anonymous_id - } +function mapPayload(payload: Payload) { + const { id, event_id, data, ...rest } = payload + const result: { + id?: string + person_id?: string + attributes?: Record + } = { + ...rest, + person_id: id, + attributes: data + } - return request(url, { - method: 'post', - json: body - }) + if (event_id) { + result.id = event_id } + + return result } export default action diff --git a/packages/destination-actions/src/destinations/customerio/unsuppressPerson/generated-types.ts b/packages/destination-actions/src/destinations/customerio/unsuppressPerson/generated-types.ts new file mode 100644 index 0000000000..378d503661 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/unsuppressPerson/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the person that this mobile device belongs to. + */ + person_id: string +} diff --git a/packages/destination-actions/src/destinations/customerio/unsuppressPerson/index.ts b/packages/destination-actions/src/destinations/customerio/unsuppressPerson/index.ts new file mode 100644 index 0000000000..ed606fc720 --- /dev/null +++ b/packages/destination-actions/src/destinations/customerio/unsuppressPerson/index.ts @@ -0,0 +1,44 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { sendBatch, sendSingle } from '../utils' + +const action: ActionDefinition = { + title: 'Unsuppress Person', + description: `Unsuppress a person in Customer.io. This will allow the person to receive messages again.`, + defaultSubscription: 'event = "User Unsuppressed"', + fields: { + person_id: { + label: 'Person ID', + description: 'The ID of the person that this mobile device belongs to.', + type: 'string', + required: true, + default: { + '@path': '$.userId' + } + } + }, + + performBatch: (request, { payload: payloads, settings }) => { + return sendBatch( + request, + payloads.map((payload) => ({ + action: 'unsuppress', + payload, + settings, + type: 'person' + })) + ) + }, + + perform: (request, { payload, settings }) => { + return sendSingle(request, { + action: 'unsuppress', + payload, + settings, + type: 'person' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/customerio/utils.ts b/packages/destination-actions/src/destinations/customerio/utils.ts index a6e5d4c8ea..13a1ad04f1 100644 --- a/packages/destination-actions/src/destinations/customerio/utils.ts +++ b/packages/destination-actions/src/destinations/customerio/utils.ts @@ -1,17 +1,9 @@ import dayjs from '../../lib/dayjs' import isPlainObject from 'lodash/isPlainObject' +import { fullFormats } from 'ajv-formats/dist/formats' -export const trackApiEndpoint = (accountRegion?: string) => { - if (accountRegion === AccountRegion.EU) { - return 'https://track-eu.customer.io' - } - - return 'https://track.customer.io' -} - -export enum AccountRegion { - US = 'US 🇺🇸', - EU = 'EU 🇪🇺' +const isEmail = (value: string): boolean => { + return (fullFormats.email as RegExp).test(value) } const isRecord = (value: unknown): value is Record => { @@ -31,6 +23,19 @@ const isIsoDate = (value: string): boolean => { return typeof value === 'string' && matcher.test(value) && !isNaN(Date.parse(value)) } +export const trackApiEndpoint = ({ accountRegion }: { accountRegion?: string }) => { + if (accountRegion === AccountRegion.EU) { + return 'https://track-eu.customer.io' + } + + return 'https://track.customer.io' +} + +export enum AccountRegion { + US = 'US 🇺🇸', + EU = 'EU 🇪🇺' +} + export const convertValidTimestamp = (value: Value): Value | number => { // Timestamps may be on a `string` field, so check if the string is only // numbers. If it is, ignore it since it's probably already a unix timestamp. @@ -50,11 +55,12 @@ export const convertValidTimestamp = (value: Value): Value | nu } // Recursively walk through an object and try to convert any strings into dates -export const convertAttributeTimestamps = (payload: Record): Record => { - const clone: Record = {} +export const convertAttributeTimestamps = (payload: Payload): Payload => { + const clone = {} as Payload const keys = Object.keys(payload) - keys.forEach((key) => { + keys.forEach((k) => { + const key = k as keyof Payload const value = payload[key] if (typeof value === 'string') { @@ -62,7 +68,7 @@ export const convertAttributeTimestamps = (payload: Record): Re const maybeDate = dayjs(value) if (isIsoDate(value)) { - clone[key] = maybeDate.unix() + ;(clone[key] as unknown) = maybeDate.unix() return } } @@ -78,3 +84,124 @@ export const convertAttributeTimestamps = (payload: Record): Re return clone } + +type RequestPayload = { + settings: { + accountRegion?: string + trackEndpoint?: string + } + type: string + action: string + payload: Payload +} + +type Identifiers = { + anonymous_id?: string + cio_id?: string + email?: string + id?: string + object_id?: string + object_type_id?: string + primary?: Identifiers + secondary?: Identifiers +} + +type BasePayload = { + anonymous_id?: string + convert_timestamp?: boolean + email?: string + object_id?: string + object_type_id?: string + person_id?: string + primary?: Identifiers + secondary?: Identifiers +} + +export const buildPayload = ({ action, type, payload }: RequestPayload) => { + const { convert_timestamp, person_id, anonymous_id, email, object_id, object_type_id, ...data } = payload + let rest = data + + if ('convert_timestamp' in payload && convert_timestamp !== false) { + rest = convertAttributeTimestamps(rest) + } + + const body: { + attributes?: Record + cio_relationships?: Record[] + identifiers?: Identifiers + type: string + action: string + object_id?: string + object_type_id?: string + } = { + type, + action, + ...rest + } + + if (anonymous_id) { + body.attributes = { ...body.attributes, anonymous_id: anonymous_id } + } + + // `merge` is the only action that does not require identifiers at the root level. + if (action !== 'merge') { + body.identifiers = resolveIdentifiers({ anonymous_id, email, object_id, object_type_id, person_id }) + } + + // Remove unnecessary anonymous_id attribute if it's also in the identifiers object. + if (body.identifiers && 'anonymous_id' in body.identifiers) { + delete body.attributes?.anonymous_id + } + + return body +} + +export const resolveIdentifiers = ({ + anonymous_id, + email, + object_id, + object_type_id = '1', + person_id +}: Record): Identifiers | undefined => { + if (object_id && object_type_id) { + return { + object_id: object_id as string, + object_type_id: object_type_id as string + } + } else if ((person_id as string)?.startsWith('cio_')) { + return { cio_id: (person_id as string).slice(4) } + } else if (isEmail(person_id as string)) { + return { email: person_id as string } + } else if (person_id) { + return { id: person_id as string } + } else if (email) { + return { email: email as string } + } else if (anonymous_id) { + return { anonymous_id: anonymous_id as string } + } +} + +export const sendBatch = (request: Function, options: RequestPayload[]) => { + if (!options?.length) { + return + } + + const [{ settings }] = options + const batch = options.map((opts) => buildPayload(opts)) + + return request(`${trackApiEndpoint(settings)}/api/v2/batch`, { + method: 'post', + json: { + batch + } + }) +} + +export const sendSingle = (request: Function, options: RequestPayload) => { + const json = buildPayload(options) + + return request(`${trackApiEndpoint(options.settings)}/api/v2/entity`, { + method: 'post', + json + }) +} diff --git a/packages/destination-actions/src/destinations/devrev/createRevUser/index.ts b/packages/destination-actions/src/destinations/devrev/createRevUser/index.ts index 99667ffe98..c3c61ce3ae 100644 --- a/packages/destination-actions/src/destinations/devrev/createRevUser/index.ts +++ b/packages/destination-actions/src/destinations/devrev/createRevUser/index.ts @@ -13,7 +13,8 @@ import { devrevApiPaths, CreateAccountBody, getName, - getBaseUrl + getBaseUrl, + CreateRevUserBody } from '../utils' import { APIError } from '@segment/actions-core' @@ -155,14 +156,16 @@ const action: ActionDefinition = { (revorg) => revorg.external_ref_issuer == 'devrev:platform:revorg:account' ) revOrgId = filtered[0].id + const createUserPayload: CreateRevUserBody = { + email, + display_name: name, + external_ref: email, + org_id: revOrgId + } + if (payload.tag) createUserPayload.tags = [{ id: payload.tag }] const createRevUser: RevUserGet = await request(`${getBaseUrl(settings)}${devrevApiPaths.revUsersCreate}`, { method: 'post', - json: { - email, - full_name: name, - external_ref: email, - org_id: revOrgId - } + json: createUserPayload }) revUserId = createRevUser.data.rev_user.id } else if (existingUsers.data.rev_users.length == 1) { diff --git a/packages/destination-actions/src/destinations/devrev/mocks/index.ts b/packages/destination-actions/src/destinations/devrev/mocks/index.ts index 226e83c0e8..4468852dd1 100644 --- a/packages/destination-actions/src/destinations/devrev/mocks/index.ts +++ b/packages/destination-actions/src/destinations/devrev/mocks/index.ts @@ -3,7 +3,7 @@ import * as types from '../utils/types' interface revUserCreateBody { email: string - full_name: string + display_name: string org_id: string } @@ -124,7 +124,7 @@ export const revUsersCreateResponse = async (_: never, body: revUserCreateBody) rev_user: { id: testRevUserNewer.id, created_date: newerCreateDate, - display_name: body.full_name, + display_name: body.display_name, email: body.email, rev_org: { id: body.org_id, diff --git a/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 1ad684a65d..15703e577a 100644 --- a/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,6 +4,7 @@ exports[`Testing snapshot for Devrev's streamEvent destination action: all field Object { "events_list": Array [ Object { + "event_id": "sZcTM%n(kDC3tsz4iK5h", "event_time": "2021-02-01T00:00:00.000Z", "name": "sZcTM%n(kDC3tsz4iK5h", "payload": Object { @@ -33,6 +34,7 @@ exports[`Testing snapshot for Devrev's streamEvent destination action: required Object { "events_list": Array [ Object { + "event_id": "test-event-id", "event_time": "2021-02-01T00:00:00.000Z", "name": "sZcTM%n(kDC3tsz4iK5h", "payload": Object { diff --git a/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/index.test.ts index 512e827867..9a9abbd43f 100644 --- a/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/index.test.ts @@ -56,6 +56,7 @@ describe('Devrev.streamEvent', () => { events_list: [ { name: testEventPayload.event as string, + event_id: testMessageId, event_time: testEventPayload.timestamp as string, payload: { eventName: testEventPayload.event, diff --git a/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/snapshot.test.ts index 49d925943f..3cb2d9547d 100644 --- a/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/devrev/streamEvent/__tests__/snapshot.test.ts @@ -3,6 +3,10 @@ import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' import nock from 'nock' +jest.mock('@lukeed/uuid', () => ({ + v4: jest.fn(() => 'test-event-id') +})) + const testDestination = createTestIntegration(destination) const actionSlug = 'streamEvent' const destinationSlug = 'Devrev' diff --git a/packages/destination-actions/src/destinations/devrev/streamEvent/index.ts b/packages/destination-actions/src/destinations/devrev/streamEvent/index.ts index 29456865c4..7cc518750f 100644 --- a/packages/destination-actions/src/destinations/devrev/streamEvent/index.ts +++ b/packages/destination-actions/src/destinations/devrev/streamEvent/index.ts @@ -3,6 +3,7 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { TrackEventsPublishBody, devrevApiPaths, getBaseUrl } from '../utils' import { RequestOptions } from '@segment/actions-core' +import { v4 as uuidv4 } from '@lukeed/uuid' const action: ActionDefinition = { title: 'Stream Event', @@ -122,6 +123,7 @@ const action: ActionDefinition = { { name: eventName, event_time: timestamp.toString(), + event_id: payload.messageId || uuidv4(), payload: { // add mapped data to payload ...payload, diff --git a/packages/destination-actions/src/destinations/devrev/utils/types.ts b/packages/destination-actions/src/destinations/devrev/utils/types.ts index acfe04c2e2..78c4f08440 100644 --- a/packages/destination-actions/src/destinations/devrev/utils/types.ts +++ b/packages/destination-actions/src/destinations/devrev/utils/types.ts @@ -119,7 +119,16 @@ export interface CreateAccountBody { external_refs: string[] } +export interface CreateRevUserBody { + email: string + external_ref: string + display_name?: string + tags?: { id: string }[] + org_id?: string +} + export interface TraceEvent { + event_id: string event_time: string name: string payload: object diff --git a/packages/destination-actions/src/destinations/display-video-360/__tests__/index.test.ts b/packages/destination-actions/src/destinations/display-video-360/__tests__/index.test.ts new file mode 100644 index 0000000000..580db7b3d5 --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/__tests__/index.test.ts @@ -0,0 +1,183 @@ +import nock from 'nock' +import { createTestIntegration, IntegrationError } from '@segment/actions-core' +import Destination from '../index' +import { GET_AUDIENCE_URL, CREATE_AUDIENCE_URL, OAUTH_URL } from '../constants' + +const advertiserId = '424242' +const audienceName = 'The Super Mario Brothers Fans' +const testDestination = createTestIntegration(Destination) +const advertiserCreateAudienceUrl = CREATE_AUDIENCE_URL.replace('advertiserID', advertiserId) +const advertiserGetAudienceUrl = GET_AUDIENCE_URL.replace('advertiserID', advertiserId) +const expectedExternalID = `products/DISPLAY_VIDEO_ADVERTISER/customers/${advertiserId}/userLists/8457147615` +const accountType = 'DISPLAY_VIDEO_ADVERTISER' + +const createAudienceInput = { + settings: { + oauth: { + refresh_token: 'freshy', + access_token: 'tok3n', + clientId: '123', + clientSecret: '123' + } + }, + audienceName: '', + audienceSettings: { + advertiserId: advertiserId, + accountType: accountType + } +} + +const getAudienceInput = { + settings: { + oauth: { + refresh_token: 'freshy', + access_token: 'tok3n', + client_id: '123', + client_secret: '123' + } + }, + audienceSettings: { + advertiserId: advertiserId, + accountType: accountType + }, + audienceName: audienceName, + externalId: expectedExternalID +} + +const getAudienceResponse = [ + { + results: [ + { + userList: { + resourceName: expectedExternalID, + membershipStatus: 'OPEN', + name: audienceName, + description: 'Created by Segment.' + } + } + ], + fieldMask: 'userList.name,userList.description,userList.membershipStatus,userList.matchRatePercentage', + requestId: 'Hw7-_h0P-vCzQ' + } +] + +describe('Display Video 360', () => { + describe('createAudience', () => { + it('should fail if no audience name is set', async () => { + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + + it('should fail if no advertiser ID is set', async () => { + createAudienceInput.audienceName = 'The Void' + createAudienceInput.audienceSettings.advertiserId = '' + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + + it('creates an audience', async () => { + nock(OAUTH_URL).post(/.*/).reply(200, { access_token: 'tok3n' }) + nock(advertiserCreateAudienceUrl) + .post(/.*/) + .reply(200, { + results: [ + { + resourceName: `products/DISPLAY_VIDEO_ADVERTISER/customers/${advertiserId}/userLists/8460733279` + } + ] + }) + + createAudienceInput.audienceName = audienceName + createAudienceInput.audienceSettings.advertiserId = advertiserId + + const r = await testDestination.createAudience(createAudienceInput) + expect(r).toEqual({ + externalId: `products/DISPLAY_VIDEO_ADVERTISER/customers/${advertiserId}/userLists/8460733279` + }) + }) + + it('errors out when audience with same name already exists', async () => { + nock(advertiserCreateAudienceUrl) + .post(/.*/) + .reply(400, { + error: { + code: 400, + message: 'Request contains an invalid argument.', + status: 'INVALID_ARGUMENT', + details: [ + { + '@type': 'type.googleapis.com/google.ads.audiencepartner.v2.errors.AudiencePartnerFailure', + errors: [ + { + errorCode: { + userListError: 'NAME_ALREADY_USED' + }, + message: 'Name is already being used for another user list for the account.', + trigger: { + stringValue: audienceName + }, + location: { + fieldPathElements: [ + { + fieldName: 'operations', + index: 0 + }, + { + fieldName: 'create' + }, + { + fieldName: 'name' + } + ] + } + } + ], + requestId: 'gMjeoMWem82kFnHKBnmzsA' + } + ] + } + }) + + createAudienceInput.audienceName = audienceName + createAudienceInput.audienceSettings.advertiserId = advertiserId + + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + }) + + describe('getAudience', () => { + it("should fail if Segment Audience ID doesn't match Google Audience ID", async () => { + const bogusGetAudienceInput = { + ...getAudienceInput, + externalId: 'bogus' + } + + nock(OAUTH_URL).post(/.*/).reply(200, { access_token: 'tok3n' }) + nock(advertiserGetAudienceUrl).post(/.*/).reply(200, getAudienceResponse) + await expect(testDestination.getAudience(bogusGetAudienceInput)).rejects.toThrowError(IntegrationError) + }) + + it('should succeed when Segment Audience ID matches Google audience ID', async () => { + nock(OAUTH_URL).post(/.*/).reply(200, { access_token: 'tok3n' }) + nock(advertiserGetAudienceUrl).post(/.*/).reply(200, getAudienceResponse) + + const r = await testDestination.getAudience(getAudienceInput) + expect(r).toEqual({ + externalId: expectedExternalID + }) + }) + + it('should succeed when the destination instance is flagged as a migration instance', async () => { + const migrationGetAudienceInput = { + ...getAudienceInput, + settings: {}, // Settings for migration instances are set as {} in the migration script. + externalId: 'iWasHereInTheBeforeTimes' + } + + nock(advertiserGetAudienceUrl).post(/.*/).reply(200, getAudienceResponse) + + const r = await testDestination.getAudience(migrationGetAudienceInput) + expect(r).toEqual({ + externalId: 'iWasHereInTheBeforeTimes' + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/display-video-360/__tests__/shared.test.ts b/packages/destination-actions/src/destinations/display-video-360/__tests__/shared.test.ts new file mode 100644 index 0000000000..360fd821e8 --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/__tests__/shared.test.ts @@ -0,0 +1,255 @@ +import nock from 'nock' +import { + buildHeaders, + assembleRawOps, + bulkUploaderResponseHandler, + createUpdateRequest, + sendUpdateRequest +} from '../shared' +import { AudienceSettings } from '../generated-types' +import { UpdateHandlerPayload } from '../types' +import { UpdateUsersDataResponse, ErrorCode, ErrorInfo } from '../proto/protofile' +import { StatsContext, Response } from '@segment/actions-core' +import createRequestClient from '../../../../../core/src/create-request-client' + +const oneMockPayload: UpdateHandlerPayload = { + external_audience_id: 'products/DISPLAY_VIDEO_ADVERTISER/customers/123/userLists/456', + google_gid: 'CAESEHIV8HXNp0pFdHgi2rElMfk', + mobile_advertising_id: '3b6e47b3-1437-4ba2-b3c9-446e4d0cd1e5', + partner_provided_id: 'my-anon-id-42', + enable_batching: true +} + +const mockRequestClient = createRequestClient() + +const manyMockPayloads: UpdateHandlerPayload[] = [ + oneMockPayload, + { + external_audience_id: 'products/DISPLAY_VIDEO_ADVERTISER/customers/123/userLists/456', + partner_provided_id: 'my-anon-id-43', + enable_batching: true + }, + { + external_audience_id: 'products/DISPLAY_VIDEO_ADVERTISER/customers/123/userLists/456', + google_gid: 'XNp0pFdHgi2rElMfk', + enable_batching: true + } +] + +const mockStatsClient = { + incr: jest.fn(), + observe: jest.fn(), + _name: jest.fn(), + _tags: jest.fn(), + histogram: jest.fn(), + set: jest.fn() +} + +const mockStatsContext = { + statsClient: mockStatsClient, + tags: [] +} as StatsContext + +const getRandomError = () => { + // possible errors for this stage are BAD_DATA, BAD_COOKIE, BAD_ATTRIBUTE_ID, BAD_NETWORK_ID. + const random = Math.floor(Math.random() * 4) + switch (random) { + case 0: + return ErrorCode.BAD_DATA + case 1: + return ErrorCode.BAD_COOKIE + case 2: + return ErrorCode.BAD_ATTRIBUTE_ID + case 3: + return ErrorCode.BAD_NETWORK_ID + } +} + +// Mock only the error code. The contents of the response are not important. +const createMockResponse = (errorCode: ErrorCode, payload: UpdateHandlerPayload[]) => { + const responseHandler = new UpdateUsersDataResponse() + responseHandler.status = errorCode + + if (errorCode === ErrorCode.PARTIAL_SUCCESS) { + // Making assumptions about IdType and UserId here because + // we are not currently testing their content therefore, it doesn't matter. + + responseHandler.errors = payload.map((p) => { + const errorInfo = new ErrorInfo() + errorInfo.errorCode = getRandomError() + errorInfo.userListId = BigInt(p.external_audience_id.split('/').pop() || '-1') + errorInfo.userIdType = 0 + errorInfo.userId = p.google_gid || p.mobile_advertising_id || p.partner_provided_id || '' + return errorInfo + }) + } + + const b = Buffer.from(responseHandler.toBinary()) + const arrayBuffer = b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength) + + return new Response(arrayBuffer, { status: errorCode === ErrorCode.NO_ERROR ? 200 : 400 }) +} + +describe('shared', () => { + describe('buildHeaders', () => { + it('should build headers correctly', () => { + const accessToken = 'real-token' + const audienceSettings: AudienceSettings = { + advertiserId: '123', + accountType: 'DISPLAY_VIDEO_ADVERTISER' + } + + const result = buildHeaders(audienceSettings, accessToken) + expect(result).toEqual({ + Authorization: 'Bearer real-token', + 'Content-Type': 'application/json', + 'Login-Customer-Id': 'products/DATA_PARTNER/customers/1663649500', + 'Linked-Customer-Id': 'products/DISPLAY_VIDEO_ADVERTISER/customers/123' + }) + }) + }) + + describe('assembleRawOps', () => { + it('should return an array of UserOperation objects with IDFA', () => { + const results = assembleRawOps(oneMockPayload, 'add') + expect(results).toEqual([ + { + UserId: 'CAESEHIV8HXNp0pFdHgi2rElMfk', + UserIdType: 0, + UserListId: 456, + Delete: false + }, + { + UserId: '3b6e47b3-1437-4ba2-b3c9-446e4d0cd1e5', + UserIdType: 1, + UserListId: 456, + Delete: false + }, + { + UserId: 'my-anon-id-42', + UserIdType: 4, + UserListId: 456, + Delete: false + } + ]) + }) + + it('should return an array of UserOperation objects with Android Advertising ID', () => { + oneMockPayload.mobile_advertising_id = '3b6e47b314374ba2b3c9446e4d0cd1e5' + + const results = assembleRawOps(oneMockPayload, 'remove') + expect(results).toEqual([ + { + UserId: 'CAESEHIV8HXNp0pFdHgi2rElMfk', + UserIdType: 0, + UserListId: 456, + Delete: true + }, + { + UserId: '3b6e47b314374ba2b3c9446e4d0cd1e5', + UserIdType: 2, + UserListId: 456, + Delete: true + }, + { + UserId: 'my-anon-id-42', + UserIdType: 4, + UserListId: 456, + Delete: true + } + ]) + }) + }) + + // This method is used for both success and error cases. + // The easiest way to tell if something worked is to check the calls to statsClient + // The assumptions made around the payload are based on the error codes described in the proto file. + describe('bulkUploaderResponseHandler', () => { + it('handles success', async () => { + const mockResponse: Response = createMockResponse(ErrorCode.NO_ERROR, manyMockPayloads) + const statsName = 'addToAudience' + + await bulkUploaderResponseHandler(mockResponse, statsName, mockStatsContext) + expect(mockStatsClient.incr).toHaveBeenCalledWith(`${statsName}.success`, 1, mockStatsContext.tags) + }) + + it('handles 400 error', async () => { + const mockResponse: Response = createMockResponse(ErrorCode.BAD_COOKIE, manyMockPayloads) + const statsName = 'addToAudience' + + await bulkUploaderResponseHandler(mockResponse, statsName, mockStatsContext) + expect(mockStatsClient.incr).toHaveBeenCalledWith(`${statsName}.error.BAD_COOKIE`, 1, mockStatsContext.tags) + }) + + it('handles 500 error', async () => { + const mockResponse: Response = createMockResponse(ErrorCode.INTERNAL_ERROR, manyMockPayloads) + const statsName = 'removeFromAudience' + + await expect(bulkUploaderResponseHandler(mockResponse, statsName, mockStatsContext)).rejects.toThrow( + 'Bulk Uploader Internal Error' + ) + + expect(mockStatsClient.incr).toHaveBeenCalledWith(`${statsName}.error.INTERNAL_ERROR`, 1, mockStatsContext.tags) + }) + }) + + // If the request is invalid, its serialization will throw an error. + // No need to test the contents of the object because that is covered in assembleRawOps. + describe('createUpdateRequest', () => { + it('should create an UpdateUsersDataRequest object with the correct number of operations', () => { + const r = createUpdateRequest(manyMockPayloads, 'add') + expect(r.ops.length).toEqual(5) + }) + + it('should throw an error when unable to create UpdateUsersDataRequest', () => { + const mockPayload = { + enable_batching: true + } as UpdateHandlerPayload + expect(() => createUpdateRequest([mockPayload], 'remove')).toThrowError() + }) + }) + + // Not testing payload content here because it's covered by the bulkUploaderResponseHandler. + // Attempting to assemble a valid response payload is not worth the effort. + describe('sendUpdateRequest', () => { + it('should succeed', async () => { + nock('https://cm.g.doubleclick.net').post('/upload?nid=segment').reply(200) + + const r = createUpdateRequest(manyMockPayloads, 'add') + await sendUpdateRequest(mockRequestClient, r, 'addToAudience', mockStatsContext) + expect(mockStatsClient.incr).toHaveBeenCalledWith('addToAudience.success', 1, mockStatsContext.tags) + }) + + // To gracefully fails means that the request was successful, but some of the operations failed. + // The response will contain a list of errors. Its content is unknown. + // The endpoint will return a 400 status code. + it('should gracefully fail', async () => { + nock('https://cm.g.doubleclick.net').post('/upload?nid=segment').reply(400) + + UpdateUsersDataResponse.prototype.fromBinary = jest.fn(() => { + const responseHandler = new UpdateUsersDataResponse() + responseHandler.status = ErrorCode.PARTIAL_SUCCESS + responseHandler.errors = [ + { + errorCode: ErrorCode.BAD_DATA, + userListId: BigInt(456), + userIdType: 0, + userId: 'CAESEHIV8HXNp0pFdHgi2rElMfk' + } as ErrorInfo + ] + return responseHandler + }) + + const r = createUpdateRequest(manyMockPayloads, 'add') + await sendUpdateRequest(mockRequestClient, r, 'addToAudience', mockStatsContext) + expect(mockStatsClient.incr).toHaveBeenCalledWith('addToAudience.error.PARTIAL_SUCCESS', 1, mockStatsContext.tags) + }) + + it('should abruptly fail', async () => { + nock('https://cm.g.doubleclick.net').post('/upload?nid=segment').reply(500) + + const r = createUpdateRequest(manyMockPayloads, 'add') + await expect(sendUpdateRequest(mockRequestClient, r, 'addToAudience', mockStatsContext)).rejects.toThrow() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/display-video-360/addToAudience/generated-types.ts b/packages/destination-actions/src/destinations/display-video-360/addToAudience/generated-types.ts new file mode 100644 index 0000000000..995036efed --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/addToAudience/generated-types.ts @@ -0,0 +1,24 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Enable batching of requests to the TikTok Audiences. + */ + enable_batching: boolean + /** + * The Audience ID in Google's DB. + */ + external_audience_id: string + /** + * Mobile Advertising ID. Android Advertising ID or iOS IDFA. + */ + mobile_advertising_id?: string + /** + * Google GID - ID is deprecated in some areas and will eventually sunset. ID is included for those who were on the legacy destination. + */ + google_gid?: string + /** + * Partner Provided ID - Equivalent to the Segment Anonymous ID. Segment Audience must include Anonymous Ids to match effectively. + */ + partner_provided_id?: string +} diff --git a/packages/destination-actions/src/destinations/display-video-360/addToAudience/index.ts b/packages/destination-actions/src/destinations/display-video-360/addToAudience/index.ts new file mode 100644 index 0000000000..79abe4a8ca --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/addToAudience/index.ts @@ -0,0 +1,40 @@ +import type { ActionDefinition } from '@segment/actions-core' + +import type { Settings, AudienceSettings } from '../generated-types' +import type { Payload } from './generated-types' +import { handleUpdate } from '../shared' + +import { + enable_batching, + external_audience_id, + google_gid, + mobile_advertising_id, + partner_provided_id +} from '../properties' + +const action: ActionDefinition = { + title: 'Add to Audience', + description: 'Add a user to a Display & Video 360 audience.', + defaultSubscription: 'event = "Audience Entered"', + fields: { + enable_batching: { ...enable_batching }, + external_audience_id: { ...external_audience_id }, + mobile_advertising_id: { ...mobile_advertising_id }, + google_gid: { ...google_gid }, + partner_provided_id: { ...partner_provided_id } + }, + perform: async (request, { payload, statsContext }) => { + statsContext?.tags.push('slug:actions-display-video-360') + statsContext?.statsClient?.incr('addToAudience', 1, statsContext?.tags) + await handleUpdate(request, [payload], 'add', statsContext) + return { success: true } + }, + performBatch: async (request, { payload, statsContext }) => { + statsContext?.tags.push('slug:actions-display-video-360') + statsContext?.statsClient?.incr('addToAudience.batch', 1, statsContext?.tags) + await handleUpdate(request, payload, 'add', statsContext) + return { success: true } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/display-video-360/constants.ts b/packages/destination-actions/src/destinations/display-video-360/constants.ts new file mode 100644 index 0000000000..bb1194eb18 --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/constants.ts @@ -0,0 +1,8 @@ +export const GOOGLE_API_VERSION = 'v2' +// accountType and advertiserID are used as markers to be replaced in the code. DO NOT REMOVE THEM. +export const BASE_URL = `https://audiencepartner.googleapis.com/${GOOGLE_API_VERSION}/products/accountType/customers/advertiserID/` +export const CREATE_AUDIENCE_URL = `${BASE_URL}userLists:mutate` +export const GET_AUDIENCE_URL = `${BASE_URL}audiencePartner:searchStream` +export const OAUTH_URL = 'https://accounts.google.com/o/oauth2/token' +export const USER_UPLOAD_ENDPOINT = 'https://cm.g.doubleclick.net/upload?nid=segment' +export const SEGMENT_DMP_ID = '1663649500' diff --git a/packages/destination-actions/src/destinations/display-video-360/errors.ts b/packages/destination-actions/src/destinations/display-video-360/errors.ts new file mode 100644 index 0000000000..c34851eda8 --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/errors.ts @@ -0,0 +1,67 @@ +import { ErrorCodes, IntegrationError, InvalidAuthenticationError } from '@segment/actions-core' +import { StatsContext } from '@segment/actions-core/destination-kit' + +import { GoogleAPIError } from './types' + +const isGoogleAPIError = (error: unknown): error is GoogleAPIError => { + if (typeof error === 'object' && error !== null) { + const e = error as GoogleAPIError + // Not using any forces us to check for all the properties we need. + return ( + typeof e.response === 'object' && + e.response !== null && + typeof e.response.data === 'object' && + e.response.data !== null && + typeof e.response.data.error === 'object' && + e.response.data.error !== null + ) + } + return false +} + +// This method follows the retry logic defined in IntegrationError in the actions-core package +export const handleRequestError = (error: unknown, statsName: string, statsContext: StatsContext | undefined) => { + const { statsClient, tags: statsTags } = statsContext || {} + + if (!isGoogleAPIError(error)) { + if (!error) { + statsTags?.push('error:unknown') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError('Unknown error', 'UNKNOWN_ERROR', 500) + } + } + + const gError = error as GoogleAPIError + const code = gError.response?.status + + // @ts-ignore - Errors can be objects or arrays of objects. This will work for both. + const message = gError.response?.data?.error?.message || gError.response?.data?.[0]?.error?.message + + if (code === 401) { + statsTags?.push('error:invalid-authentication') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new InvalidAuthenticationError(message, ErrorCodes.INVALID_AUTHENTICATION) + } + + if (code === 403) { + statsTags?.push('error:forbidden') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'FORBIDDEN', 403) + } + + if (code === 501) { + statsTags?.push('error:integration-error') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'INTEGRATION_ERROR', 501) + } + + if (code === 408 || code === 423 || code === 429 || code >= 500) { + statsTags?.push('error:retryable-error') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'RETRYABLE_ERROR', code) + } + + statsTags?.push('error:generic-error') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + return new IntegrationError(message, 'INTEGRATION_ERROR', code) +} diff --git a/packages/destination-actions/src/destinations/display-video-360/generated-types.ts b/packages/destination-actions/src/destinations/display-video-360/generated-types.ts new file mode 100644 index 0000000000..b36920d339 --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/generated-types.ts @@ -0,0 +1,15 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings {} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface AudienceSettings { + /** + * The ID of your advertiser, used throughout Display & Video 360. Use this ID when you contact Display & Video 360 support to help our teams locate your specific account. + */ + advertiserId: string + /** + * The type of the advertiser account you have linked to this Display & Video 360 destination. + */ + accountType: string +} diff --git a/packages/destination-actions/src/destinations/display-video-360/index.ts b/packages/destination-actions/src/destinations/display-video-360/index.ts new file mode 100644 index 0000000000..f0d88db297 --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/index.ts @@ -0,0 +1,200 @@ +import { AudienceDestinationDefinition, IntegrationError } from '@segment/actions-core' + +import type { Settings, AudienceSettings } from './generated-types' +import type { RefreshTokenResponse } from './types' + +import addToAudience from './addToAudience' +import removeFromAudience from './removeFromAudience' + +import { CREATE_AUDIENCE_URL, GET_AUDIENCE_URL, OAUTH_URL } from './constants' +import { buildHeaders, getAuthToken, getAuthSettings, isLegacyDestinationMigration } from './shared' +import { handleRequestError } from './errors' + +const destination: AudienceDestinationDefinition = { + name: 'Display and Video 360 (Actions)', + slug: 'actions-display-video-360', + mode: 'cloud', + authentication: { + scheme: 'oauth2', + fields: {}, // Fields is required. Left empty on purpose. + refreshAccessToken: async (request, { auth }) => { + const { data } = await request(OAUTH_URL, { + method: 'POST', + body: new URLSearchParams({ + refresh_token: process.env.ACTIONS_DISPLAY_VIDEO_360_REFRESH_TOKEN as string, + client_id: auth.clientId, + client_secret: auth.clientSecret, + grant_type: 'refresh_token' + }) + }) + return { accessToken: data.access_token } + } + }, + audienceFields: { + advertiserId: { + type: 'string', + label: 'Advertiser ID', + required: true, + description: + 'The ID of your advertiser, used throughout Display & Video 360. Use this ID when you contact Display & Video 360 support to help our teams locate your specific account.' + }, + accountType: { + type: 'string', + label: 'Account Type', + description: 'The type of the advertiser account you have linked to this Display & Video 360 destination.', + required: true, + choices: [ + { label: 'Advertiser', value: 'DISPLAY_VIDEO_ADVERTISER' }, + { label: 'Partner', value: 'DISPLAY_VIDEO_PARTNER' }, + { label: 'Publisher', value: 'GOOGLE_AD_MANAGER' } + ] + } + }, + audienceConfig: { + mode: { + type: 'synced', + full_audience_sync: false + }, + async createAudience(request, createAudienceInput) { + const { audienceName, audienceSettings, statsContext, settings } = createAudienceInput + const { advertiserId, accountType } = audienceSettings || {} + const { statsClient, tags: statsTags } = statsContext || {} + const statsName = 'createAudience' + statsTags?.push(`slug:${destination.slug}`) + statsClient?.incr(`${statsName}.call`, 1, statsTags) + + // @ts-ignore - TS doesn't know about the oauth property + const authSettings = getAuthSettings(settings) + + if (!audienceName) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing audience name value', 'MISSING_REQUIRED_FIELD', 400) + } + + if (!advertiserId) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing advertiser ID value', 'MISSING_REQUIRED_FIELD', 400) + } + + if (!accountType) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing account type value', 'MISSING_REQUIRED_FIELD', 400) + } + + const listTypeMap = { basicUserList: {}, type: 'REMARKETING', membershipStatus: 'OPEN' } + const partnerCreateAudienceUrl = CREATE_AUDIENCE_URL.replace('advertiserID', advertiserId).replace( + 'accountType', + accountType + ) + + let response + try { + const authToken = await getAuthToken(request, authSettings) + response = await request(partnerCreateAudienceUrl, { + headers: buildHeaders(createAudienceInput.audienceSettings, authToken), + method: 'POST', + json: { + operations: [ + { + create: { + ...listTypeMap, + name: audienceName, + description: 'Created by Segment', + membershipLifeSpan: '540' + } + } + ] + } + }) + + const r = await response?.json() + statsClient?.incr(`${statsName}.success`, 1, statsTags) + + return { + externalId: r['results'][0]['resourceName'] + } + } catch (error) { + throw handleRequestError(error, statsName, statsContext) + } + }, + async getAudience(request, getAudienceInput) { + const { statsContext, audienceSettings, settings } = getAudienceInput + const { statsClient, tags: statsTags } = statsContext || {} + const { advertiserId, accountType } = audienceSettings || {} + const statsName = 'getAudience' + statsTags?.push(`slug:${destination.slug}`) + statsClient?.incr(`${statsName}.call`, 1, statsTags) + + // @ts-ignore - TS doesn't know about the oauth property + const authSettings = getAuthSettings(settings) + + if (!advertiserId) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing required advertiser ID value', 'MISSING_REQUIRED_FIELD', 400) + } + + if (!accountType) { + statsTags?.push('error:missing-settings') + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError('Missing account type value', 'MISSING_REQUIRED_FIELD', 400) + } + + // Legacy destinations don't have an auth object until customers log-in to the new destination. + // However, the bulkUploader API doesn't require an auth object, so we can use the externalId to verify ownership. + // Only legacy destinations will have an externalId and no auth object. + if (isLegacyDestinationMigration(getAudienceInput, authSettings)) { + statsClient?.incr(`${statsName}.legacy`, 1, statsTags) + + return { + externalId: getAudienceInput.externalId + } + } + + const advertiserGetAudienceUrl = GET_AUDIENCE_URL.replace('advertiserID', advertiserId).replace( + 'accountType', + accountType + ) + + try { + const authToken = await getAuthToken(request, authSettings) + const response = await request(advertiserGetAudienceUrl, { + headers: buildHeaders(audienceSettings, authToken), + method: 'POST', + json: { + query: `SELECT user_list.name, user_list.description, user_list.membership_status, user_list.match_rate_percentage FROM user_list WHERE user_list.resource_name = "${getAudienceInput.externalId}"` + } + }) + + const r = await response.json() + + const externalId = r[0]?.results[0]?.userList?.resourceName + + if (externalId !== getAudienceInput.externalId) { + statsClient?.incr(`${statsName}.error`, 1, statsTags) + throw new IntegrationError( + "Unable to verify ownership over audience. Segment Audience ID doesn't match Googles Audience ID.", + 'INVALID_REQUEST_DATA', + 400 + ) + } + + statsClient?.incr(`${statsName}.success`, 1, statsTags) + return { + externalId: externalId + } + } catch (error) { + throw handleRequestError(error, statsName, statsContext) + } + } + }, + actions: { + addToAudience, + removeFromAudience + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/display-video-360/properties.ts b/packages/destination-actions/src/destinations/display-video-360/properties.ts new file mode 100644 index 0000000000..b77fd31cfe --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/properties.ts @@ -0,0 +1,53 @@ +import { InputField } from '@segment/actions-core/destination-kit/types' + +export const mobile_advertising_id: InputField = { + label: 'Mobile Advertising ID', + description: 'Mobile Advertising ID. Android Advertising ID or iOS IDFA.', + type: 'string', + required: false, + default: { + '@path': '$.context.device.advertisingId' + } +} + +export const google_gid: InputField = { + label: 'Google GID', + description: + 'Google GID - ID is deprecated in some areas and will eventually sunset. ID is included for those who were on the legacy destination.', + type: 'string', + required: false, + default: { + '@path': '$.context.DV360.google_gid' + } +} + +export const partner_provided_id: InputField = { + label: 'Partner Provided ID', + description: + 'Partner Provided ID - Equivalent to the Segment Anonymous ID. Segment Audience must include Anonymous Ids to match effectively.', + type: 'string', + required: false, + default: { + '@path': '$.anonymousId' + } +} + +export const enable_batching: InputField = { + label: 'Enable Batching', + description: 'Enable batching of requests to the TikTok Audiences.', + type: 'boolean', + default: true, + required: true, + unsafe_hidden: true +} + +export const external_audience_id: InputField = { + label: 'External Audience ID', + description: "The Audience ID in Google's DB.", + type: 'string', + required: true, + unsafe_hidden: true, + default: { + '@path': '$.context.personas.external_audience_id' + } +} diff --git a/packages/destination-actions/src/destinations/display-video-360/proto/protofile.ts b/packages/destination-actions/src/destinations/display-video-360/proto/protofile.ts new file mode 100644 index 0000000000..bc4ce5eeab --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/proto/protofile.ts @@ -0,0 +1,664 @@ +// @generated by protoc-gen-es v1.2.0 with parameter "target=ts" +// @generated from file dmp.proto (syntax proto2) +/* eslint-disable */ +// @ts-nocheck + +import type { + BinaryReadOptions, + FieldList, + JsonReadOptions, + JsonValue, + PartialMessage, + PlainMessage +} from '@bufbuild/protobuf' +import { Message, proto2, protoInt64 } from '@bufbuild/protobuf' + +/** + * The type of identifier being uploaded. + * + * @generated from enum UserIdType + */ +export enum UserIdType { + /** + * A user identifier received through the cookie matching service. + * + * @generated from enum value: GOOGLE_USER_ID = 0; + */ + GOOGLE_USER_ID = 0, + + /** + * iOS Advertising ID. + * + * @generated from enum value: IDFA = 1; + */ + IDFA = 1, + + /** + * Android Advertising ID. + * + * @generated from enum value: ANDROID_ADVERTISING_ID = 2; + */ + ANDROID_ADVERTISING_ID = 2, + + /** + * Roku ID. + * + * @generated from enum value: RIDA = 5; + */ + RIDA = 5, + + /** + * Amazon Fire TV ID. + * + * @generated from enum value: AFAI = 6; + */ + AFAI = 6, + + /** + * XBOX/Microsoft ID. + * + * @generated from enum value: MSAI = 7; + */ + MSAI = 7, + + /** + * A "generic" category for any UUID formatted device provided ID. + * Allows partner uploads without needing to select a specific, + * pre-existing Device ID type. + * + * @generated from enum value: GENERIC_DEVICE_ID = 9; + */ + GENERIC_DEVICE_ID = 9, + + /** + * Partner provided ID. User identifier in partner's namespace. + * If the partner has sent the partner user identifier during cookie matching, + * then Google will be able to store user list membership associated with + * the partner's user identifier. + * See cookie matching documentation: + * https://developers.google.com/authorized-buyers/rtb/cookie-guide + * + * @generated from enum value: PARTNER_PROVIDED_ID = 4; + */ + PARTNER_PROVIDED_ID = 4 +} +// Retrieve enum metadata with: proto2.getEnumType(UserIdType) +proto2.util.setEnumType(UserIdType, 'UserIdType', [ + { no: 0, name: 'GOOGLE_USER_ID' }, + { no: 1, name: 'IDFA' }, + { no: 2, name: 'ANDROID_ADVERTISING_ID' }, + { no: 5, name: 'RIDA' }, + { no: 6, name: 'AFAI' }, + { no: 7, name: 'MSAI' }, + { no: 9, name: 'GENERIC_DEVICE_ID' }, + { no: 4, name: 'PARTNER_PROVIDED_ID' } +]) + +/** + * Notification code. + * + * @generated from enum NotificationCode + */ +export enum NotificationCode { + /** + * A cookie is considered inactive if Google has not seen any activity related + * to the cookie in several days. + * + * @generated from enum value: INACTIVE_COOKIE = 0; + */ + INACTIVE_COOKIE = 0 +} +// Retrieve enum metadata with: proto2.getEnumType(NotificationCode) +proto2.util.setEnumType(NotificationCode, 'NotificationCode', [{ no: 0, name: 'INACTIVE_COOKIE' }]) + +/** + * Notification status code. + * + * @generated from enum NotificationStatus + */ +export enum NotificationStatus { + /** + * No need to send notifications for this request. + * + * @generated from enum value: NO_NOTIFICATION = 0; + */ + NO_NOTIFICATION = 0, + + /** + * Google decided to not send notifications, even though there were + * notifications to send. + * + * @generated from enum value: NOTIFICATIONS_OMITTED = 1; + */ + NOTIFICATIONS_OMITTED = 1 +} +// Retrieve enum metadata with: proto2.getEnumType(NotificationStatus) +proto2.util.setEnumType(NotificationStatus, 'NotificationStatus', [ + { no: 0, name: 'NO_NOTIFICATION' }, + { no: 1, name: 'NOTIFICATIONS_OMITTED' } +]) + +/** + * Response error codes. + * + * @generated from enum ErrorCode + */ +export enum ErrorCode { + /** + * @generated from enum value: NO_ERROR = 0; + */ + NO_ERROR = 0, + + /** + * Some of the user data operations failed. See comments in the + * UpdateUserDataResponse + * + * @generated from enum value: PARTIAL_SUCCESS = 1; + */ + PARTIAL_SUCCESS = 1, + + /** + * Provided network_id cannot add data to attribute_id or non-HTTPS. + * + * @generated from enum value: PERMISSION_DENIED = 2; + */ + PERMISSION_DENIED = 2, + + /** + * Cannot parse payload. + * + * @generated from enum value: BAD_DATA = 3; + */ + BAD_DATA = 3, + + /** + * Cannot decode provided cookie. + * + * @generated from enum value: BAD_COOKIE = 4; + */ + BAD_COOKIE = 4, + + /** + * Invalid or closed user_list_id. + * + * @generated from enum value: BAD_ATTRIBUTE_ID = 5; + */ + BAD_ATTRIBUTE_ID = 5, + + /** + * An invalid nid parameter was provided in the request. + * + * @generated from enum value: BAD_NETWORK_ID = 7; + */ + BAD_NETWORK_ID = 7, + + /** + * Request payload size over allowed limit. + * + * @generated from enum value: REQUEST_TOO_BIG = 8; + */ + REQUEST_TOO_BIG = 8, + + /** + * No UserDataOperation messages in UpdateUsersDataRequest. + * + * @generated from enum value: EMPTY_REQUEST = 9; + */ + EMPTY_REQUEST = 9, + + /** + * The server could not process the request due to an internal error. Retrying + * the same request later is suggested. + * + * @generated from enum value: INTERNAL_ERROR = 10; + */ + INTERNAL_ERROR = 10, + + /** + * Bad data_source_id -- most likely out of range from [1, 1000]. + * + * @generated from enum value: BAD_DATA_SOURCE_ID = 11; + */ + BAD_DATA_SOURCE_ID = 11, + + /** + * The timestamp is a past/future time that is too far from current time. + * + * @generated from enum value: BAD_TIMESTAMP = 12; + */ + BAD_TIMESTAMP = 12, + + /** + * Missing internal mapping. + * If operation is PARTNER_PROVIDED_ID, then this error means our mapping + * table does not contain corresponding google user id. This mapping is + * recorded during Cookie Matching. + * For other operations, then it may be internal error. + * + * @generated from enum value: UNKNOWN_ID = 21; + */ + UNKNOWN_ID = 21 +} +// Retrieve enum metadata with: proto2.getEnumType(ErrorCode) +proto2.util.setEnumType(ErrorCode, 'ErrorCode', [ + { no: 0, name: 'NO_ERROR' }, + { no: 1, name: 'PARTIAL_SUCCESS' }, + { no: 2, name: 'PERMISSION_DENIED' }, + { no: 3, name: 'BAD_DATA' }, + { no: 4, name: 'BAD_COOKIE' }, + { no: 5, name: 'BAD_ATTRIBUTE_ID' }, + { no: 7, name: 'BAD_NETWORK_ID' }, + { no: 8, name: 'REQUEST_TOO_BIG' }, + { no: 9, name: 'EMPTY_REQUEST' }, + { no: 10, name: 'INTERNAL_ERROR' }, + { no: 11, name: 'BAD_DATA_SOURCE_ID' }, + { no: 12, name: 'BAD_TIMESTAMP' }, + { no: 21, name: 'UNKNOWN_ID' } +]) + +/** + * Update data for a single user. + * + * @generated from message UserDataOperation + */ +export class UserDataOperation extends Message { + /** + * User id. The type is determined by the user_id_type field. + * + * Must always be present. Specifies which user this operation applies to. + * + * @generated from field: optional string user_id = 1 [default = ""]; + */ + userId?: string + + /** + * The type of the user id. + * + * @generated from field: optional UserIdType user_id_type = 14 [default = GOOGLE_USER_ID]; + */ + userIdType?: UserIdType + + /** + * The id of the userlist. This can be retrieved from the AdX UI for AdX + * customers, the AdWords API for non-AdX customers, or through your Technical + * Account Manager. + * + * @generated from field: optional int64 user_list_id = 4 [default = 0]; + */ + userListId?: bigint + + /** + * Optional time (seconds since the epoch) when the user performed an action + * causing them to be added to the list. Using the default value of 0 + * indicates that the current time on the server should be used. + * + * @generated from field: optional int64 time_added_to_user_list = 5 [default = 0]; + */ + timeAddedToUserList?: bigint + + /** + * Same as time_added_to_user_list but with finer grained time resolution, in + * microseconds. If both timestamps are specified, + * time_added_to_user_list_in_usec will be used. + * + * @generated from field: optional int64 time_added_to_user_list_in_usec = 8 [default = 0]; + */ + timeAddedToUserListInUsec?: bigint + + /** + * Set to true if the operation is a deletion. + * + * @generated from field: optional bool delete = 6 [default = false]; + */ + delete?: boolean + + /** + * Set true if the user opted out from being targeted. + * + * @generated from field: optional bool opt_out = 12 [default = false]; + */ + optOut?: boolean + + /** + * An id indicating the data source which contributed this membership. The id + * is required to be in the range of 1 to 1000 and any ids greater than this + * will result in an error of type BAD_DATA_SOURCE_ID. These ids don't have + * any semantics for Google and may be used as labels for reporting purposes. + * + * @generated from field: optional int32 data_source_id = 7 [default = 0]; + */ + dataSourceId?: number + + constructor(data?: PartialMessage) { + super() + proto2.util.initPartial(data, this) + } + + static readonly runtime: typeof proto2 = proto2 + static readonly typeName = 'UserDataOperation' + static readonly fields: FieldList = proto2.util.newFieldList(() => [ + { no: 1, name: 'user_id', kind: 'scalar', T: 9 /* ScalarType.STRING */, opt: true, default: '' }, + { + no: 14, + name: 'user_id_type', + kind: 'enum', + T: proto2.getEnumType(UserIdType), + opt: true, + default: UserIdType.GOOGLE_USER_ID + }, + { + no: 4, + name: 'user_list_id', + kind: 'scalar', + T: 3 /* ScalarType.INT64 */, + opt: true, + default: protoInt64.parse('0') + }, + { + no: 5, + name: 'time_added_to_user_list', + kind: 'scalar', + T: 3 /* ScalarType.INT64 */, + opt: true, + default: protoInt64.parse('0') + }, + { + no: 8, + name: 'time_added_to_user_list_in_usec', + kind: 'scalar', + T: 3 /* ScalarType.INT64 */, + opt: true, + default: protoInt64.parse('0') + }, + { no: 6, name: 'delete', kind: 'scalar', T: 8 /* ScalarType.BOOL */, opt: true, default: false }, + { no: 12, name: 'opt_out', kind: 'scalar', T: 8 /* ScalarType.BOOL */, opt: true, default: false }, + { no: 7, name: 'data_source_id', kind: 'scalar', T: 5 /* ScalarType.INT32 */, opt: true, default: 0 } + ]) + + static fromBinary(bytes: Uint8Array, options?: Partial): UserDataOperation { + return new UserDataOperation().fromBinary(bytes, options) + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UserDataOperation { + return new UserDataOperation().fromJson(jsonValue, options) + } + + static fromJsonString(jsonString: string, options?: Partial): UserDataOperation { + return new UserDataOperation().fromJsonString(jsonString, options) + } + + static equals( + a: UserDataOperation | PlainMessage | undefined, + b: UserDataOperation | PlainMessage | undefined + ): boolean { + return proto2.util.equals(UserDataOperation, a, b) + } +} + +/** + * This protocol buffer is used to update user data. It is sent as the payload + * of an HTTPS POST request with the Content-Type header set to + * "application/octet-stream" (preferrably Content-Encoding: gzip). + * + * @generated from message UpdateUsersDataRequest + */ +export class UpdateUsersDataRequest extends Message { + /** + * Multiple operations over user attributes or user lists. + * + * @generated from field: repeated UserDataOperation ops = 1; + */ + ops: UserDataOperation[] = [] + + /** + * If true, request sending notifications about the given users in the + * response. Note that in some circumstances notifications may not be sent + * even if requested. In this case the notification_status field of the + * response will be set to NOTIFICATIONS_OMITTED. + * + * @generated from field: optional bool send_notifications = 2 [default = false]; + */ + sendNotifications?: boolean + + constructor(data?: PartialMessage) { + super() + proto2.util.initPartial(data, this) + } + + static readonly runtime: typeof proto2 = proto2 + static readonly typeName = 'UpdateUsersDataRequest' + static readonly fields: FieldList = proto2.util.newFieldList(() => [ + { no: 1, name: 'ops', kind: 'message', T: UserDataOperation, repeated: true }, + { no: 2, name: 'send_notifications', kind: 'scalar', T: 8 /* ScalarType.BOOL */, opt: true, default: false } + ]) + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateUsersDataRequest { + return new UpdateUsersDataRequest().fromBinary(bytes, options) + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateUsersDataRequest { + return new UpdateUsersDataRequest().fromJson(jsonValue, options) + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateUsersDataRequest { + return new UpdateUsersDataRequest().fromJsonString(jsonString, options) + } + + static equals( + a: UpdateUsersDataRequest | PlainMessage | undefined, + b: UpdateUsersDataRequest | PlainMessage | undefined + ): boolean { + return proto2.util.equals(UpdateUsersDataRequest, a, b) + } + + process_consent: boolean = false +} + +/** + * Information about an error with an individual user operation. + * + * @generated from message ErrorInfo + */ +export class ErrorInfo extends Message { + /** + * The user_list_id in the request which caused problems. This may be empty + * if the problem was with a particular user id. + * + * @generated from field: optional int64 user_list_id = 2 [default = 0]; + */ + userListId?: bigint + + /** + * The user_id which caused problems. This may be empty if other data was bad + * regardless of a cookie. + * + * @generated from field: optional string user_id = 3 [default = ""]; + */ + userId?: string + + /** + * The type of the user ID. + * + * @generated from field: optional UserIdType user_id_type = 7 [default = GOOGLE_USER_ID]; + */ + userIdType?: UserIdType + + /** + * @generated from field: optional ErrorCode error_code = 4; + */ + errorCode?: ErrorCode + + constructor(data?: PartialMessage) { + super() + proto2.util.initPartial(data, this) + } + + static readonly runtime: typeof proto2 = proto2 + static readonly typeName = 'ErrorInfo' + static readonly fields: FieldList = proto2.util.newFieldList(() => [ + { + no: 2, + name: 'user_list_id', + kind: 'scalar', + T: 3 /* ScalarType.INT64 */, + opt: true, + default: protoInt64.parse('0') + }, + { no: 3, name: 'user_id', kind: 'scalar', T: 9 /* ScalarType.STRING */, opt: true, default: '' }, + { + no: 7, + name: 'user_id_type', + kind: 'enum', + T: proto2.getEnumType(UserIdType), + opt: true, + default: UserIdType.GOOGLE_USER_ID + }, + { no: 4, name: 'error_code', kind: 'enum', T: proto2.getEnumType(ErrorCode), opt: true } + ]) + + static fromBinary(bytes: Uint8Array, options?: Partial): ErrorInfo { + return new ErrorInfo().fromBinary(bytes, options) + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ErrorInfo { + return new ErrorInfo().fromJson(jsonValue, options) + } + + static fromJsonString(jsonString: string, options?: Partial): ErrorInfo { + return new ErrorInfo().fromJsonString(jsonString, options) + } + + static equals( + a: ErrorInfo | PlainMessage | undefined, + b: ErrorInfo | PlainMessage | undefined + ): boolean { + return proto2.util.equals(ErrorInfo, a, b) + } +} + +/** + * Per user notification information. + * + * @generated from message NotificationInfo + */ +export class NotificationInfo extends Message { + /** + * The user_id for which the notification applies. One of the user_ids sent + * in a UserDataOperation. + * + * @generated from field: optional string user_id = 1 [default = ""]; + */ + userId?: string + + /** + * @generated from field: optional NotificationCode notification_code = 2; + */ + notificationCode?: NotificationCode + + constructor(data?: PartialMessage) { + super() + proto2.util.initPartial(data, this) + } + + static readonly runtime: typeof proto2 = proto2 + static readonly typeName = 'NotificationInfo' + static readonly fields: FieldList = proto2.util.newFieldList(() => [ + { no: 1, name: 'user_id', kind: 'scalar', T: 9 /* ScalarType.STRING */, opt: true, default: '' }, + { no: 2, name: 'notification_code', kind: 'enum', T: proto2.getEnumType(NotificationCode), opt: true } + ]) + + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationInfo { + return new NotificationInfo().fromBinary(bytes, options) + } + + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationInfo { + return new NotificationInfo().fromJson(jsonValue, options) + } + + static fromJsonString(jsonString: string, options?: Partial): NotificationInfo { + return new NotificationInfo().fromJsonString(jsonString, options) + } + + static equals( + a: NotificationInfo | PlainMessage | undefined, + b: NotificationInfo | PlainMessage | undefined + ): boolean { + return proto2.util.equals(NotificationInfo, a, b) + } +} + +/** + * Response to the UpdateUsersDataRequest. Sent in HTTP response to the + * original POST request, with the Content-Type header set to + * "application/octet-stream". The HTTP response status is either 200 (no + * errors) or 400, in which case the protocol buffer will provide error details. + * + * @generated from message UpdateUsersDataResponse + */ +export class UpdateUsersDataResponse extends Message { + /** + * When status == PARTIAL_SUCCESS, some (not all) of the operations failed and + * the "errors" field has details on the types and number of errors + * encountered. When status == NO_ERROR, all the data was imported + * successfully. When status > PARTIAL_SUCCESS no data was imported. + * + * @generated from field: optional ErrorCode status = 1; + */ + status?: ErrorCode + + /** + * Each operation that failed is reported as a separate error here when + * status == PARTIAL_SUCCESS. + * + * @generated from field: repeated ErrorInfo errors = 2; + */ + errors: ErrorInfo[] = [] + + /** + * Useful, non-error, information about the user ids in the request. Each + * NotificationInfo provides information about a single user id. Only sent if send_notifications is set to true. + * + * @generated from field: repeated NotificationInfo notifications = 3; + */ + notifications: NotificationInfo[] = [] + + /** + * Indicates why a notification has not been sent. + * + * @generated from field: optional NotificationStatus notification_status = 4; + */ + notificationStatus?: NotificationStatus + + constructor(data?: PartialMessage) { + super() + proto2.util.initPartial(data, this) + } + + static readonly runtime: typeof proto2 = proto2 + static readonly typeName = 'UpdateUsersDataResponse' + static readonly fields: FieldList = proto2.util.newFieldList(() => [ + { no: 1, name: 'status', kind: 'enum', T: proto2.getEnumType(ErrorCode), opt: true }, + { no: 2, name: 'errors', kind: 'message', T: ErrorInfo, repeated: true }, + { no: 3, name: 'notifications', kind: 'message', T: NotificationInfo, repeated: true }, + { no: 4, name: 'notification_status', kind: 'enum', T: proto2.getEnumType(NotificationStatus), opt: true } + ]) + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateUsersDataResponse { + return new UpdateUsersDataResponse().fromBinary(bytes, options) + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateUsersDataResponse { + return new UpdateUsersDataResponse().fromJson(jsonValue, options) + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateUsersDataResponse { + return new UpdateUsersDataResponse().fromJsonString(jsonString, options) + } + + static equals( + a: UpdateUsersDataResponse | PlainMessage | undefined, + b: UpdateUsersDataResponse | PlainMessage | undefined + ): boolean { + return proto2.util.equals(UpdateUsersDataResponse, a, b) + } +} diff --git a/packages/destination-actions/src/destinations/display-video-360/removeFromAudience/generated-types.ts b/packages/destination-actions/src/destinations/display-video-360/removeFromAudience/generated-types.ts new file mode 100644 index 0000000000..995036efed --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/removeFromAudience/generated-types.ts @@ -0,0 +1,24 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Enable batching of requests to the TikTok Audiences. + */ + enable_batching: boolean + /** + * The Audience ID in Google's DB. + */ + external_audience_id: string + /** + * Mobile Advertising ID. Android Advertising ID or iOS IDFA. + */ + mobile_advertising_id?: string + /** + * Google GID - ID is deprecated in some areas and will eventually sunset. ID is included for those who were on the legacy destination. + */ + google_gid?: string + /** + * Partner Provided ID - Equivalent to the Segment Anonymous ID. Segment Audience must include Anonymous Ids to match effectively. + */ + partner_provided_id?: string +} diff --git a/packages/destination-actions/src/destinations/display-video-360/removeFromAudience/index.ts b/packages/destination-actions/src/destinations/display-video-360/removeFromAudience/index.ts new file mode 100644 index 0000000000..4d0cb7176a --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/removeFromAudience/index.ts @@ -0,0 +1,40 @@ +import type { ActionDefinition } from '@segment/actions-core' + +import type { Settings, AudienceSettings } from '../generated-types' +import type { Payload } from './generated-types' +import { handleUpdate } from '../shared' + +import { + enable_batching, + external_audience_id, + mobile_advertising_id, + google_gid, + partner_provided_id +} from '../properties' + +const action: ActionDefinition = { + title: 'Remove from Audience', + description: 'Remove users from an audience', + defaultSubscription: 'event = "Audience Exited"', + fields: { + enable_batching: { ...enable_batching }, + external_audience_id: { ...external_audience_id }, + mobile_advertising_id: { ...mobile_advertising_id }, + google_gid: { ...google_gid }, + partner_provided_id: { ...partner_provided_id } + }, + perform: async (request, { payload, statsContext }) => { + statsContext?.tags.push('slug:actions-display-video-360') + statsContext?.statsClient?.incr('removeFromAudience', 1, statsContext?.tags) + await handleUpdate(request, [payload], 'remove', statsContext) + return { success: true } + }, + performBatch: async (request, { payload, statsContext }) => { + statsContext?.tags.push('slug:actions-display-video-360') + statsContext?.statsClient?.incr('removeFromAudience.batch', 1, statsContext?.tags) + await handleUpdate(request, payload, 'remove', statsContext) + return { success: true } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/display-video-360/shared.ts b/packages/destination-actions/src/destinations/display-video-360/shared.ts new file mode 100644 index 0000000000..e813c15e2d --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/shared.ts @@ -0,0 +1,245 @@ +import { IntegrationError, RequestClient, StatsContext } from '@segment/actions-core' +import { OAUTH_URL, USER_UPLOAD_ENDPOINT, SEGMENT_DMP_ID } from './constants' +import type { RefreshTokenResponse } from './types' + +import { + UserIdType, + UpdateUsersDataRequest, + UserDataOperation, + UpdateUsersDataResponse, + ErrorCode +} from './proto/protofile' + +import { ListOperation, UpdateHandlerPayload, UserOperation } from './types' +import type { AudienceSettings, Settings } from './generated-types' +import { GetAudienceInput } from '@segment/actions-core/destination-kit/execute' + +type SettingsWithOauth = Settings & { oauth: DV360AuthCredentials } +type DV360AuthCredentials = { refresh_token: string; access_token: string; client_id: string; client_secret: string } + +export const isLegacyDestinationMigration = ( + getAudienceInput: GetAudienceInput, + authSettings: DV360AuthCredentials +): boolean => { + const noOAuth = !authSettings.refresh_token || !authSettings.access_token + const hasExternalAudienceId = getAudienceInput.externalId !== undefined + return noOAuth && hasExternalAudienceId +} + +export const getAuthSettings = (settings: SettingsWithOauth): DV360AuthCredentials => { + if (!settings.oauth) { + return {} as DV360AuthCredentials + } + + return { + refresh_token: settings.oauth.refresh_token, + access_token: settings.oauth.access_token, + client_id: process.env.ACTIONS_DISPLAY_VIDEO_360_CLIENT_ID, + client_secret: process.env.ACTIONS_DISPLAY_VIDEO_360_CLIENT_SECRET + } as DV360AuthCredentials +} + +// Use the refresh token to get a new access token. +// Refresh tokens are long-lived and belong to the user. +// Client_id and secret belong to the application. +// Given the short expiration time of access tokens, we need to refresh them periodically. +export const getAuthToken = async (request: RequestClient, settings: DV360AuthCredentials) => { + if (!settings.refresh_token) { + throw new IntegrationError('Refresh token is missing', 'INVALID_REQUEST_DATA', 400) + } + + const { data } = await request(OAUTH_URL, { + method: 'POST', + body: new URLSearchParams({ + refresh_token: settings.refresh_token, + client_id: settings.client_id, + client_secret: settings.client_secret, + grant_type: 'refresh_token' + }) + }) + + return data.access_token +} + +export const buildHeaders = (audienceSettings: AudienceSettings | undefined, accessToken: string) => { + if (!audienceSettings || !accessToken) { + throw new IntegrationError('Bad Request', 'INVALID_REQUEST_DATA', 400) + } + + return { + // @ts-ignore - TS doesn't know about the oauth property + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Login-Customer-Id': `products/DATA_PARTNER/customers/${SEGMENT_DMP_ID}`, + 'Linked-Customer-Id': `products/${audienceSettings.accountType}/customers/${audienceSettings?.advertiserId}` + } +} + +export const assembleRawOps = (payload: UpdateHandlerPayload, operation: ListOperation): UserOperation[] => { + const rawOperations = [] + const audienceId = parseInt(payload.external_audience_id.split('/').pop() || '-1') + const isDelete = operation === 'remove' + + if (payload.google_gid) { + rawOperations.push({ + UserId: payload.google_gid, + UserIdType: UserIdType.GOOGLE_USER_ID, + UserListId: audienceId, + Delete: isDelete + }) + } + + if (payload.mobile_advertising_id) { + const isIDFA = payload.mobile_advertising_id.includes('-') + + rawOperations.push({ + UserId: payload.mobile_advertising_id, + UserIdType: isIDFA ? UserIdType.IDFA : UserIdType.ANDROID_ADVERTISING_ID, + UserListId: audienceId, + Delete: isDelete + }) + } + + if (payload.partner_provided_id) { + rawOperations.push({ + UserId: payload.partner_provided_id, + UserIdType: UserIdType.PARTNER_PROVIDED_ID, + UserListId: audienceId, + Delete: isDelete + }) + } + + return rawOperations +} + +const handleErrorCode = ( + errorCodeString: string, + r: UpdateUsersDataResponse, + statsName: string, + statsContext: StatsContext | undefined +) => { + if (errorCodeString === 'PARTIAL_SUCCESS') { + statsContext?.statsClient.incr(`${statsName}.error.PARTIAL_SUCCESS`, 1, statsContext?.tags) + r.errors?.forEach((e) => { + if (e.errorCode) { + statsContext?.statsClient.incr(`${statsName}.error.${ErrorCode[e.errorCode]}`, 1, statsContext?.tags) + } + }) + } else { + statsContext?.statsClient.incr(`${statsName}.error.${errorCodeString}`, 1, statsContext?.tags) + } +} + +export const bulkUploaderResponseHandler = async ( + response: Response, + statsName: string, + statsContext: StatsContext | undefined +) => { + if (!response || !response.body) { + throw new IntegrationError(`Something went wrong unpacking the protobuf response`, 'INVALID_REQUEST_DATA', 400) + } + + const responseHandler = new UpdateUsersDataResponse() + const buffer = await response.arrayBuffer() + const protobufResponse = Buffer.from(buffer) + + const r = responseHandler.fromBinary(protobufResponse) + const errorCode = r.status as ErrorCode + const errorCodeString = ErrorCode[errorCode] || 'UNKNOWN_ERROR' + + if (errorCodeString === 'NO_ERROR' || response.status === 200) { + statsContext?.statsClient.incr(`${statsName}.success`, 1, statsContext?.tags) + } else { + handleErrorCode(errorCodeString, r, statsName, statsContext) + // Only internal errors shall be retried as they imply a temporary issue. + // The rest of the errors are permanent and shall be discarded. + // This emulates the legacy behavior of the DV360 destination. + if (errorCode === ErrorCode.INTERNAL_ERROR) { + statsContext?.statsClient.incr(`${statsName}.error.INTERNAL_ERROR`, 1, statsContext?.tags) + throw new IntegrationError('Bulk Uploader Internal Error', 'INTERNAL_SERVER_ERROR', 500) + } + } +} + +// To interact with the bulk uploader, we need to create a protobuf object as defined in the proto file. +// This method takes the raw payload and creates the protobuf object. +export const createUpdateRequest = ( + payload: UpdateHandlerPayload[], + operation: 'add' | 'remove' +): UpdateUsersDataRequest => { + const updateRequest = new UpdateUsersDataRequest() + + payload.forEach((p) => { + const rawOps = assembleRawOps(p, operation) + + // Every ID will generate an operation. + // That means that if google_gid, mobile_advertising_id, and anonymous_id are all present, we will create 3 operations. + // This emulates the legacy behavior of the DV360 destination. + rawOps.forEach((rawOp) => { + const op = new UserDataOperation({ + userId: rawOp.UserId, + userIdType: rawOp.UserIdType, + userListId: BigInt(rawOp.UserListId), + delete: rawOp.Delete + }) + + if (!op) { + throw new Error('Unable to create UserDataOperation') + } + + updateRequest.ops.push(op) + }) + }) + + // Backed by deletion and suppression features in Segment. + updateRequest.process_consent = true + + return updateRequest +} + +export const sendUpdateRequest = async ( + request: RequestClient, + updateRequest: UpdateUsersDataRequest, + statsName: string, + statsContext: StatsContext | undefined +) => { + const binaryOperation = updateRequest.toBinary() + + try { + const response = await request(USER_UPLOAD_ENDPOINT, { + headers: { 'Content-Type': 'application/octet-stream' }, + body: binaryOperation, + method: 'POST' + }) + + await bulkUploaderResponseHandler(response, statsName, statsContext) + } catch (error) { + if (error.response?.status === 500) { + throw new IntegrationError(error.response.message, 'INTERNAL_SERVER_ERROR', 500) + } + + await bulkUploaderResponseHandler(error.response, statsName, statsContext) + } +} + +export const handleUpdate = async ( + request: RequestClient, + payload: UpdateHandlerPayload[], + operation: 'add' | 'remove', + statsContext: StatsContext | undefined +) => { + const statsName = operation === 'add' ? 'addToAudience' : 'removeFromAudience' + statsContext?.statsClient?.incr(`${statsName}.call`, 1, statsContext?.tags) + + const updateRequest = createUpdateRequest(payload, operation) + + if (updateRequest.ops.length !== 0) { + await sendUpdateRequest(request, updateRequest, statsName, statsContext) + } else { + statsContext?.statsClient.incr(`${statsName}.discard`, 1, statsContext?.tags) + } + + return { + status: 200 + } +} diff --git a/packages/destination-actions/src/destinations/display-video-360/types.ts b/packages/destination-actions/src/destinations/display-video-360/types.ts new file mode 100644 index 0000000000..e5065bcffe --- /dev/null +++ b/packages/destination-actions/src/destinations/display-video-360/types.ts @@ -0,0 +1,32 @@ +import type { Payload as AddToAudiencePayload } from './addToAudience/generated-types' +import type { Payload as RemoveFromAudiencePayload } from './removeFromAudience/generated-types' + +export interface RefreshTokenResponse { + access_token: string +} + +export interface GoogleAPIError { + response: { + status: number + data: { + error: { + message: string + } + } + } +} + +export type BasicListTypeMap = { + basicUserList: any + [key: string]: any +} + +export type UserOperation = { + UserId: string + UserIdType: number + UserListId: number + Delete: boolean +} + +export type ListOperation = 'add' | 'remove' +export type UpdateHandlerPayload = AddToAudiencePayload & RemoveFromAudiencePayload diff --git a/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/__tests__/index.test.ts index 1564243ee0..4f93b93814 100644 --- a/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/__tests__/index.test.ts @@ -9,7 +9,8 @@ const goodTrackEvent = createTestEvent({ context: { personas: { computation_class: 'audience', - computation_key: 'dy_segment_test' + computation_key: 'dy_segment_test', + computation_id: 'dy_segment_audience_id' }, traits: { email: 'test@email.com' @@ -26,7 +27,8 @@ const goodIdentifyEvent = createTestEvent({ context: { personas: { computation_class: 'audience', - computation_key: 'dy_segment_test' + computation_key: 'dy_segment_test', + computation_id: 'dy_segment_audience_id' } }, traits: { diff --git a/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/generated-types.ts index 41cb2e1bac..37d60b6c25 100644 --- a/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/generated-types.ts @@ -5,6 +5,10 @@ export interface Payload { * Segment Audience key / name */ segment_audience_key: string + /** + * Segment Audience ID + */ + segment_audience_id: string /** * Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'. */ diff --git a/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/index.ts index d613105774..0c0ed6ee64 100644 --- a/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/dynamic-yield-audiences/syncAudience/index.ts @@ -13,17 +13,29 @@ const action: ActionDefinition = { segment_audience_key: { label: 'Audience Key', description: 'Segment Audience key / name', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_key' } }, + segment_audience_id: { + label: 'Audience ID', + description: 'Segment Audience ID', + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_id' + } + }, segment_computation_action: { label: 'Segment Computation Action', description: "Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'.", - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_class' @@ -33,21 +45,24 @@ const action: ActionDefinition = { segment_user_id: { label: 'Segment User ID', description: 'The Segment userId value.', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@path': '$.userId' } }, segment_anonymous_id: { label: 'Segment Anonymous ID', description: 'The Segment anonymousId value.', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@path': '$.anonymousId' } }, user_email: { label: 'Email address', description: "The user's email address", - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@if': { @@ -64,6 +79,7 @@ const action: ActionDefinition = { const payload = data.payload const settings = data.settings const audienceName = payload.segment_audience_key + const audienceID = payload.segment_audience_id const audienceValue = d?.rawData?.properties?.[audienceName] ?? d?.rawData?.traits?.[audienceName] const URL = getUpsertURL(settings) @@ -72,6 +88,7 @@ const action: ActionDefinition = { json: { audienceValue, audienceName, + audienceID, identifier: payload.segment_user_id ?? payload.segment_anonymous_id, email: payload.user_email ? hashAndEncode(payload.user_email) : undefined, sectionId: settings.sectionId, diff --git a/packages/destination-actions/src/destinations/engage-messaging-sendgrid/previewApiLookup.types.ts b/packages/destination-actions/src/destinations/engage-messaging-sendgrid/previewApiLookup.types.ts index ad95c1ec52..bb5c6da186 100644 --- a/packages/destination-actions/src/destinations/engage-messaging-sendgrid/previewApiLookup.types.ts +++ b/packages/destination-actions/src/destinations/engage-messaging-sendgrid/previewApiLookup.types.ts @@ -35,6 +35,10 @@ export interface Payload { * The response type of the request. Currently only supporting JSON. */ responseType: string + /** + * Whether the message should be retried (if the error code is retryable) when the data feed fails or if it should be sent with empty data instead + */ + shouldRetryOnRetryableError?: boolean /** * A user profile's traits */ diff --git a/packages/destination-actions/src/destinations/engage-messaging-sendgrid/sendEmail.types.ts b/packages/destination-actions/src/destinations/engage-messaging-sendgrid/sendEmail.types.ts index 730dc5f335..3e40ecd023 100644 --- a/packages/destination-actions/src/destinations/engage-messaging-sendgrid/sendEmail.types.ts +++ b/packages/destination-actions/src/destinations/engage-messaging-sendgrid/sendEmail.types.ts @@ -77,6 +77,10 @@ export interface Payload { * Send email without subscription check */ byPassSubscription?: boolean + /** + * Send email with an ip pool + */ + ipPool?: string /** * Send to any subscription status other than unsubscribed */ @@ -119,7 +123,15 @@ export interface Payload { * The response type of the request. Currently only supporting JSON. */ responseType: string + /** + * Whether the message should be retried (if the error code is retryable) when the data feed fails or if it should be sent with empty data instead + */ + shouldRetryOnRetryableError?: boolean }[] + /** + * Segment computation ID + */ + segmentComputationId?: string /** * An array of user profile identity information. */ diff --git a/packages/destination-actions/src/destinations/engage-messaging-twilio/sendMobilePush.types.ts b/packages/destination-actions/src/destinations/engage-messaging-twilio/sendMobilePush.types.ts index 0de7e318dc..2c293f3a8c 100644 --- a/packages/destination-actions/src/destinations/engage-messaging-twilio/sendMobilePush.types.ts +++ b/packages/destination-actions/src/destinations/engage-messaging-twilio/sendMobilePush.types.ts @@ -89,6 +89,10 @@ export interface Payload { * Whether or not the notification should actually get sent. */ send?: boolean + /** + * Segment computation ID + */ + segmentComputationId?: string /** * An array of user profile identity information. */ @@ -120,4 +124,8 @@ export interface Payload { * Time of when the actual event happened. */ eventOccurredTS?: string + /** + * Controls the notification payload format + */ + googleApiVersion?: string } diff --git a/packages/destination-actions/src/destinations/engage-messaging-twilio/sendSms.types.ts b/packages/destination-actions/src/destinations/engage-messaging-twilio/sendSms.types.ts index c5baea0938..9b4827de50 100644 --- a/packages/destination-actions/src/destinations/engage-messaging-twilio/sendSms.types.ts +++ b/packages/destination-actions/src/destinations/engage-messaging-twilio/sendSms.types.ts @@ -47,6 +47,10 @@ export interface Payload { * Send to any subscription status other than unsubscribed */ sendBasedOnOptOut?: boolean + /** + * Segment computation ID + */ + segmentComputationId?: string /** * An array of user profile identity information. */ diff --git a/packages/destination-actions/src/destinations/engage-messaging-twilio/sendWhatsApp.types.ts b/packages/destination-actions/src/destinations/engage-messaging-twilio/sendWhatsApp.types.ts index 59750acb32..5c9d5b89c8 100644 --- a/packages/destination-actions/src/destinations/engage-messaging-twilio/sendWhatsApp.types.ts +++ b/packages/destination-actions/src/destinations/engage-messaging-twilio/sendWhatsApp.types.ts @@ -37,6 +37,10 @@ export interface Payload { * Whether or not trait enrich from event (i.e without profile api call) */ traitEnrichment?: boolean + /** + * Segment computation ID + */ + segmentComputationId?: string /** * An array of user profile identity information. */ diff --git a/packages/destination-actions/src/destinations/engage/sendgrid/Profile.ts b/packages/destination-actions/src/destinations/engage/sendgrid/Profile.ts deleted file mode 100644 index cfb8967309..0000000000 --- a/packages/destination-actions/src/destinations/engage/sendgrid/Profile.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Profile { - user_id?: string - anonymous_id?: string - email?: string - traits: Record -} diff --git a/packages/destination-actions/src/destinations/engage/sendgrid/__tests__/send-email.test.ts b/packages/destination-actions/src/destinations/engage/sendgrid/__tests__/send-email.test.ts index 0d48c17fd7..8d62e9741e 100644 --- a/packages/destination-actions/src/destinations/engage/sendgrid/__tests__/send-email.test.ts +++ b/packages/destination-actions/src/destinations/engage/sendgrid/__tests__/send-email.test.ts @@ -5,13 +5,14 @@ import Sendgrid from '..' import { FLAGON_NAME_LOG_ERROR, FLAGON_NAME_LOG_INFO, SendabilityStatus } from '../../utils' import { loggerMock, expectErrorLogged, expectInfoLogged } from '../../utils/testUtils' import { insertEmailPreviewText } from '../sendEmail/insertEmailPreviewText' +import { FLAGON_NAME_DATA_FEEDS } from '../../utils/apiLookups' const sendgrid = createTestIntegration(Sendgrid) const timestamp = new Date().toISOString() function createDefaultActionProps() { return { - features: { [FLAGON_NAME_LOG_INFO]: true, [FLAGON_NAME_LOG_ERROR]: true }, + features: { [FLAGON_NAME_LOG_INFO]: true, [FLAGON_NAME_LOG_ERROR]: true, [FLAGON_NAME_DATA_FEEDS]: true }, logger: loggerMock } } @@ -131,6 +132,7 @@ describe.each([ traitEnrichment: true, groupId: '', byPassSubscription: false, + ipPool: '', sendBasedOnOptOut: false, toEmail: '', externalIds: { @@ -762,7 +764,7 @@ describe.each([ const bodyHtml = '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' const replacedHtmlWithLink = - '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' + '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' const expectedSendGridRequest = { personalizations: [ { @@ -802,9 +804,13 @@ describe.each([ ], tracking_settings: { subscription_tracking: { - enable: true, + enable: false, substitution_tag: '[unsubscribe]' } + }, + headers: { + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': '' } } @@ -845,7 +851,7 @@ describe.each([ const bodyHtml = '

Hi First Name, welcome to Segment

Manage Preferences | Unsubscribe' const replacedHtmlWithLink = - '

Hi First Name, welcome to Segment

Unsubscribe' + '

Hi First Name, welcome to Segment

Unsubscribe' const expectedSendGridRequest = { personalizations: [ { @@ -885,9 +891,13 @@ describe.each([ ], tracking_settings: { subscription_tracking: { - enable: true, + enable: false, substitution_tag: '[unsubscribe]' } + }, + headers: { + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': '' } } @@ -928,7 +938,7 @@ describe.each([ const bodyHtml = '

Hi First Name, welcome to Segment. Here is an Unsubscribe link.

Unsubscribe | Manage Preferences' const replacedHtmlWithLink = - '

Hi First Name, welcome to Segment. Here is an Unsubscribe link.

Unsubscribe' + '

Hi First Name, welcome to Segment. Here is an Unsubscribe link.

Unsubscribe' const expectedSendGridRequest = { personalizations: [ { @@ -968,9 +978,13 @@ describe.each([ ], tracking_settings: { subscription_tracking: { - enable: true, + enable: false, substitution_tag: '[unsubscribe]' } + }, + headers: { + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': '' } } @@ -1033,32 +1047,426 @@ describe.each([ __segment_internal_external_id_value__: userData.email } } - ], - from: { - email: 'from@example.com', - name: 'From Name' - }, - reply_to: { - email: 'replyto@example.com', - name: 'Test user' - }, - subject: `Hello ${userData.lastName} ${userData.firstName}.`, - content: [ - { - type: 'text/html', - value: replacedHtmlWithLink - } - ], - tracking_settings: { - subscription_tracking: { - enable: true, - substitution_tag: '[unsubscribe]' - } - } - } + ], + from: { + email: 'from@example.com', + name: 'From Name' + }, + reply_to: { + email: 'replyto@example.com', + name: 'Test user' + }, + subject: `Hello ${userData.lastName} ${userData.firstName}.`, + content: [ + { + type: 'text/html', + value: replacedHtmlWithLink + } + ], + tracking_settings: { + subscription_tracking: { + enable: true, + substitution_tag: '[unsubscribe]' + } + } + } + + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', expectedSendGridRequest) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + unsubscribeLink: '', + preferencesLink: '', + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + body: undefined, + bodyHtml: bodyHtml, + bodyType: 'html' + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('Adds list-unsubscribe headers with subscription tracking turned off', async () => { + const bodyHtml = + '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' + const replacedHtmlWithLink = + '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' + const expectedSendGridRequest = { + personalizations: [ + { + to: [ + { + email: userData.email + } + ], + bcc: [ + { + email: 'test@test.com' + } + ], + custom_args: { + source_id: 'sourceId', + space_id: 'spaceId', + user_id: userData.userId, + __segment_internal_external_id_key__: 'email', + __segment_internal_external_id_value__: userData.email + } + } + ], + from: { + email: 'from@example.com', + name: 'From Name' + }, + reply_to: { + email: 'replyto@example.com', + name: 'Test user' + }, + subject: `Hello ${userData.lastName} ${userData.firstName}.`, + content: [ + { + type: 'text/html', + value: replacedHtmlWithLink + } + ], + tracking_settings: { + subscription_tracking: { + enable: false, + substitution_tag: '[unsubscribe]' + } + }, + headers: { + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': '' + } + } + + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', expectedSendGridRequest) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + unsubscribeLink: 'http://global_unsubscribe_link', + preferencesLink: 'http://preferences_link', + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + body: undefined, + bodyHtml: bodyHtml, + bodyType: 'html' + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('Do not add list-unsubscribe headers when sendgrid substitution tag is used', async () => { + const bodyHtml = '

Hi First Name, welcome to Segment

Unsubscribe' + const replacedHtmlWithLink = + '

Hi First Name, welcome to Segment

Unsubscribe' + const expectedSendGridRequest = { + personalizations: [ + { + to: [ + { + email: userData.email + } + ], + bcc: [ + { + email: 'test@test.com' + } + ], + custom_args: { + source_id: 'sourceId', + space_id: 'spaceId', + user_id: userData.userId, + __segment_internal_external_id_key__: 'email', + __segment_internal_external_id_value__: userData.email + } + } + ], + from: { + email: 'from@example.com', + name: 'From Name' + }, + reply_to: { + email: 'replyto@example.com', + name: 'Test user' + }, + subject: `Hello ${userData.lastName} ${userData.firstName}.`, + content: [ + { + type: 'text/html', + value: replacedHtmlWithLink + } + ], + tracking_settings: { + subscription_tracking: { + enable: true, + substitution_tag: '[unsubscribe]' + } + } + } + + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', expectedSendGridRequest) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + unsubscribeLink: 'http://global_unsubscribe_link', + preferencesLink: 'http://preferences_link', + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + body: undefined, + bodyHtml: bodyHtml, + bodyType: 'html' + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('should show a default in the subject when a trait is empty', async () => { + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', { ...sendgridRequestBody, subject: `Hi Person` }) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + subject: 'Hi {{profile.traits.lastName | default: "Person"}}', + traits: { + firstName: userData.firstName, + lastName: ' ' + } + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect( + responses.map((response) => response.options.body?.toString().includes('Hi Person')).some((item) => item) + ).toEqual(true) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('should show a default in the subject when a trait is ', async () => { + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', { ...sendgridRequestBody, subject: `Hi Person` }) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + subject: 'Hi {{profile.traits.lastName | default: "Person"}}', + traits: { + firstName: userData.firstName, + lastName: '' + } + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect( + responses.map((response) => response.options.body?.toString().includes('Hi Person')).some((item) => item) + ).toEqual(true) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('should show a default in the subject when a trait is null', async () => { + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', { ...sendgridRequestBody, subject: `Hi Person` }) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + subject: 'Hi {{profile.traits.lastName | default: "Person"}}', + traits: { + firstName: userData.firstName, + lastName: null + } + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect( + responses.map((response) => response.options.body?.toString().includes('Hi Person')).some((item) => item) + ).toEqual(true) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('should show the correct non-string trait in the subject when a trait is non-string', async () => { + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', { ...sendgridRequestBody, subject: `Hi true` }) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + subject: 'Hi {{profile.traits.lastName | default: "Person"}}', + traits: { + firstName: userData.firstName, + lastName: true + } + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect( + responses.map((response) => response.options.body?.toString().includes('Hi true')).some((item) => item) + ).toEqual(true) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('should show a default in the subject when a trait is undefined', async () => { + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', { ...sendgridRequestBody, subject: `Hi Person` }) + .reply(200, {}) + + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + subject: 'Hi {{profile.traits.lastName | default: "Person"}}', + traits: { + firstName: userData.firstName, + lastName: undefined + } + }) + }) + + expect(responses.length).toBeGreaterThan(0) + expect( + responses.map((response) => response.options.body?.toString().includes('Hi Person')).some((item) => item) + ).toEqual(true) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('should show a default in the subject when traits is null', async () => { + nock(`${endpoint}/v1/spaces/spaceId/collections/users/profiles/user_id:${userData.userId}`) + .get('/traits?limit=200') + .reply(200, { + traits: null + }) const sendGridRequest = nock('https://api.sendgrid.com') - .post('/v3/mail/send', expectedSendGridRequest) + .post('/v3/mail/send', { ...sendgridRequestBody, subject: `Hello you` }) .reply(200, {}) const responses = await sendgrid.testAction('sendEmail', { @@ -1072,17 +1480,13 @@ describe.each([ encoding: 'none', id: userData.email, isSubscribed: true, - unsubscribeLink: '', - preferencesLink: '', type: 'email' } ] }), settings, mapping: getDefaultMapping({ - body: undefined, - bodyHtml: bodyHtml, - bodyType: 'html' + subject: 'Hello {{profile.traits.last_name | default: "you"}}' }) }) @@ -1495,6 +1899,119 @@ describe.each([ ) }) + describe('ip pool', () => { + beforeEach(() => { + nock(`${endpoint}/v1/spaces/spaceId/collections/users/profiles/user_id:${userData.userId}`) + .get('/traits?limit=200') + .reply(200, { + traits: { + firstName: userData.firstName, + lastName: userData.lastName + } + }) + }) + + it('sends the email to ip pool when name is specified', async () => { + const sendGridRequest = nock('https://api.sendgrid.com').post('/v3/mail/send').reply(200, {}) + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { id: userData.email, type: 'email', collection: 'users', isSubscribed: true, encoding: 'none' } + ] + }), + settings, + mapping: getDefaultMapping({ ipPool: 'testPoolName' }) + }) + + expect(responses[0].options.body).toContain('"ip_pool_name":"testPoolName"') + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('sends the email to ip pool when name is null', async () => { + const sendGridRequest = nock('https://api.sendgrid.com').post('/v3/mail/send').reply(200, {}) + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { id: userData.email, type: 'email', collection: 'users', isSubscribed: true, encoding: 'none' } + ] + }), + settings, + mapping: getDefaultMapping({ ipPool: null }) + }) + + expect(responses[0].options.body).not.toContain('ip_pool_name') + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('sends the email to ip pool when name is undefined', async () => { + const sendGridRequest = nock('https://api.sendgrid.com').post('/v3/mail/send').reply(200, {}) + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { id: userData.email, type: 'email', collection: 'users', isSubscribed: true, encoding: 'none' } + ] + }), + settings, + mapping: getDefaultMapping({ ipPool: undefined }) + }) + + expect(responses[0].options.body).not.toContain('ip_pool_name') + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('sends the email to ip pool when name is empty string', async () => { + const sendGridRequest = nock('https://api.sendgrid.com').post('/v3/mail/send').reply(200, {}) + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { id: userData.email, type: 'email', collection: 'users', isSubscribed: true, encoding: 'none' } + ] + }), + settings, + mapping: getDefaultMapping({ ipPool: '' }) + }) + + expect(responses[0].options.body).not.toContain('ip_pool_name') + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + + it('sends the email to ip pool when name is not specified', async () => { + const sendGridRequest = nock('https://api.sendgrid.com').post('/v3/mail/send').reply(200, {}) + const responses = await sendgrid.testAction('sendEmail', { + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { id: userData.email, type: 'email', collection: 'users', isSubscribed: true, encoding: 'none' } + ] + }), + settings, + mapping: getDefaultMapping() + }) + + expect(responses[0].options.body).not.toContain('ip_pool_name') + expect(responses.length).toBeGreaterThan(0) + expect(sendGridRequest.isDone()).toEqual(true) + }) + }) + describe('subscription groups', () => { beforeEach(() => { nock(`${endpoint}/v1/spaces/spaceId/collections/users/profiles/user_id:${userData.userId}`) @@ -1729,7 +2246,7 @@ describe.each([ const bodyHtml = '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' const replacedHtmlWithLink = - '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' + '

Hi First Name, welcome to Segment

Unsubscribe | Manage Preferences' const expectedSendGridRequest = { personalizations: [ @@ -1770,9 +2287,13 @@ describe.each([ ], tracking_settings: { subscription_tracking: { - enable: true, + enable: false, substitution_tag: '[unsubscribe]' } + }, + headers: { + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': '' } } @@ -1839,92 +2360,7 @@ describe.each([ }) describe('api lookups', () => { - it('liquid renders url with profile traits before requesting', async () => { - nock('https://api.sendgrid.com').post('/v3/mail/send', sendgridRequestBody).reply(200, {}) - - const apiLookupRequest = nock(`https://fakeweather.com`) - .get(`/api/${userData.lastName}`) - .reply(200, { - current: { - temperature: 70 - } - }) - - await sendgrid.testAction('sendEmail', { - event: createMessagingTestEvent({ - timestamp, - event: 'Audience Entered', - userId: userData.userId, - external_ids: [ - { - collection: 'users', - encoding: 'none', - id: userData.email, - isSubscribed: true, - type: 'email' - } - ] - }), - settings, - mapping: getDefaultMapping({ - apiLookups: { - id: '1', - name: 'weather', - url: 'https://fakeweather.com/api/{{profile.traits.lastName}}', - method: 'get', - cacheTtl: 0, - responseType: 'json' - } - }) - }) - - expect(apiLookupRequest.isDone()).toBe(true) - }) - - it('liquid renders body with profile traits before requesting', async () => { - nock('https://api.sendgrid.com').post('/v3/mail/send', sendgridRequestBody).reply(200, {}) - - const apiLookupRequest = nock(`https://fakeweather.com`) - .post(`/api`, `lastName is ${userData.lastName}`) - .reply(200, { - current: { - temperature: 70 - } - }) - - await sendgrid.testAction('sendEmail', { - event: createMessagingTestEvent({ - timestamp, - event: 'Audience Entered', - userId: userData.userId, - external_ids: [ - { - collection: 'users', - encoding: 'none', - id: userData.email, - isSubscribed: true, - type: 'email' - } - ] - }), - settings, - mapping: getDefaultMapping({ - apiLookups: { - id: '1', - name: 'weather', - url: 'https://fakeweather.com/api', - body: 'lastName is {{profile.traits.lastName}}', - method: 'post', - cacheTtl: 0, - responseType: 'json' - } - }) - }) - - expect(apiLookupRequest.isDone()).toBe(true) - }) - - it('are called and responses are passed to email body liquid renderer before sending', async () => { + it('are called and successful responses are passed to email body liquid renderer before sending', async () => { const sendGridRequest = nock('https://api.sendgrid.com') .post('/v3/mail/send', { ...sendgridRequestBody, @@ -1955,6 +2391,7 @@ describe.each([ }) const responses = await sendgrid.testAction('sendEmail', { + ...defaultActionProps, event: createMessagingTestEvent({ timestamp, event: 'Audience Entered', @@ -1991,7 +2428,7 @@ describe.each([ } ], bodyHtml: - 'Current temperature: {{lookups.weather.current.temperature}}, Current bitcoin price: {{lookups.btcPrice.current.price}}' + 'Current temperature: {{datafeeds.weather.current.temperature}}, Current bitcoin price: {{datafeeds.btcPrice.current.price}}' }) }) @@ -1999,11 +2436,13 @@ describe.each([ expect(sendGridRequest.isDone()).toBe(true) }) - it('should throw error if at least one api lookup fails', async () => { - nock(`https://fakeweather.com`).get('/api').reply(429) + it('should rethrow request client error if at least one api lookup fails with shouldRetryOnRetryableError == true', async () => { + const dataFeedNock = nock(`https://fakeweather.com`).get('/api').reply(429) + const sendGridRequest = nock('https://api.sendgrid.com').post('/v3/mail/send', sendgridRequestBody).reply(200, {}) await expect( sendgrid.testAction('sendEmail', { + ...defaultActionProps, event: createMessagingTestEvent({ timestamp, event: 'Audience Entered', @@ -2020,21 +2459,75 @@ describe.each([ }), settings, mapping: getDefaultMapping({ - apiLookups: { + apiLookups: [ + { + id: '1', + name: 'weather', + url: 'https://fakeweather.com/api', + method: 'get', + cacheTtl: 0, + responseType: 'json', + shouldRetryOnRetryableError: true + } + ] + }) + }) + ).rejects.toThrowError('Too Many Requests') + + expect(dataFeedNock.isDone()).toEqual(true) + expect(sendGridRequest.isDone()).toEqual(false) + expectErrorLogged('Too Many Requests') + }) + + it('should send message with empty data if api lookup fails with shouldRetryOnRetryableError == false', async () => { + const sendGridRequest = nock('https://api.sendgrid.com') + .post('/v3/mail/send', { + ...sendgridRequestBody, + content: [ + { + type: 'text/html', + value: `Current temperature: 99` + } + ] + }) + .reply(200, {}) + const dataFeedNock = nock(`https://fakeweather.com`).get('/api').reply(429) + + await sendgrid.testAction('sendEmail', { + ...defaultActionProps, + event: createMessagingTestEvent({ + timestamp, + event: 'Audience Entered', + userId: userData.userId, + external_ids: [ + { + collection: 'users', + encoding: 'none', + id: userData.email, + isSubscribed: true, + type: 'email' + } + ] + }), + settings, + mapping: getDefaultMapping({ + apiLookups: [ + { id: '1', name: 'weather', url: 'https://fakeweather.com/api', method: 'get', cacheTtl: 0, - responseType: 'json' + responseType: 'json', + shouldRetryOnRetryableError: false } - }), - ...defaultActionProps + ], + bodyHtml: 'Current temperature: {{datafeeds.weather.current.temperature | default: 99 }}' }) - ).rejects.toThrowError('Too Many Requests') + }) - const sendGridRequest = nock('https://api.sendgrid.com').post('/v3/mail/send', sendgridRequestBody).reply(200, {}) - expect(sendGridRequest.isDone()).toEqual(false) + expect(dataFeedNock.isDone()).toBe(true) + expect(sendGridRequest.isDone()).toEqual(true) expectErrorLogged('Too Many Requests') }) }) diff --git a/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/actionDefinition.ts b/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/actionDefinition.ts index 05b0ecc7fe..5c77916a63 100644 --- a/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/actionDefinition.ts +++ b/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/actionDefinition.ts @@ -1,8 +1,8 @@ import { ActionDefinition } from '@segment/actions-core' import { Settings } from '../generated-types' import { Payload } from './generated-types' -import { apiLookupActionFields, performApiLookup } from './api-lookups' -import { Profile } from '../Profile' +import { apiLookupActionFields, performApiLookup } from '../../utils/apiLookups' +import { Profile } from '../../utils/Profile' export const actionDefinition: ActionDefinition = { title: 'Perform a single API lookup', diff --git a/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/api-lookups.ts b/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/api-lookups.ts deleted file mode 100644 index 10abf07df3..0000000000 --- a/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/api-lookups.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { IntegrationError } from '@segment/actions-core' -import { InputField } from '@segment/actions-core' -import { RequestClient, RequestOptions } from '@segment/actions-core' -import { Logger, StatsClient } from '@segment/actions-core/destination-kit' -import type { Settings } from '../generated-types' -import { Liquid as LiquidJs } from 'liquidjs' -import { Profile } from '../Profile' - -const Liquid = new LiquidJs() - -export type ApiLookupConfig = { - id?: string | undefined - name: string - url: string - method: string - /** Cache ttl in ms */ - cacheTtl: number - body?: string | undefined - headers?: object | undefined - responseType: string -} - -export const performApiLookup = async ( - request: RequestClient, - { id, url, method, body, headers }: ApiLookupConfig, - profile: Profile, - statsClient: StatsClient | undefined, - tags: string[], - settings: Settings, - logger?: Logger | undefined -) => { - let renderedUrl: string - let renderedBody: string | undefined - try { - renderedUrl = await Liquid.parseAndRender(url, { profile }) - } catch (error) { - logger?.error( - `TE Messaging: Email api lookup url parse failure - api lookup id: ${id} - ${settings.spaceId} - [${error}]` - ) - tags.push('reason:parse_apilookup_url') - statsClient?.incr('actions-personas-messaging-sendgrid-error', 1, tags) - throw new IntegrationError('Unable to parse email api lookup url', 'api lookup url parse failure', 400) - } - try { - renderedBody = body ? await Liquid.parseAndRender(body, { profile }) : undefined - } catch (error) { - logger?.error( - `TE Messaging: Email api lookup body parse failure - api lookup id: ${id} - ${settings.spaceId} - [${error}]` - ) - tags.push('reason:parse_apilookup_body') - statsClient?.incr('actions-personas-messaging-sendgrid-error', 1, tags) - throw new IntegrationError('Unable to parse email api lookup body', 'api lookup body parse failure', 400) - } - - try { - const res = await request(renderedUrl, { - headers: (headers as Record) ?? undefined, - timeout: 10000, - method: method as RequestOptions['method'], - body: renderedBody, - skipResponseCloning: true - }) - return res.data - } catch (error) { - logger?.error(`TE Messaging: Email api lookup failure - api lookup id: ${id} - ${settings.spaceId} - [${error}]`) - tags.push('reason:apilookup_failure') - statsClient?.incr('actions-personas-messaging-sendgrid-error', 1, tags) - // Rethrow error to preserve default http retry logic - throw error - } -} - -/** The action definition config fields representing a single API lookup */ -export const apiLookupActionFields: Record = { - id: { - label: 'ID', - description: 'The id of the API lookup for use in logging & observability', - type: 'string' - }, - name: { - label: 'Name', - description: 'The name of the API lookup referenced in liquid syntax', - type: 'string', - required: true - }, - url: { - label: 'URL', - description: 'The URL endpoint to call', - type: 'string', - required: true - }, - method: { - label: 'Request Method', - description: 'The request method, e.g. GET/POST/etc.', - type: 'string', - required: true - }, - cacheTtl: { - label: 'Cache TTL', - description: 'The cache TTL in ms', - type: 'integer', - required: true - }, - body: { - label: 'Request Body', - description: 'The request body for use with POST/PUT/PATCH requests', - type: 'string' - }, - headers: { - label: 'Request Headers', - description: 'Headers in JSON to be sent with the request', - type: 'object' - }, - responseType: { - label: 'Response Type', - description: 'The response type of the request. Currently only supporting JSON.', - type: 'string', - required: true - } -} - -export const apiLookupLiquidKey = 'lookups' diff --git a/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/index.ts b/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/index.ts index a3a06f49fe..9a53ed0aee 100644 --- a/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/index.ts +++ b/packages/destination-actions/src/destinations/engage/sendgrid/previewApiLookup/index.ts @@ -1,2 +1 @@ export * from './actionDefinition' -export * from './api-lookups' diff --git a/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/SendEmailPerformer.ts b/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/SendEmailPerformer.ts index b2068fae2d..133a7a9b7c 100644 --- a/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/SendEmailPerformer.ts +++ b/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/SendEmailPerformer.ts @@ -1,10 +1,10 @@ import { ExtId, MessageSendPerformer, OperationContext, ResponseError, track } from '../../utils' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { Profile } from '../Profile' +import { Profile } from '../../utils/Profile' import { Liquid as LiquidJs } from 'liquidjs' import { IntegrationError, RequestOptions } from '@segment/actions-core' -import { ApiLookupConfig, apiLookupLiquidKey, performApiLookup } from '../previewApiLookup' +import { ApiLookupConfig, FLAGON_NAME_DATA_FEEDS, apiLookupLiquidKey, performApiLookup } from '../../utils/apiLookups' import { insertEmailPreviewText } from './insertEmailPreviewText' import cheerio from 'cheerio' import { isRestrictedDomain } from './isRestrictedDomain' @@ -90,10 +90,23 @@ export class SendEmailPerformer extends MessageSendPerformer }, contentType: string ) { - const parsedContent = await Liquid.parseAndRender(content, liquidData) + const traits = liquidData.profile.traits ? { ...liquidData.profile.traits } : liquidData.profile.traits + if (traits) { + for (const trait of Object.keys(traits)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + if (traits && traits[trait] && (traits[trait] === '' || traits[trait].toString().trim() === '')) { + traits[trait] = '' + } + } + } + const parsedContent = + content == null + ? content + : await Liquid.parseAndRender(content, { ...liquidData, profile: { ...liquidData.profile, traits } }) this.logOnError(() => 'Content type: ' + contentType) return parsedContent } + async sendToRecepient(emailProfile: ExtId) { const traits = await this.getProfileTraits() @@ -118,21 +131,25 @@ export class SendEmailPerformer extends MessageSendPerformer } const bcc = JSON.parse(this.payload.bcc ?? '[]') - const [ - parsedFromEmail, - parsedFromName, - parsedFromReplyToEmail, - parsedFromReplyToName, - parsedSubject, - apiLookupData - ] = await Promise.all([ - this.parseTemplating(this.payload.fromEmail, { profile }, 'FromEmail'), - this.parseTemplating(this.payload.fromName, { profile }, 'FromName'), - this.parseTemplating(this.payload.replyToEmail, { profile }, 'ReplyToEmail'), - this.parseTemplating(this.payload.replyToName, { profile }, 'ReplyToName'), - this.parseTemplating(this.payload.subject, { profile }, 'Subject'), - this.performApiLookups(this.payload.apiLookups, profile) - ]) + const [parsedFromEmail, parsedFromName, parsedFromReplyToEmail, parsedFromReplyToName, parsedSubject] = + await Promise.all([ + this.parseTemplating(this.payload.fromEmail, { profile }, 'FromEmail'), + this.parseTemplating(this.payload.fromName, { profile }, 'FromName'), + this.parseTemplating(this.payload.replyToEmail, { profile }, 'ReplyToEmail'), + this.parseTemplating(this.payload.replyToName, { profile }, 'ReplyToName'), + this.parseTemplating(this.payload.subject, { profile }, 'Subject') + ]) + + let apiLookupData = {} + if (this.isFeatureActive(FLAGON_NAME_DATA_FEEDS)) { + try { + apiLookupData = await this.performApiLookups(this.payload.apiLookups, profile) + } catch (error) { + // Catching error to add tags, rethrowing to continue bubbling up + this.tags.push('reason:data_feed_failure') + throw error + } + } const parsedBodyHtml = await this.getBodyHtml(profile, apiLookupData, emailProfile) @@ -178,9 +195,33 @@ export class SendEmailPerformer extends MessageSendPerformer } } let mailContent - if (this.payload.byPassSubscription) { + let unsubscribeLink + if (this.payload.groupId) { + const group = emailProfile.groups?.find((grp) => grp.id === this.payload.groupId) + unsubscribeLink = group?.groupUnsubscribeLink ?? '' + } else { + unsubscribeLink = emailProfile?.unsubscribeLink + } + + if (unsubscribeLink) { + // Add list-unsubscribe headers for one click unsubscribe compliance if we have unsubscribe links in the emailProfile mailContent = { ...mailContentSubscriptionHonored, + headers: { + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'List-Unsubscribe': '<' + unsubscribeLink + '>' + } + } + // Turn off subscription tracking (If this is enabled sendgrid will not honor list unsubscribe headers) + mailContent.tracking_settings.subscription_tracking.enable = false + this.statsClient?.incr('request.list_unsubscribe__header_added', 1) + } else { + mailContent = mailContentSubscriptionHonored + } + + if (this.payload.byPassSubscription) { + mailContent = { + ...mailContent, mail_settings: { bypass_list_management: { enable: true @@ -189,9 +230,20 @@ export class SendEmailPerformer extends MessageSendPerformer } this.statsClient?.incr('request.by_pass_subscription', 1) } else { - mailContent = mailContentSubscriptionHonored this.statsClient?.incr('request.dont_pass_subscription', 1) } + + // Check if ip pool name is provided and sends the email with the ip pool name if it is + if (this.payload.ipPool) { + mailContent = { + ...mailContent, + ip_pool_name: this.payload.ipPool + } + this.statsClient?.incr('request.ip_pool_name_provided', 1) + } else { + this.statsClient?.incr('request.ip_pool_name_not_provided', 1) + } + const req: RequestOptions = { method: 'post', headers: { @@ -211,6 +263,12 @@ export class SendEmailPerformer extends MessageSendPerformer return response } + @track() + async getBodyTemplateFromS3(bodyUrl: string) { + const { content } = await this.request(bodyUrl, { method: 'GET', skipResponseCloning: true }) + return content + } + @track() async getBodyHtml( profile: Profile, @@ -228,7 +286,7 @@ export class SendEmailPerformer extends MessageSendPerformer ) { let parsedBodyHtml if (this.payload.bodyUrl && this.settings.unlayerApiKey) { - const { content: body } = await this.request(this.payload.bodyUrl, { method: 'GET', skipResponseCloning: true }) + const body = await this.getBodyTemplateFromS3(this.payload.bodyUrl) const bodyHtml = this.payload.bodyType === 'html' ? body : await this.generateEmailHtml(body) parsedBodyHtml = await this.parseTemplating(bodyHtml, { profile, [apiLookupLiquidKey]: apiLookupData }, 'Body') } else { @@ -299,7 +357,8 @@ export class SendEmailPerformer extends MessageSendPerformer this.statsClient.statsClient, this.tags, this.settings, - this.logger.loggerClient + this.logger.loggerClient, + this.dataFeedCache ) return { name: apiLookup.name, data } }) @@ -311,6 +370,34 @@ export class SendEmailPerformer extends MessageSendPerformer }, {}) } + @track() + validateLinkAndLog(link: string): void { + let workspaceId = this.payload.customArgs && this.payload.customArgs['workspace_id'] + let audienceId = + this.payload.customArgs && + (this.payload.customArgs['audience_id'] || this.payload.customArgs['__segment_internal_audience_id__']) + workspaceId = JSON.stringify(workspaceId) + audienceId = JSON.stringify(audienceId) + + this.logger.info(`Validating the link: ${link} ${workspaceId} ${audienceId}`) + + const parsedLink = new URL(link) + // Generic function to check for missing parameters + const checkParam = (paramName: string) => { + const paramValue = parsedLink.searchParams.get(paramName) + if (!paramValue || paramValue === '') { + this.logger.error(`${paramName} is missing: ${link} ${workspaceId} ${audienceId}`) + this.statsClient.incr('missing_query_param', 1, [`param:${paramName}`, `audienceId:${audienceId}`]) + } + } + + // List of required query parameters + const requiredParams = ['contactId', 'data', 'code', 'spaceId', 'workspaceId', 'messageId', 'user-agent'] + + // Check each required parameter + requiredParams.forEach((param) => checkParam(param)) + } + @track() insertUnsubscribeLinks(html: string, emailProfile: EmailProfile): string { const spaceId = this.settings.spaceId @@ -319,10 +406,28 @@ export class SendEmailPerformer extends MessageSendPerformer const preferencesLink = emailProfile?.preferencesLink const unsubscribeLinkRef = 'a[href*="[upa_unsubscribe_link]"]' const preferencesLinkRef = 'a[href*="[upa_preferences_link]"]' + const sendgridUnsubscribeLinkRef = 'a[href*="[unsubscribe]"]' const sendgridUnsubscribeLinkTag = '[unsubscribe]' const $ = cheerio.load(html) // eslint-disable-next-line @typescript-eslint/no-this-alias const _this = this + let hasSendgridSubstitutionTag = false + $(sendgridUnsubscribeLinkRef).each(function () { + emailProfile.unsubscribeLink = '' + emailProfile.preferencesLink = '' + if (groupId) { + const group = emailProfile.groups?.find((grp) => grp.id === groupId) + if (group) { + group.groupUnsubscribeLink = '' + } + } + hasSendgridSubstitutionTag = true + }) + + if (hasSendgridSubstitutionTag) { + return $.html() + } + if (groupId) { const group = emailProfile.groups?.find((grp) => grp.id === groupId) const groupUnsubscribeLink = group?.groupUnsubscribeLink @@ -332,7 +437,9 @@ export class SendEmailPerformer extends MessageSendPerformer _this.statsClient.incr('group_unsubscribe_link_missing', 1) $(this).attr('href', sendgridUnsubscribeLinkTag) } else { - $(this).attr('href', groupUnsubscribeLink) + _this.validateLinkAndLog(groupUnsubscribeLink) + $(this).removeAttr('href') + $(this).attr('clicktracking', 'off').attr('href', groupUnsubscribeLink) _this.logger?.info(`Group Unsubscribe link replaced`) _this.statsClient?.incr('replaced_group_unsubscribe_link', 1) } @@ -344,7 +451,9 @@ export class SendEmailPerformer extends MessageSendPerformer _this.statsClient?.incr('global_unsubscribe_link_missing', 1) $(this).attr('href', sendgridUnsubscribeLinkTag) } else { - $(this).attr('href', globalUnsubscribeLink) + _this.validateLinkAndLog(globalUnsubscribeLink) + $(this).removeAttr('href') + $(this).attr('clicktracking', 'off').attr('href', globalUnsubscribeLink) _this.logger?.info(`Global Unsubscribe link replaced`) _this.statsClient?.incr('replaced_global_unsubscribe_link', 1) } @@ -362,7 +471,9 @@ export class SendEmailPerformer extends MessageSendPerformer _this.logger?.info(`Preferences link removed from the html body - ${spaceId}`) _this.statsClient?.incr('removed_preferences_link', 1) } else { - $(this).attr('href', preferencesLink) + _this.validateLinkAndLog(preferencesLink) + $(this).removeAttr('href') + $(this).attr('clicktracking', 'off').attr('href', preferencesLink) _this.logger?.info(`Preferences link replaced - ${spaceId}`) _this.statsClient?.incr('replaced_preferences_link', 1) } @@ -372,7 +483,7 @@ export class SendEmailPerformer extends MessageSendPerformer } onResponse(args: { response?: Response; error?: ResponseError; operation: OperationContext }) { - const headers = args.response?.headers || args.error?.response.headers + const headers = args.response?.headers || args.error?.response?.headers // if we need to investigate with sendgrid, we'll need this: https://docs.sendgrid.com/glossary/message-id const sgMsgId = headers?.get('X-Message-ID') if (sgMsgId) args.operation.logs.push('[sendgrid]X-Message-ID: ' + sgMsgId) diff --git a/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/actionDefinition.ts b/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/actionDefinition.ts index 543b115497..920cdae39b 100644 --- a/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/actionDefinition.ts +++ b/packages/destination-actions/src/destinations/engage/sendgrid/sendEmail/actionDefinition.ts @@ -1,7 +1,7 @@ import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { apiLookupActionFields } from '../previewApiLookup' +import { apiLookupActionFields } from '../../utils/apiLookups' import { SendEmailPerformer } from './SendEmailPerformer' export const actionDefinition: ActionDefinition = { @@ -119,6 +119,12 @@ export const actionDefinition: ActionDefinition = { type: 'boolean', default: false }, + ipPool: { + label: 'IP Pool', + description: 'Send email with an ip pool', + type: 'string', + default: '' + }, sendBasedOnOptOut: { label: 'Send OptOut', description: 'Send to any subscription status other than unsubscribed', @@ -132,6 +138,15 @@ export const actionDefinition: ActionDefinition = { multiple: true, properties: apiLookupActionFields }, + segmentComputationId: { + label: 'Segment Computation ID', + description: 'Segment computation ID', + type: 'string', + required: false, + default: { + '@path': '$.context.personas.computation_id' + } + }, externalIds: { label: 'External IDs', description: 'An array of user profile identity information.', diff --git a/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-mobile-push.test.ts b/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-mobile-push.test.ts index c3703158af..a7de8eace7 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-mobile-push.test.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-mobile-push.test.ts @@ -350,7 +350,7 @@ describe('sendMobilePush action', () => { expect(responses[0].data).toMatchObject(externalIds[0]) }) - it('parses links in tapActionButtons', async () => { + it('parses links and titles in tapActionButtons', async () => { const title = 'buy' const body = 'now' const tapAction = 'OPEN_DEEP_LINK' @@ -365,7 +365,7 @@ describe('sendMobilePush action', () => { tapActionButtons: [ { id: '1', - text: 'open', + text: 'open{{profile.traits.fav_color}}', onTap: 'deep_link', link: 'app://buy-now/{{profile.traits.fav_color}}' }, @@ -414,7 +414,7 @@ describe('sendMobilePush action', () => { tapActionButtons: [ { id: '1', - text: 'open', + text: 'openmantis_green', onTap: 'deep_link', link: 'app://buy-now/mantis_green' }, @@ -758,4 +758,128 @@ describe('sendMobilePush action', () => { expect(responses[1].data).toMatchObject(externalIds[0]) }) }) + + describe('Google Api Formatting', () => { + const externalId = { + type: 'android.push_token', + id: 'android-token-1', + channelType: 'ANDROID_PUSH', + subscriptionStatus: 'subscribed' + } + + const androidLegacyReq = new URLSearchParams({ + Body: defaultTemplate.types['twilio/text'].body, + Title: customizationTitle, + FcmPayload: JSON.stringify({ + mutable_content: true, + notification: { + badge: 1 + } + }), + ApnPayload: JSON.stringify({ + aps: { + 'mutable-content': 1, + badge: 1 + } + }), + Recipients: JSON.stringify({ + fcm: [{ addr: externalId.id }] + }), + CustomData: JSON.stringify({ + space_id: spaceId, + badgeAmount: 1, + badgeStrategy: 'inc', + __segment_internal_external_id_key__: externalId.type, + __segment_internal_external_id_value__: externalId.id + }) + }) + + const androidV1Req = new URLSearchParams({ + Body: defaultTemplate.types['twilio/text'].body, + Title: customizationTitle, + FcmPayload: JSON.stringify({ + android: { + mutable_content: true, + notification: { + badge: 1 + } + } + }), + ApnPayload: JSON.stringify({ + aps: { + 'mutable-content': 1, + badge: 1 + } + }), + Recipients: JSON.stringify({ + fcm: [{ addr: externalId.id }] + }), + CustomData: JSON.stringify({ + space_id: spaceId, + badgeAmount: 1, + badgeStrategy: 'inc', + __segment_internal_external_id_key__: externalId.type, + __segment_internal_external_id_value__: externalId.id + }) + }) + + it('should format FCM overrides with legacy format when googleApiVersion field is not provided', async () => { + const notifyReqUrl = `https://push.ashburn.us1.twilio.com/v1/Services/${pushServiceSid}/Notifications` + const notifyReqBody = androidLegacyReq + + nock(`https://content.twilio.com`).get(`/v1/Content/${contentSid}`).reply(200, defaultTemplate) + nock(notifyReqUrl).post('', notifyReqBody.toString()).reply(201, externalId) + + const responses = await testAction({ + mappingOverrides: { + externalIds: [externalId] + } + }) + expect(responses[1].url).toStrictEqual(notifyReqUrl) + expect(responses[1].status).toEqual(201) + expect(responses[1].data).toMatchObject(externalId) + }) + + it('should format FCM overrides with legacy format when googleApiVersion field is set to legacy', async () => { + const notifyReqUrl = `https://push.ashburn.us1.twilio.com/v1/Services/${pushServiceSid}/Notifications` + const notifyReqBody = androidLegacyReq + + nock(`https://content.twilio.com`).get(`/v1/Content/${contentSid}`).reply(200, defaultTemplate) + nock(notifyReqUrl).post('', notifyReqBody.toString()).reply(201, externalId) + + const responses = await testAction({ + mappingOverrides: { + googleApiVersion: 'legacy', + externalIds: [externalId] + } + }) + expect(responses[1].url).toStrictEqual(notifyReqUrl) + expect(responses[1].status).toEqual(201) + expect(responses[1].data).toMatchObject(externalId) + }) + + it('should format FCM overrides with v1 format when googleApiVersion field is v1', async () => { + const externalId = { + type: 'android.push_token', + id: 'android-token-1', + channelType: 'ANDROID_PUSH', + subscriptionStatus: 'subscribed' + } + const notifyReqUrl = `https://push.ashburn.us1.twilio.com/v1/Services/${pushServiceSid}/Notifications` + const notifyReqBody = androidV1Req + + nock(`https://content.twilio.com`).get(`/v1/Content/${contentSid}`).reply(200, defaultTemplate) + nock(notifyReqUrl).post('', notifyReqBody.toString()).reply(201, externalId) + + const responses = await testAction({ + mappingOverrides: { + googleApiVersion: 'v1', + externalIds: [externalId] + } + }) + expect(responses[1].url).toStrictEqual(notifyReqUrl) + expect(responses[1].status).toEqual(201) + expect(responses[1].data).toMatchObject(externalId) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-sms.test.ts b/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-sms.test.ts index b03bbd9a0a..db70c39ac4 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-sms.test.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-sms.test.ts @@ -1,9 +1,12 @@ import nock from 'nock' import { createTestAction, expectErrorLogged, expectInfoLogged, loggerMock as logger } from './__helpers__/test-utils' import { FLAGON_NAME_LOG_ERROR, FLAGON_NAME_LOG_INFO, SendabilityStatus } from '../../utils' -import { FLAGON_EVENT_STREAMS_ONBOARDING } from '../utils' -const defaultTags = JSON.stringify({}) +const phoneNumber = '+1234567891' +const defaultTags = JSON.stringify({ + external_id_type: 'phone', + external_id_value: phoneNumber +}) describe.each(['stage', 'production'])('%s environment', (environment) => { const contentSid = 'g' @@ -20,7 +23,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { traitEnrichment: true, externalIds: [ { type: 'email', id: 'test@twilio.com', subscriptionStatus: 'true' }, - { type: 'phone', id: '+1234567891', subscriptionStatus: 'true', channelType: 'sms' } + { type: 'phone', id: phoneNumber, subscriptionStatus: 'true', channelType: 'sms' } ], sendBasedOnOptOut: false }) @@ -159,7 +162,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -202,11 +205,100 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { expect(twilioContentRequest.isDone()).toEqual(true) }) + it('should send SMS with content sid and body', async () => { + const twilioMessagingRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json') + .reply(201, {}) + + const twilioContentResponse = { + types: { + 'twilio/text': { + body: '' + } + } + } + + const twilioContentRequest = nock('https://content.twilio.com') + .get(`/v1/Content/${contentSid}`) + .reply(200, twilioContentResponse) + + await testAction({ + mappingOverrides: { + contentSid + }, + mappingOmitKeys: ['body'] + }) + expect(twilioMessagingRequest.isDone()).toEqual(true) + expect(twilioContentRequest.isDone()).toEqual(true) + }) + + it('should send SMS with content sid and trait', async () => { + const twilioMessagingRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json') + .reply(201, {}) + + const twilioContentResponse = { + types: { + 'twilio/text': { + body: 'Hi {{profile.traits.firstName | default: "Person"}}' + } + } + } + + const twilioContentRequest = nock('https://content.twilio.com') + .get(`/v1/Content/${contentSid}`) + .reply(200, twilioContentResponse) + + const responses = await testAction({ + mappingOverrides: { + contentSid, + traits: { + firstName: '' + } + } + }) + expect(twilioMessagingRequest.isDone()).toEqual(true) + expect(twilioContentRequest.isDone()).toEqual(true) + expect( + responses.map((response) => response.options.body?.toString().includes('Hi+Person')).some((item) => item) + ).toEqual(true) + }) + + it('should send SMS with content sid and null traits', async () => { + const twilioMessagingRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json') + .reply(201, {}) + + const twilioContentResponse = { + types: { + 'twilio/text': { + body: 'Hi {{profile.traits.firstName | default: "Person"}}' + } + } + } + + const twilioContentRequest = nock('https://content.twilio.com') + .get(`/v1/Content/${contentSid}`) + .reply(200, twilioContentResponse) + + const responses = await testAction({ + mappingOverrides: { + contentSid, + traits: null + } + }) + expect(twilioMessagingRequest.isDone()).toEqual(true) + expect(twilioContentRequest.isDone()).toEqual(true) + expect( + responses.map((response) => response.options.body?.toString().includes('Hi+Person')).some((item) => item) + ).toEqual(true) + }) + it('should send MMS with media in payload', async () => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', MediaUrl: 'http://myimg.com', Tags: defaultTags @@ -240,7 +332,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true' }) @@ -275,9 +367,12 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: '+1 (505) 555-4555', ShortenUrls: 'true', - Tags: defaultTags + Tags: JSON.stringify({ + external_id_type: 'phone', + external_id_value: '+1 (505) 555-4555' + }) }) const twilioHostname = 'api.nottwilio.com' @@ -286,7 +381,12 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { .post('/Messages.json', expectedTwilioRequest.toString()) .reply(201, {}) - const responses = await testAction({ settingsOverrides: { twilioHostname } }) + const responses = await testAction({ + settingsOverrides: { twilioHostname }, + mappingOverrides: { + externalIds: [{ type: 'phone', id: '+1 (505) 555-4555', subscriptionStatus: true, channelType: 'sms' }] + } + }) expect(responses.map((response) => response.url)).toStrictEqual([ `https://${twilioHostname}/2010-04-01/Accounts/a/Messages.json` ]) @@ -297,7 +397,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags, StatusCallback: @@ -352,7 +452,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -382,7 +482,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -393,7 +493,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'sms' }] + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'sms' }] } }) expect(responses.map((response) => response.url)).toStrictEqual([ @@ -408,7 +508,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -419,7 +519,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'sms' }], + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'sms' }], sendBasedOnOptOut: true } }) @@ -436,7 +536,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -447,7 +547,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'sms' }], + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'sms' }], sendBasedOnOptOut: undefined } }) @@ -464,7 +564,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -475,7 +575,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'sms' }] + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'sms' }] } }) expect(responses).toHaveLength(0) @@ -489,7 +589,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -500,7 +600,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'sms' }], + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'sms' }], sendBasedOnOptOut: undefined } }) @@ -516,7 +616,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -527,7 +627,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'sms' }], + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'sms' }], sendBasedOnOptOut: true } }) @@ -542,7 +642,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -553,7 +653,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus: randomSubscriptionStatusPhrase }] + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus: randomSubscriptionStatusPhrase }] } }) expect(responses).toHaveLength(0) @@ -637,7 +737,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', Tags: defaultTags }) @@ -660,21 +760,18 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { }) it('add tags to body', async () => { - const features = { [FLAGON_EVENT_STREAMS_ONBOARDING]: true } - const expectedTwilioRequest = new URLSearchParams({ Body: 'Hello world, jane!', From: 'MG1111222233334444', - To: '+1234567891', + To: phoneNumber, ShortenUrls: 'true', - Tags: '{"audience_id":"1","correlation_id":"1","journey_name":"j-1","step_name":"2","campaign_name":"c-3","campaign_key":"4","user_id":"u-5","message_id":"m-6"}' + Tags: '{"audience_id":"1","correlation_id":"1","journey_name":"j-1","step_name":"2","campaign_name":"c-3","campaign_key":"4","user_id":"u-5","message_id":"m-6","external_id_type":"phone","external_id_value":"+1234567891"}' }) const twilioRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') .post('/Messages.json', expectedTwilioRequest.toString()) .reply(201, {}) const responses = await testAction({ - features, mappingOverrides: { customArgs: { audience_id: '1', diff --git a/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-whatsapp.test.ts b/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-whatsapp.test.ts index 60fe0e5dc9..208e5f02ee 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-whatsapp.test.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/__tests__/send-whatsapp.test.ts @@ -2,8 +2,12 @@ import nock from 'nock' import { createTestAction, expectErrorLogged, expectInfoLogged } from './__helpers__/test-utils' const defaultTemplateSid = 'my_template' -const defaultTo = 'whatsapp:+1234567891' -const defaultTags = JSON.stringify({}) +const phoneNumber = '+1234567891' +const defaultTo = `whatsapp:${phoneNumber}` +const defaultTags = JSON.stringify({ + external_id_type: 'phone', + external_id_value: phoneNumber +}) describe.each(['stage', 'production'])('%s environment', (environment) => { const spaceId = 'd' @@ -19,7 +23,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { traitEnrichment: true, externalIds: [ { type: 'email', id: 'test@twilio.com', subscriptionStatus: 'subscribed' }, - { type: 'phone', id: '+1234567891', subscriptionStatus: 'subscribed', channelType: 'whatsapp' } + { type: 'phone', id: phoneNumber, subscriptionStatus: 'subscribed', channelType: 'whatsapp' } ] }) }) @@ -35,10 +39,40 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { expect(responses.length).toEqual(0) }) + it('should abort when there are no external IDs in the payload', async () => { + const responses = await testAction({ + mappingOverrides: { + externalIds: [] + } + }) + + expect(responses.length).toEqual(0) + }) + + it('should abort when there is an empty `phone` external ID in the payload', async () => { + const responses = await testAction({ + mappingOverrides: { + externalIds: [{ type: 'phone', id: '', subscriptionStatus: 'subscribed' }] + } + }) + + expect(responses.length).toEqual(0) + }) + + it('should abort when there is a null `phone` external ID in the payload', async () => { + const responses = await testAction({ + mappingOverrides: { + externalIds: [{ type: 'phone', id: null, subscriptionStatus: 'subscribed' }] + } + }) + + expect(responses.length).toEqual(0) + }) + it('should abort when there is no `channelType` in the external ID payload', async () => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus: 'subscribed' }] + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus: 'subscribed' }] } }) @@ -64,6 +98,149 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { expect(twilioRequest.isDone()).toEqual(true) }) + it('should send WhatsApp for partially formatted E164 number in non-default region', async () => { + // EU number without "+" + const phone = '441112276181' + const expectedTwilioRequest = new URLSearchParams({ + ContentSid: defaultTemplateSid, + From: 'MG1111222233334444', + To: `whatsapp:+${phone}`, + Tags: JSON.stringify({ + external_id_type: 'phone', + external_id_value: phone // expect external id to stay the same.. without "+" + }) + }) + + const twilioRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json', expectedTwilioRequest.toString()) + .reply(201, {}) + + const responses = await testAction({ + mappingOverrides: { + externalIds: [ + // EU number without "+" + { type: 'phone', id: phone, subscriptionStatus: 'subscribed', channelType: 'whatsapp' } + ] + } + }) + expect(responses.map((response) => response.url)).toStrictEqual([ + 'https://api.twilio.com/2010-04-01/Accounts/a/Messages.json' + ]) + expect(twilioRequest.isDone()).toEqual(true) + }) + + it('should send WhatsApp for fully formatted E164 number in non-default region', async () => { + // EU number with "+" + const phone = '+441112276181' + const expectedTwilioRequest = new URLSearchParams({ + ContentSid: defaultTemplateSid, + From: 'MG1111222233334444', + To: `whatsapp:${phone}`, + Tags: JSON.stringify({ + external_id_type: 'phone', + external_id_value: phone + }) + }) + + const twilioRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json', expectedTwilioRequest.toString()) + .reply(201, {}) + + const responses = await testAction({ + mappingOverrides: { + externalIds: [ + // EU number wtih "+" + { type: 'phone', id: phone, subscriptionStatus: 'subscribed', channelType: 'whatsapp' } + ] + } + }) + expect(responses.map((response) => response.url)).toStrictEqual([ + 'https://api.twilio.com/2010-04-01/Accounts/a/Messages.json' + ]) + expect(twilioRequest.isDone()).toEqual(true) + }) + + it('should send WhatsApp for partially formatted E164 number in default region "US"', async () => { + const phone = '11116369373' + const expectedTwilioRequest = new URLSearchParams({ + ContentSid: defaultTemplateSid, + From: 'MG1111222233334444', + To: `whatsapp:+${phone}`, + Tags: JSON.stringify({ + external_id_type: 'phone', + external_id_value: phone + }) + }) + + const twilioRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json', expectedTwilioRequest.toString()) + .reply(201, {}) + + const responses = await testAction({ + mappingOverrides: { + externalIds: [{ type: 'phone', id: phone, subscriptionStatus: 'subscribed', channelType: 'whatsapp' }] + } + }) + expect(responses.map((response) => response.url)).toStrictEqual([ + 'https://api.twilio.com/2010-04-01/Accounts/a/Messages.json' + ]) + expect(twilioRequest.isDone()).toEqual(true) + }) + + it('should send WhatsApp for fully formatted E164 number in default region "US"', async () => { + const phone = '+11116369373' + const expectedTwilioRequest = new URLSearchParams({ + ContentSid: defaultTemplateSid, + From: 'MG1111222233334444', + To: `whatsapp:${phone}`, + Tags: JSON.stringify({ + external_id_type: 'phone', + external_id_value: phone + }) + }) + + const twilioRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json', expectedTwilioRequest.toString()) + .reply(201, {}) + + const responses = await testAction({ + mappingOverrides: { + externalIds: [{ type: 'phone', id: phone, subscriptionStatus: 'subscribed', channelType: 'whatsapp' }] + } + }) + expect(responses.map((response) => response.url)).toStrictEqual([ + 'https://api.twilio.com/2010-04-01/Accounts/a/Messages.json' + ]) + expect(twilioRequest.isDone()).toEqual(true) + }) + + it('should send WhatsApp for fully formatted E164 number for default region "US"', async () => { + const phone = '+11231233212' + const expectedTwilioRequest = new URLSearchParams({ + ContentSid: defaultTemplateSid, + From: 'MG1111222233334444', + To: `whatsapp:${phone}`, + Tags: JSON.stringify({ + external_id_type: 'phone', + external_id_value: phone + }) + }) + + const twilioRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') + .post('/Messages.json', expectedTwilioRequest.toString()) + .reply(201, {}) + + const responses = await testAction({ + mappingOverrides: { + externalIds: [{ type: 'phone', id: phone, subscriptionStatus: 'subscribed', channelType: 'whatsapp' }] + } + }) + expect(responses.map((response) => response.url)).toStrictEqual([ + 'https://api.twilio.com/2010-04-01/Accounts/a/Messages.json' + ]) + expect(twilioRequest.isDone()).toEqual(true) + }) + it('should send WhatsApp for custom hostname', async () => { const expectedTwilioRequest = new URLSearchParams({ ContentSid: defaultTemplateSid, @@ -137,7 +314,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'whatsapp' }] + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'whatsapp' }] } }) expect(responses.map((response) => response.url)).toStrictEqual([ @@ -162,7 +339,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { const responses = await testAction({ mappingOverrides: { - externalIds: [{ type: 'phone', id: '+1234567891', subscriptionStatus, channelType: 'whatsapp' }] + externalIds: [{ type: 'phone', id: phoneNumber, subscriptionStatus, channelType: 'whatsapp' }] } }) expect(responses).toHaveLength(0) @@ -188,7 +365,7 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { externalIds: [ { type: 'phone', - id: '+1234567891', + id: phoneNumber, subscriptionStatus: randomSubscriptionStatusPhrase, channelType: 'whatsapp' } @@ -205,7 +382,10 @@ describe.each(['stage', 'production'])('%s environment', (environment) => { ContentSid: defaultTemplateSid, From: from, To: 'whatsapp:+19195551234', - Tags: defaultTags + Tags: JSON.stringify({ + external_id_type: 'phone', + external_id_value: '(919) 555 1234' + }) }) const twilioRequest = nock('https://api.twilio.com/2010-04-01/Accounts/a') diff --git a/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/PushSender.ts b/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/PushSender.ts index 2b17604172..dce57c899e 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/PushSender.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/PushSender.ts @@ -120,7 +120,7 @@ export class PushSender extends TwilioMessageSender { return { ...button, onTap: this.getTapActionPreset(button.onTap, button.link), - ...(await this.parseContent({ link: button.link }, profile)) + ...(await this.parseContent({ link: button.link, text: button.text }, profile)) } }) ) @@ -146,12 +146,7 @@ export class PushSender extends TwilioMessageSender { Sound: this.payload.customizations?.sound, Priority: this.payload.customizations?.priority, TimeToLive: this.payload.customizations?.ttl, - FcmPayload: { - mutable_content: true, - notification: { - badge: badgeAmount - } - }, + FcmPayload: this.getFcmNotificationOverrides(badgeAmount), ApnPayload: { aps: { 'mutable-content': 1, @@ -172,6 +167,27 @@ export class PushSender extends TwilioMessageSender { } } + private getFcmNotificationOverrides(badgeAmount: number) { + // FCM V1 format + if (this.payload.googleApiVersion === 'v1') { + return { + android: { + mutable_content: true, + notification: { + badge: badgeAmount + } + } + } + } + // FCM legacy format + return { + mutable_content: true, + notification: { + badge: badgeAmount + } + } + } + // transforms open_app + url, to deep_link tap action preset // when the tap action is open_app and there is a link, it is supposed to be "deep_link" // any other conditions return the tap action as is diff --git a/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/actionDefinition.ts b/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/actionDefinition.ts index 73589132d0..24bc36eab4 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/actionDefinition.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/sendMobilePush/actionDefinition.ts @@ -163,6 +163,15 @@ export const actionDefinition: ActionDefinition = { required: false, default: false }, + segmentComputationId: { + label: 'Segment Computation ID', + description: 'Segment computation ID', + type: 'string', + required: false, + default: { + '@path': '$.context.personas.computation_id' + } + }, externalIds: { label: 'External IDs', description: 'An array of user profile identity information.', @@ -225,6 +234,17 @@ export const actionDefinition: ActionDefinition = { default: { '@path': '$.timestamp' } + }, + googleApiVersion: { + label: 'Google Api Version', + description: 'Controls the notification payload format', + type: 'string', + required: false, + choices: [ + { label: 'legacy', value: 'legacy' }, + { label: 'v1', value: 'v1' } + ], + default: 'legacy' } }, perform: async (request, data) => { diff --git a/packages/destination-actions/src/destinations/engage/twilio/sendSms/actionDefinition.ts b/packages/destination-actions/src/destinations/engage/twilio/sendSms/actionDefinition.ts index 2362758f8f..12fc8ddfe3 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/sendSms/actionDefinition.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/sendSms/actionDefinition.ts @@ -78,6 +78,15 @@ export const actionDefinition: ActionDefinition = { type: 'boolean', default: false }, + segmentComputationId: { + label: 'Segment Computation ID', + description: 'Segment computation ID', + type: 'string', + required: false, + default: { + '@path': '$.context.personas.computation_id' + } + }, externalIds: { label: 'External IDs', description: 'An array of user profile identity information.', diff --git a/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/WhatsAppMessageSender.ts b/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/WhatsAppMessageSender.ts index 8870d164c6..35e077a8b6 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/WhatsAppMessageSender.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/WhatsAppMessageSender.ts @@ -19,15 +19,39 @@ export class WhatsAppMessageSender extends PhoneMessageSender { @track() async getBody(phone: string) { - let parsedPhone + if (!this.payload.contentSid) { + throw new IntegrationError('A valid whatsApp Content SID was not provided.', `INVALID_CONTENT_SID`, 400) + } + + const params: Record = { + ContentSid: this.payload.contentSid, + From: this.payload.from, + To: this.parsePhoneNumber(phone) + } + const contentVariables = await this.getVariables() + + if (contentVariables) params['ContentVariables'] = contentVariables + return new URLSearchParams(params) + } + + @track() + private parsePhoneNumber(phone: string): string { + let parsedPhone try { // Defaulting to US for now as that's where most users will seemingly be. Though // any number already given in e164 format should parse correctly even with the // default region being US. parsedPhone = phoneUtil.parse(phone, 'US') + // parsedPhone will not be valid nor possible if an erroneous region is added to it (US) + if (!phoneUtil.isPossibleNumber(parsedPhone) || !phoneUtil.isValidNumber(parsedPhone)) { + // the number we received may already have a region code embedded in it but may be missing a "+", or it may be truly invalid + // try again, adding a "+" in front of the number + parsedPhone = phoneUtil.parse('+' + phone, 'US') + } parsedPhone = phoneUtil.format(parsedPhone, PhoneNumberFormat.E164) - parsedPhone = `whatsapp:${parsedPhone}` + // return E164 number with whatsapp prepended + return `whatsapp:${parsedPhone}` } catch (e) { const underlyingError = e as Error throw new IntegrationError( @@ -36,21 +60,6 @@ export class WhatsAppMessageSender extends PhoneMessageSender { 400 ) } - - if (!this.payload.contentSid) { - throw new IntegrationError('A valid whatsApp Content SID was not provided.', `INVALID_CONTENT_SID`, 400) - } - - const params: Record = { - ContentSid: this.payload.contentSid, - From: this.payload.from, - To: parsedPhone - } - const contentVariables = await this.getVariables() - - if (contentVariables) params['ContentVariables'] = contentVariables - - return new URLSearchParams(params) } @track({ diff --git a/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/actionDefinition.ts b/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/actionDefinition.ts index dd1ebe421d..5ffd51c757 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/actionDefinition.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/sendWhatsApp/actionDefinition.ts @@ -59,6 +59,15 @@ export const actionDefinition: ActionDefinition = { required: false, default: true }, + segmentComputationId: { + label: 'Segment Computation ID', + description: 'Segment computation ID', + type: 'string', + required: false, + default: { + '@path': '$.context.personas.computation_id' + } + }, externalIds: { label: 'External IDs', description: 'An array of user profile identity information.', diff --git a/packages/destination-actions/src/destinations/engage/twilio/utils/PhoneMessageSender.ts b/packages/destination-actions/src/destinations/engage/twilio/utils/PhoneMessageSender.ts index 3af2c09580..802565e7d9 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/utils/PhoneMessageSender.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/utils/PhoneMessageSender.ts @@ -3,8 +3,6 @@ import { TwilioMessageSender, TwilioPayloadBase } from './TwilioMessageSender' import { OperationDecorator, TrackedError, OperationContext, ExtId } from '../../utils' -export const FLAGON_EVENT_STREAMS_ONBOARDING = 'event-streams-onboarding' - /** * Base class for sending sms/mms */ @@ -50,7 +48,9 @@ export abstract class PhoneMessageSender ex campaign_name: this.payload.customArgs && this.payload.customArgs['campaign_name'], campaign_key: this.payload.customArgs && this.payload.customArgs['campaign_key'], user_id: this.payload.customArgs && this.payload.customArgs['user_id'], - message_id: this.payload.customArgs && this.payload.customArgs['message_id'] + message_id: this.payload.customArgs && this.payload.customArgs['message_id'], + external_id_type: recepient.type, + external_id_value: phone } body.append('Tags', JSON.stringify(tags)) diff --git a/packages/destination-actions/src/destinations/engage/twilio/utils/TwilioMessageSender.ts b/packages/destination-actions/src/destinations/engage/twilio/utils/TwilioMessageSender.ts index 1995fcd975..66f19a7a55 100644 --- a/packages/destination-actions/src/destinations/engage/twilio/utils/TwilioMessageSender.ts +++ b/packages/destination-actions/src/destinations/engage/twilio/utils/TwilioMessageSender.ts @@ -38,6 +38,15 @@ export abstract class TwilioMessageSender ex content: R, profile: Profile ): Promise { + const traits = profile.traits ? { ...profile.traits } : profile.traits + if (traits) { + for (const trait of Object.keys(traits)) { + if (traits && traits[trait] === '') { + traits[trait] = '' + } + } + } + const parsedEntries = await Promise.all( Object.entries(content).map(async ([key, val]) => { if (val == null) { @@ -45,9 +54,9 @@ export abstract class TwilioMessageSender ex } if (Array.isArray(val)) { - val = await Promise.all(val.map((item) => Liquid.parseAndRender(item, { profile }))) + val = await Promise.all(val.map((item) => Liquid.parseAndRender(item, { profile: { ...profile, traits } }))) } else { - val = await Liquid.parseAndRender(val, { profile }) + val = await Liquid.parseAndRender(val, { profile: { ...profile, traits } }) } return [key, val] }) diff --git a/packages/destination-actions/src/destinations/engage/utils/EngageActionPerformer.ts b/packages/destination-actions/src/destinations/engage/utils/EngageActionPerformer.ts index d0c8ab283e..62e5594cf1 100644 --- a/packages/destination-actions/src/destinations/engage/utils/EngageActionPerformer.ts +++ b/packages/destination-actions/src/destinations/engage/utils/EngageActionPerformer.ts @@ -19,6 +19,7 @@ import truncate from 'lodash/truncate' export abstract class EngageActionPerformer { readonly logger: EngageLogger = new EngageLogger(this) readonly statsClient: EngageStats = new EngageStats(this) + readonly dataFeedCache = this.executeInput.dataFeedCache readonly currentOperation: OperationContext | undefined readonly payload: TPayload @@ -69,7 +70,15 @@ export abstract class EngageActionPerformer +} diff --git a/packages/destination-actions/src/destinations/engage/utils/ResponseError.ts b/packages/destination-actions/src/destinations/engage/utils/ResponseError.ts index 55d3dc5cad..d73e4ca0c8 100644 --- a/packages/destination-actions/src/destinations/engage/utils/ResponseError.ts +++ b/packages/destination-actions/src/destinations/engage/utils/ResponseError.ts @@ -3,7 +3,7 @@ import { HTTPError } from '@segment/actions-core/request-client' export interface ResponseError extends HTTPError { response: HTTPError['response'] & { data: { - code: string + code: string | number message: string more_info: string status?: number @@ -18,8 +18,14 @@ export interface ResponseError extends HTTPError { status?: number } +export interface ErrorDetails { + message: string + code: string + status?: number +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getErrorDetails(error: any) { +export function getErrorDetails(error: any): ErrorDetails { //example of errors are here: https://segment.atlassian.net/browse/CHANNELS-819 // each API may have its own response.data structure. E.g. Twilio has code, message, more_info, status, while Sendgrid has array of errors where each has `message`, `field`, `help`. const respError = error as ResponseError @@ -31,7 +37,7 @@ export function getErrorDetails(error: any) { const code = respError.code || respError.response?.data?.code // || respError.response?.statusText // e.g. 'Not Found' for 404 - + const message = [ respError.name || respError.constructor?.name, respError.message, @@ -45,5 +51,5 @@ export function getErrorDetails(error: any) { ] .filter(Boolean) .join('; ') - return { status, code, message } + return { status, code: code?.toString(), message } } diff --git a/packages/destination-actions/src/destinations/engage/utils/__tests__/apiLookups.test.ts b/packages/destination-actions/src/destinations/engage/utils/__tests__/apiLookups.test.ts new file mode 100644 index 0000000000..f8163477b2 --- /dev/null +++ b/packages/destination-actions/src/destinations/engage/utils/__tests__/apiLookups.test.ts @@ -0,0 +1,493 @@ +import nock from 'nock' +import { ApiLookupConfig, getRequestId, performApiLookup } from '../apiLookups' +import { DataFeedCache } from '../../../../../../core/src/destination-kit/index' +import createRequestClient from '../../../../../../core/src/create-request-client' + +const profile = { + traits: { + userId: 'jane', + firstName: 'First Name', + lastName: 'Browning', + phone: '+11235554657', + email: 'test@example.com' + } +} + +const settings = { + unlayerApiKey: 'unlayerApiKey', + sendGridApiKey: 'sendGridApiKey', + profileApiEnvironment: 'staging', + profileApiAccessToken: 'c', + spaceId: 'spaceId', + sourceId: 'sourceId', + region: 'us-west-2' +} + +const nonCachedApiLookup = { + id: '1', + name: 'weather', + url: 'https://fakeweather.com/api/current', + method: 'get', + cacheTtl: 0, + responseType: 'json' +} + +const cachedApiLookup = { + ...nonCachedApiLookup, + cacheTtl: 60000 +} + +const createMockRequestStore = (overrides?: Partial) => { + const mockStore: Record = {} + const mockDataFeedCache: DataFeedCache = { + setRequestResponse: jest.fn(async (requestId, response) => { + mockStore[requestId] = response + }), + getRequestResponse: jest.fn(async (requestId) => { + return mockStore[requestId] + }), + maxExpirySeconds: 600000, + maxResponseSizeBytes: 1000000, + ...overrides + } + return mockDataFeedCache +} + +const request = createRequestClient({}) + +afterEach(() => { + jest.clearAllMocks() + nock.cleanAll() +}) + +describe('api lookups', () => { + it('liquid renders url and body with profile traits before requesting', async () => { + const apiLookupRequest = nock(`https://fakeweather.com`) + .post(`/api/${profile.traits.lastName}`, { firstName: profile.traits.firstName }) + .reply(200, { + current: { + temperature: 70 + } + }) + + const data = await performApiLookup( + request, + { + ...nonCachedApiLookup, + url: 'https://fakeweather.com/api/{{profile.traits.lastName}}', + body: '{ "firstName": "{{profile.traits.firstName}}" }', + method: 'post' + }, + profile, + undefined, + [], + settings, + undefined, + undefined + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + expect(data).toEqual({ + current: { + temperature: 70 + } + }) + }) + + it('rethrows error when shouldRetryOnRetryableError is true and api call fails', async () => { + const apiLookupRequest = nock(`https://fakeweather.com`).get(`/api/current`).reply(429) + + await expect( + performApiLookup( + request, + { + ...nonCachedApiLookup, + shouldRetryOnRetryableError: true + }, + profile, + undefined, + [], + settings, + undefined, + undefined + ) + ).rejects.toThrowError() + + expect(apiLookupRequest.isDone()).toEqual(true) + }) + + it('does not rethrow error and returns empty object when shouldRetryOnRetryableError is false and api call fails', async () => { + const apiLookupRequest = nock(`https://fakeweather.com`).get(`/api/current`).reply(429) + + const data = await performApiLookup( + request, + { + ...nonCachedApiLookup, + shouldRetryOnRetryableError: false + }, + profile, + undefined, + [], + settings, + undefined, + undefined + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + expect(data).toEqual({}) + }) + + describe('when cacheTtl > 0', () => { + it('throws error if cache is not available', async () => { + const apiLookupRequest = nock(`https://fakeweather.com`) + .get(`/api/current`) + .reply(200, { + current: { + temperature: 70 + } + }) + + await expect( + performApiLookup(request, cachedApiLookup, profile, undefined, [], settings, undefined, undefined) + ).rejects.toThrowError('Data feed cache not available and cache needed') + + expect(apiLookupRequest.isDone()).toEqual(false) + }) + + it('throws error if response size is too big', async () => { + const mockDataFeedCache = createMockRequestStore({ maxResponseSizeBytes: 1 }) + const apiLookupRequest = nock(`https://fakeweather.com`) + .get(`/api/current`) + .reply(200, { + current: { + temperature: 70 + } + }) + + await expect( + performApiLookup(request, cachedApiLookup, profile, undefined, [], settings, undefined, mockDataFeedCache) + ).rejects.toThrowError('Data feed response size too big too cache and caching needed, failing send') + + expect(apiLookupRequest.isDone()).toEqual(true) + }) + + it('sets cache when cache miss', async () => { + const mockDataFeedCache = createMockRequestStore() + const apiLookupRequest = nock(`https://fakeweather.com`) + .get(`/api/current`) + .reply(200, { + current: { + temperature: 70 + } + }) + + const data = await performApiLookup( + request, + cachedApiLookup, + profile, + undefined, + [], + settings, + undefined, + mockDataFeedCache + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + const requestId = getRequestId(cachedApiLookup) + expect(mockDataFeedCache.setRequestResponse).toHaveBeenCalledWith( + requestId, + '{"current":{"temperature":70}}', + cachedApiLookup.cacheTtl / 1000 + ) + expect(data).toEqual({ + current: { + temperature: 70 + } + }) + }) + + it('uses cache when cache entry exists', async () => { + const apiLookupRequest = nock(`https://fakeweather.com`) + .get(`/api/current`) + .reply(200, { + current: { + temperature: 70 + } + }) + + const mockDataFeedCache = createMockRequestStore() + const requestId = getRequestId(cachedApiLookup) + await mockDataFeedCache.setRequestResponse(requestId, '{"current":{"temperature":70}}', cachedApiLookup.cacheTtl) + + const data = await performApiLookup( + request, + cachedApiLookup, + profile, + undefined, + [], + settings, + undefined, + mockDataFeedCache + ) + + expect(apiLookupRequest.isDone()).toEqual(false) + expect(data).toEqual({ + current: { + temperature: 70 + } + }) + }) + + describe('cached responses are unique dependent on api config post liquid rendering value', () => { + const profiles = [{ traits: { lastName: 'Browning' } }, { traits: { lastName: 'Smith' } }] + + it('url is different', async () => { + const mockDataFeedCache = createMockRequestStore() + const config: ApiLookupConfig = { + url: 'https://fakeweather.com/api/current/{{profile.traits.lastName}}', + method: 'get', + name: 'test', + cacheTtl: 60000, + responseType: 'json' + } + + for (const [i, profile] of profiles.entries()) { + const renderedPath = `/api/current/${profile.traits.lastName}` + const profileSpecificTemperature = profile.traits.lastName === 'Browning' ? 70 : 60 + + const apiLookupRequest = nock(`https://fakeweather.com`) + .get(renderedPath) + .reply(200, { + current: { + temperature: profileSpecificTemperature + } + }) + + const data = await performApiLookup( + request, + config, + profile, + undefined, + [], + settings, + undefined, + mockDataFeedCache + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + + const requestId = getRequestId({ ...config, url: `https://fakeweather.com${renderedPath}` }) + + expect(mockDataFeedCache.setRequestResponse).toHaveBeenNthCalledWith( + i + 1, + requestId, + `{"current":{"temperature":${profileSpecificTemperature}}}`, + cachedApiLookup.cacheTtl / 1000 + ) + + expect(data).toEqual({ + current: { + temperature: profileSpecificTemperature + } + }) + } + }) + + it('body is different', async () => { + const mockDataFeedCache = createMockRequestStore() + const config: ApiLookupConfig = { + url: 'https://fakeweather.com/api/current', + method: 'post', + body: '{"lastName":"{{profile.traits.lastName}}"}', + name: 'test', + cacheTtl: 60000, + responseType: 'json' + } + + for (const [i, profile] of profiles.entries()) { + const renderedBody = { lastName: profile.traits.lastName } + const profileSpecificTemperature = profile.traits.lastName === 'Browning' ? 70 : 60 + + const apiLookupRequest = nock(`https://fakeweather.com`) + .post('/api/current', renderedBody) + .reply(200, { + current: { + temperature: profileSpecificTemperature + } + }) + + const data = await performApiLookup( + request, + config, + profile, + undefined, + [], + settings, + undefined, + mockDataFeedCache + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + + const requestId = getRequestId({ ...config, body: JSON.stringify(renderedBody) }) + + expect(mockDataFeedCache.setRequestResponse).toHaveBeenNthCalledWith( + i + 1, + requestId, + `{"current":{"temperature":${profileSpecificTemperature}}}`, + cachedApiLookup.cacheTtl / 1000 + ) + + expect(data).toEqual({ + current: { + temperature: profileSpecificTemperature + } + }) + } + }) + + it('headers are different', async () => { + const mockDataFeedCache = createMockRequestStore() + const config1: ApiLookupConfig = { + url: 'https://fakeweather.com/api/current', + method: 'get', + headers: { a: 'a' }, + name: 'test', + cacheTtl: 60000, + responseType: 'json' + } + + const config2: ApiLookupConfig = { + ...config1, + headers: { a: 'b' } + } + + for (const [i, config] of [config1, config2].entries()) { + const configSpecificTemperature = JSON.stringify(config.headers) === JSON.stringify(config1.headers) ? 70 : 60 + + const apiLookupRequest = nock(`https://fakeweather.com`, { + reqheaders: config.headers as Record + }) + .get('/api/current') + .reply(200, { + current: { + temperature: configSpecificTemperature + } + }) + + const data = await performApiLookup( + request, + config, + profile, + undefined, + [], + settings, + undefined, + mockDataFeedCache + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + + const requestId = getRequestId({ ...config, headers: config.headers }) + + expect(mockDataFeedCache.setRequestResponse).toHaveBeenNthCalledWith( + i + 1, + requestId, + `{"current":{"temperature":${configSpecificTemperature}}}`, + cachedApiLookup.cacheTtl / 1000 + ) + + expect(data).toEqual({ + current: { + temperature: configSpecificTemperature + } + }) + } + }) + + it('methods are different', async () => { + const mockDataFeedCache = createMockRequestStore() + const config1: ApiLookupConfig = { + url: 'https://fakeweather.com/api/current', + method: 'get', + name: 'test', + cacheTtl: 60000, + responseType: 'json' + } + + const config2: ApiLookupConfig = { + ...config1, + method: 'post' + } + + for (const [i, config] of [config1, config2].entries()) { + const configSpecificTemperature = JSON.stringify(config.headers) === JSON.stringify(config1.headers) ? 70 : 60 + + const apiLookupRequest = nock(`https://fakeweather.com`) + .intercept('/api/current', config.method) + .reply(200, { + current: { + temperature: configSpecificTemperature + } + }) + + const data = await performApiLookup( + request, + config, + profile, + undefined, + [], + settings, + undefined, + mockDataFeedCache + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + + const requestId = getRequestId({ ...config }) + + expect(mockDataFeedCache.setRequestResponse).toHaveBeenNthCalledWith( + i + 1, + requestId, + `{"current":{"temperature":${configSpecificTemperature}}}`, + cachedApiLookup.cacheTtl / 1000 + ) + + expect(data).toEqual({ + current: { + temperature: configSpecificTemperature + } + }) + } + }) + }) + }) + + describe('when cacheTtl = 0', () => { + it('does not set or lookup cache', async () => { + const mockDataFeedCache = createMockRequestStore() + const apiLookupRequest = nock(`https://fakeweather.com`) + .get(`/api/current`) + .reply(200, { + current: { + temperature: 70 + } + }) + + await performApiLookup( + request, + nonCachedApiLookup, + profile, + undefined, + [], + settings, + undefined, + mockDataFeedCache + ) + + expect(apiLookupRequest.isDone()).toEqual(true) + expect(mockDataFeedCache.setRequestResponse).not.toHaveBeenCalled() + expect(mockDataFeedCache.getRequestResponse).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/engage/utils/apiLookups.ts b/packages/destination-actions/src/destinations/engage/utils/apiLookups.ts new file mode 100644 index 0000000000..f4c897bb37 --- /dev/null +++ b/packages/destination-actions/src/destinations/engage/utils/apiLookups.ts @@ -0,0 +1,257 @@ +import { createHash } from 'crypto' +import { IntegrationError } from '@segment/actions-core' +import { InputField } from '@segment/actions-core' +import { RequestClient, RequestOptions } from '@segment/actions-core' +import { Logger, StatsClient, DataFeedCache } from '@segment/actions-core/destination-kit' +import type { Settings } from '../sendgrid/generated-types' +import { Liquid as LiquidJs } from 'liquidjs' +import { Profile } from './Profile' +import { ResponseError } from './ResponseError' + +const Liquid = new LiquidJs() + +export type ApiLookupConfig = { + id?: string | undefined + name: string + url: string + method: string + /** Cache ttl in ms */ + cacheTtl: number + body?: string | undefined + headers?: object | undefined + responseType: string + /** Whether the message should be retired (if the error code is retryable) when the data feed fails */ + shouldRetryOnRetryableError?: boolean +} + +type LogDataFeedError = (message: string, error?: any) => void + +const renderLiquidFields = async ( + { url, body }: Pick, + profile: Profile, + datafeedTags: string[], + logDataFeedError: LogDataFeedError +) => { + let renderedUrl: string + let renderedBody: string | undefined + try { + renderedUrl = await Liquid.parseAndRender(url, { profile }) + } catch (error) { + logDataFeedError('URL liquid render failuere', error) + datafeedTags.push('error:true', 'reason:rendering_failure', 'rendering:url') + throw new IntegrationError('Unable to parse data feed url', 'DATA_FEED_RENDERING_ERROR', 400) + } + try { + renderedBody = body ? await Liquid.parseAndRender(body, { profile }) : undefined + } catch (error) { + logDataFeedError('Body liquid render failure', error) + datafeedTags.push('error:true', 'reason:rendering_failure', 'rendering:body') + throw new IntegrationError('Unable to parse email api lookup body', 'DATA_FEED_RENDERING_ERROR', 400) + } + + return { + renderedUrl, + renderedBody + } +} + +export const getRequestId = ({ method, url, body, headers }: ApiLookupConfig) => { + const requestHash = createHash('sha256') + // We hash the request to make the key smaller and to prevent storage of any sensitive data within the config + requestHash.update(`${method}${url}${body}${JSON.stringify(headers)}`) + const requestId = requestHash.digest('hex') + return requestId +} + +export const getCachedResponse = async ( + { responseType }: ApiLookupConfig, + requestId: string, + dataFeedCache: DataFeedCache, + datafeedTags: string[] +) => { + const cachedResponse = await dataFeedCache.getRequestResponse(requestId) + if (!cachedResponse) { + datafeedTags.push('cache_hit:false') + return + } + + datafeedTags.push('cache_hit:true') + if (responseType === 'json') { + return JSON.parse(cachedResponse) + } + return cachedResponse +} + +export const performApiLookup = async ( + request: RequestClient, + apiLookupConfig: ApiLookupConfig, + profile: Profile, + statsClient: StatsClient | undefined, + tags: string[], + settings: Settings, + logger?: Logger | undefined, + dataFeedCache?: DataFeedCache | undefined +) => { + const { id, method, headers, cacheTtl, name, shouldRetryOnRetryableError = true } = apiLookupConfig + const datafeedTags = [ + ...tags, + `datafeed_id:${id}`, + `datafeed_name:${name}`, + `space_id:${settings.spaceId}`, + `cache_ttl_greater_than_0:${cacheTtl > 0}`, + `retry_enabled:${shouldRetryOnRetryableError}` + ] + + const logDataFeedError: LogDataFeedError = (message: string, error?: any) => { + logger?.error( + `TE Messaging: Data feed error - message: ${message} - data feed name: ${name} - data feed id: ${id} - space id: ${settings.spaceId} - raw error: ${error}` + ) + } + + try { + const { renderedUrl, renderedBody } = await renderLiquidFields( + apiLookupConfig, + profile, + datafeedTags, + logDataFeedError + ) + + const requestId = getRequestId({ ...apiLookupConfig, url: renderedUrl, body: renderedBody }) + + if (cacheTtl > 0 && !dataFeedCache) { + logDataFeedError('Data feed cache not available and cache needed') + datafeedTags.push('cache_set:false') + throw new IntegrationError('Data feed cache not available and cache needed', 'DATA_FEED_CACHE_NOT_AVAILABLE', 400) + } + + // First check for cached response before calling 3rd party api + if (cacheTtl > 0 && dataFeedCache) { + const cachedResponse = await getCachedResponse(apiLookupConfig, requestId, dataFeedCache, datafeedTags) + if (cachedResponse) { + datafeedTags.push('error:false') + return cachedResponse + } + } + + // If not cached then call the 3rd party api + let data + try { + const res = await request(renderedUrl, { + headers: (headers as Record) ?? undefined, + timeout: 3000, + method: method as RequestOptions['method'], + body: renderedBody, + skipResponseCloning: true + }) + data = await res.data + } catch (error: any) { + const respError = error.response as ResponseError + logDataFeedError('Data feed call failure', error) + datafeedTags.push(`error:true`, `error_status:${respError?.status}`, 'reason:api_call_failure') + + // If retry is enabled for this data feed then rethrow the error to preserve centrifuge default retry logic + if (shouldRetryOnRetryableError) { + throw error + } + // Otherwise return empty data for this data feed + return {} + } + + // Then set the response in cache + if (cacheTtl > 0 && dataFeedCache) { + const dataString = JSON.stringify(data) + const size = Buffer.byteLength(dataString, 'utf-8') + + if (size > dataFeedCache.maxResponseSizeBytes) { + datafeedTags.push('error:true', 'reason:response_size_too_big') + logDataFeedError('Data feed response size too big too cache and caching needed, failing send') + throw new IntegrationError( + 'Data feed response size too big too cache and caching needed, failing send', + 'DATA_FEED_RESPONSE_TOO_BIG', + 400 + ) + } + + try { + await dataFeedCache.setRequestResponse(requestId, dataString, cacheTtl / 1000) + datafeedTags.push('cache_set:true') + } catch (error) { + logDataFeedError('Data feed cache set failure', error) + datafeedTags.push('error:true', 'reason:cache_set_failure', 'cache_set:false') + throw error + } + } + + datafeedTags.push('error:false') + return data + } catch (error) { + const isErrorCapturedInTags = datafeedTags.find((str) => str.includes('error:true')) + if (!isErrorCapturedInTags) { + datafeedTags.push('error:true', 'reason:unknown') + } + tags.push('reason:datafeed_failure') + logDataFeedError('Unexpected data feed error', error) + throw error + } finally { + statsClient?.incr('datafeed_execution', 1, datafeedTags) + } +} + +/** The action definition config fields representing a single API lookup */ +export const apiLookupActionFields: Record = { + id: { + label: 'ID', + description: 'The id of the API lookup for use in logging & observability', + type: 'string' + }, + name: { + label: 'Name', + description: 'The name of the API lookup referenced in liquid syntax', + type: 'string', + required: true + }, + url: { + label: 'URL', + description: 'The URL endpoint to call', + type: 'string', + required: true + }, + method: { + label: 'Request Method', + description: 'The request method, e.g. GET/POST/etc.', + type: 'string', + required: true + }, + cacheTtl: { + label: 'Cache TTL', + description: 'The cache TTL in ms', + type: 'integer', + required: true + }, + body: { + label: 'Request Body', + description: 'The request body for use with POST/PUT/PATCH requests', + type: 'string' + }, + headers: { + label: 'Request Headers', + description: 'Headers in JSON to be sent with the request', + type: 'object' + }, + responseType: { + label: 'Response Type', + description: 'The response type of the request. Currently only supporting JSON.', + type: 'string', + required: true + }, + shouldRetryOnRetryableError: { + label: 'Should Retry', + description: + 'Whether the message should be retried (if the error code is retryable) when the data feed fails or if it should be sent with empty data instead', + type: 'boolean' + } +} + +export const apiLookupLiquidKey = 'datafeeds' + +export const FLAGON_NAME_DATA_FEEDS = 'is-datafeeds-enabled' diff --git a/packages/destination-actions/src/destinations/equals/__tests__/__snapshots__/shapshot.test.ts.snap b/packages/destination-actions/src/destinations/equals/__tests__/__snapshots__/shapshot.test.ts.snap new file mode 100644 index 0000000000..d23dba0031 --- /dev/null +++ b/packages/destination-actions/src/destinations/equals/__tests__/__snapshots__/shapshot.test.ts.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-equals destination: send action - all fields 1`] = ` +Object { + "testType": "6SJ]ZTn3vKM", +} +`; + +exports[`Testing snapshot for actions-equals destination: send action - required fields 1`] = ` +Object { + "testType": "6SJ]ZTn3vKM", +} +`; diff --git a/packages/destination-actions/src/destinations/equals/__tests__/shapshot.test.ts b/packages/destination-actions/src/destinations/equals/__tests__/shapshot.test.ts new file mode 100644 index 0000000000..6e6d8ead21 --- /dev/null +++ b/packages/destination-actions/src/destinations/equals/__tests__/shapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-equals' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: { + url: 'https://www.someurl.com' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: { + url: 'https://www.someurl.com' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/equals/generated-types.ts b/packages/destination-actions/src/destinations/equals/generated-types.ts new file mode 100644 index 0000000000..5a5902ace9 --- /dev/null +++ b/packages/destination-actions/src/destinations/equals/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Equals URL to send data to. + */ + url: string +} diff --git a/packages/destination-actions/src/destinations/equals/index.ts b/packages/destination-actions/src/destinations/equals/index.ts new file mode 100644 index 0000000000..dbd49c797f --- /dev/null +++ b/packages/destination-actions/src/destinations/equals/index.ts @@ -0,0 +1,37 @@ +import { DestinationDefinition, defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import send from './send' + +const destination: DestinationDefinition = { + name: 'Equals', + slug: 'actions-equals', + mode: 'cloud', + description: 'Send Segment analytics data to Equals.', + + authentication: { + scheme: 'custom', + fields: { + url: { + label: 'Equals URL', + description: 'Equals URL to send data to.', + type: 'string', + required: true + } + } + }, + presets: [ + { + name: 'Send', + subscribe: 'type = track or type = page or type = screen or type = identify or type = group', + partnerAction: 'send', + mapping: defaultValues(send.fields), + type: 'automatic' + } + ], + actions: { + send + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/equals/send/generated-types.ts b/packages/destination-actions/src/destinations/equals/send/generated-types.ts new file mode 100644 index 0000000000..e2d39ea550 --- /dev/null +++ b/packages/destination-actions/src/destinations/equals/send/generated-types.ts @@ -0,0 +1,10 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Payload to deliver to Equals. Detaults to sending the entire Segment payload. + */ + data: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/equals/send/index.ts b/packages/destination-actions/src/destinations/equals/send/index.ts new file mode 100644 index 0000000000..6524cfde78 --- /dev/null +++ b/packages/destination-actions/src/destinations/equals/send/index.ts @@ -0,0 +1,38 @@ +import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Send', + defaultSubscription: 'type = track or type = page or type = screen or type = identify or type = group', + description: 'Send Segment analytics data to Equals', + fields: { + data: { + label: 'Data', + description: 'Payload to deliver to Equals. Detaults to sending the entire Segment payload.', + type: 'object', + required: true, + defaultObjectUI: 'object', + default: { '@path': '$.' } + } + }, + perform: (request, { payload, settings }) => { + try { + return request(settings.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'Segment Equals Destination' + }, + json: payload.data + }) + } catch (error) { + if (error instanceof TypeError) { + throw new PayloadValidationError(error.message) + } + throw error + } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/facebook-conversions-api/constants.ts b/packages/destination-actions/src/destinations/facebook-conversions-api/constants.ts index 8f0b1834a0..7618b9c0ff 100644 --- a/packages/destination-actions/src/destinations/facebook-conversions-api/constants.ts +++ b/packages/destination-actions/src/destinations/facebook-conversions-api/constants.ts @@ -1,5 +1,5 @@ -export const API_VERSION = '16.0' -export const CANARY_API_VERSION = '16.0' +export const API_VERSION = '18.0' +export const CANARY_API_VERSION = '18.0' export const CURRENCY_ISO_CODES = new Set([ 'AED', 'AFN', diff --git a/packages/destination-actions/src/destinations/fullstory/__tests__/fullstory.test.ts b/packages/destination-actions/src/destinations/fullstory/__tests__/fullstory.test.ts index db7b0e8ba7..c8973200fb 100644 --- a/packages/destination-actions/src/destinations/fullstory/__tests__/fullstory.test.ts +++ b/packages/destination-actions/src/destinations/fullstory/__tests__/fullstory.test.ts @@ -18,7 +18,7 @@ const testDestination = createTestIntegration(Definition) describe('FullStory', () => { describe('testAuthentication', () => { it('makes expected request', async () => { - nock(baseUrl).get('/operations/v1?limit=1').reply(200) + nock(baseUrl).get('/me').reply(200) await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() }) }) @@ -156,7 +156,7 @@ describe('FullStory', () => { describe('onDelete', () => { const falsyUserIds = ['', undefined, null] it('makes expected request given a valid user id', async () => { - nock(baseUrl).delete(`/v2beta/users?uid=${urlEncodedUserId}`).reply(200) + nock(baseUrl).delete(`/v2/users?uid=${urlEncodedUserId}`).reply(200) await expect(testDestination.onDelete!({ type: 'delete', userId }, settings)).resolves.not.toThrowError() }) @@ -171,7 +171,7 @@ describe('FullStory', () => { describe('identifyUserV2', () => { it('makes expected request with default mappings', async () => { - nock(baseUrl).post(`/v2beta/users?${integrationSourceQueryParam}`).reply(200) + nock(baseUrl).post(`/v2/users`).reply(200) const event = createTestEvent({ type: 'identify', userId, @@ -212,7 +212,7 @@ describe('FullStory', () => { describe('trackEventV2', () => { it('makes expected request with default mappings', async () => { - nock(baseUrl).post(`/v2beta/events?${integrationSourceQueryParam}`).reply(200) + nock(baseUrl).post(`/v2/events`).reply(200) const eventName = 'test-event' const sessionId = '12345:678' @@ -272,7 +272,7 @@ describe('FullStory', () => { }) it('handles undefined event values', async () => { - nock(baseUrl).post(`/v2beta/events?${integrationSourceQueryParam}`).reply(200) + nock(baseUrl).post(`/v2/events`).reply(200) const eventName = 'test-event' const event = createTestEvent({ diff --git a/packages/destination-actions/src/destinations/fullstory/__tests__/request-params.test.ts b/packages/destination-actions/src/destinations/fullstory/__tests__/request-params.test.ts index c31b764324..7934d3a0a1 100644 --- a/packages/destination-actions/src/destinations/fullstory/__tests__/request-params.test.ts +++ b/packages/destination-actions/src/destinations/fullstory/__tests__/request-params.test.ts @@ -1,10 +1,10 @@ import { - listOperationsRequestParams, customEventRequestParams, setUserPropertiesRequestParams, deleteUserRequestParams, createUserRequestParams, - createEventRequestParams + createEventRequestParams, + meRequestParams } from '../request-params' import { anonymousId, @@ -19,14 +19,14 @@ import { } from './fullstory.test' describe('requestParams', () => { - describe('listOperations', () => { - it(`returns expected request params`, () => { - const { url, options } = listOperationsRequestParams(settings) + describe('me', () => { + it('returns expected request params', () => { + const { url, options } = meRequestParams(settings) expect(options.method).toBe('get') expect(options.headers!['Content-Type']).toBe('application/json') expect(options.headers!['Authorization']).toBe(`Basic ${settings.apiKey}`) expect(options.headers!['Integration-Source']).toBe(integrationSource) - expect(url).toBe(`${baseUrl}/operations/v1?limit=1`) + expect(url).toBe(`${baseUrl}/me`) }) }) @@ -129,7 +129,7 @@ describe('requestParams', () => { expect(options.headers!['Content-Type']).toBe('application/json') expect(options.headers!['Authorization']).toBe(`Basic ${settings.apiKey}`) expect(options.headers!['Integration-Source']).toBe(integrationSource) - expect(url).toBe(`${baseUrl}/v2beta/users?uid=${urlEncodedUserId}`) + expect(url).toBe(`${baseUrl}/v2/users?uid=${urlEncodedUserId}`) }) }) @@ -148,7 +148,7 @@ describe('requestParams', () => { expect(options.headers!['Content-Type']).toBe('application/json') expect(options.headers!['Authorization']).toBe(`Basic ${settings.apiKey}`) expect(options.headers!['Integration-Source']).toBe(integrationSource) - expect(url).toBe(`${baseUrl}/v2beta/users?${integrationSourceQueryParam}`) + expect(url).toBe(`${baseUrl}/v2/users`) expect(options.json).toEqual(requestBody) }) }) @@ -173,7 +173,7 @@ describe('requestParams', () => { expect(options.headers!['Content-Type']).toBe('application/json') expect(options.headers!['Authorization']).toBe(`Basic ${settings.apiKey}`) expect(options.headers!['Integration-Source']).toBe(integrationSource) - expect(url).toBe(`${baseUrl}/v2beta/events?${integrationSourceQueryParam}`) + expect(url).toBe(`${baseUrl}/v2/events`) expect(options.json).toEqual({ name: requestValues.eventName, properties: requestValues.properties, @@ -199,7 +199,7 @@ describe('requestParams', () => { expect(options.headers!['Content-Type']).toBe('application/json') expect(options.headers!['Authorization']).toBe(`Basic ${settings.apiKey}`) expect(options.headers!['Integration-Source']).toBe(integrationSource) - expect(url).toBe(`${baseUrl}/v2beta/events?${integrationSourceQueryParam}`) + expect(url).toBe(`${baseUrl}/v2/events`) expect(options.json).toEqual({ name: requestValues.eventName, properties: requestValues.properties, @@ -221,7 +221,7 @@ describe('requestParams', () => { expect(options.headers!['Content-Type']).toBe('application/json') expect(options.headers!['Authorization']).toBe(`Basic ${settings.apiKey}`) expect(options.headers!['Integration-Source']).toBe(integrationSource) - expect(url).toBe(`${baseUrl}/v2beta/events?${integrationSourceQueryParam}`) + expect(url).toBe(`${baseUrl}/v2/events`) expect(options.json).toEqual({ name: requestValues.eventName, properties: requestValues.properties, diff --git a/packages/destination-actions/src/destinations/fullstory/identifyUserV2/generated-types.ts b/packages/destination-actions/src/destinations/fullstory/identifyUserV2/generated-types.ts index 252edef00e..91e54b9a67 100644 --- a/packages/destination-actions/src/destinations/fullstory/identifyUserV2/generated-types.ts +++ b/packages/destination-actions/src/destinations/fullstory/identifyUserV2/generated-types.ts @@ -4,7 +4,7 @@ export interface Payload { /** * The user's id */ - uid: string + uid?: string /** * The user's anonymous id */ diff --git a/packages/destination-actions/src/destinations/fullstory/identifyUserV2/index.ts b/packages/destination-actions/src/destinations/fullstory/identifyUserV2/index.ts index 6835d869ce..4cc2c29fd6 100644 --- a/packages/destination-actions/src/destinations/fullstory/identifyUserV2/index.ts +++ b/packages/destination-actions/src/destinations/fullstory/identifyUserV2/index.ts @@ -11,7 +11,7 @@ const action: ActionDefinition = { fields: { uid: { type: 'string', - required: true, + required: false, description: "The user's id", label: 'User ID', default: { diff --git a/packages/destination-actions/src/destinations/fullstory/index.ts b/packages/destination-actions/src/destinations/fullstory/index.ts index 292c90a0f4..fac99eed7b 100644 --- a/packages/destination-actions/src/destinations/fullstory/index.ts +++ b/packages/destination-actions/src/destinations/fullstory/index.ts @@ -5,7 +5,7 @@ import identifyUser from './identifyUser' import trackEvent from './trackEvent' import identifyUserV2 from './identifyUserV2' import trackEventV2 from './trackEventV2' -import { listOperationsRequestParams, deleteUserRequestParams } from './request-params' +import { deleteUserRequestParams, meRequestParams } from './request-params' const destination: DestinationDefinition = { name: 'Fullstory Cloud Mode (Actions)', @@ -15,15 +15,15 @@ const destination: DestinationDefinition = { { name: 'Track Event', subscribe: 'type = "track"', - partnerAction: 'trackEvent', - mapping: defaultValues(trackEvent.fields), + partnerAction: 'trackEventV2', + mapping: defaultValues(trackEventV2.fields), type: 'automatic' }, { name: 'Identify User', subscribe: 'type = "identify"', - partnerAction: 'identifyUser', - mapping: defaultValues(identifyUser.fields), + partnerAction: 'identifyUserV2', + mapping: defaultValues(identifyUserV2.fields), type: 'automatic' } ], @@ -39,7 +39,7 @@ const destination: DestinationDefinition = { }, testAuthentication: (request, { settings }) => { - const { url, options } = listOperationsRequestParams(settings) + const { url, options } = meRequestParams(settings) return request(url, options) } }, diff --git a/packages/destination-actions/src/destinations/fullstory/request-params.ts b/packages/destination-actions/src/destinations/fullstory/request-params.ts index 77068bb06f..16ace53c6f 100644 --- a/packages/destination-actions/src/destinations/fullstory/request-params.ts +++ b/packages/destination-actions/src/destinations/fullstory/request-params.ts @@ -34,12 +34,11 @@ const defaultRequestParams = (settings: Settings, relativeUrl: string): RequestP } /** - * Returns {@link RequestParams} for the list operations HTTP API endpoint. + * Returns {@link RequestParams} for the me HTTP API endpoint. * * @param settings Settings configured for the cloud mode destination. */ -export const listOperationsRequestParams = (settings: Settings): RequestParams => - defaultRequestParams(settings, `operations/v1?limit=1`) +export const meRequestParams = (settings: Settings): RequestParams => defaultRequestParams(settings, 'me') /** * Returns {@link RequestParams} for the V1 custom events HTTP API endpoint. @@ -130,7 +129,7 @@ export const setUserPropertiesRequestParams = ( * @param userId The id of the user to delete. */ export const deleteUserRequestParams = (settings: Settings, userId: string): RequestParams => { - const defaultParams = defaultRequestParams(settings, `v2beta/users?uid=${encodeURIComponent(userId)}`) + const defaultParams = defaultRequestParams(settings, `v2/users?uid=${encodeURIComponent(userId)}`) return { ...defaultParams, @@ -148,7 +147,7 @@ export const deleteUserRequestParams = (settings: Settings, userId: string): Req * @param requestBody The request body containing user properties to set. */ export const createUserRequestParams = (settings: Settings, requestBody: Object): RequestParams => { - const defaultParams = defaultRequestParams(settings, `v2beta/users?${integrationSourceQueryParam}`) + const defaultParams = defaultRequestParams(settings, `v2/users`) return { ...defaultParams, @@ -169,7 +168,7 @@ export const createUserRequestParams = (settings: Settings, requestBody: Object) export const createEventRequestParams = ( settings: Settings, requestValues: { - userId: string + userId?: string eventName: string properties: {} timestamp?: string @@ -178,7 +177,7 @@ export const createEventRequestParams = ( } ): RequestParams => { const { userId, eventName, properties: eventData, timestamp, useRecentSession, sessionUrl } = requestValues - const defaultParams = defaultRequestParams(settings, `v2beta/events?${integrationSourceQueryParam}`) + const defaultParams = defaultRequestParams(settings, `v2/events`) const requestBody: Record = { name: eventName, diff --git a/packages/destination-actions/src/destinations/fullstory/trackEventV2/generated-types.ts b/packages/destination-actions/src/destinations/fullstory/trackEventV2/generated-types.ts index 672565984c..ae950d0713 100644 --- a/packages/destination-actions/src/destinations/fullstory/trackEventV2/generated-types.ts +++ b/packages/destination-actions/src/destinations/fullstory/trackEventV2/generated-types.ts @@ -4,7 +4,7 @@ export interface Payload { /** * The user's id */ - userId: string + userId?: string /** * The name of the event. */ diff --git a/packages/destination-actions/src/destinations/fullstory/trackEventV2/index.ts b/packages/destination-actions/src/destinations/fullstory/trackEventV2/index.ts index 2ae7b95c77..dbfc02bb12 100644 --- a/packages/destination-actions/src/destinations/fullstory/trackEventV2/index.ts +++ b/packages/destination-actions/src/destinations/fullstory/trackEventV2/index.ts @@ -13,7 +13,7 @@ const action: ActionDefinition = { fields: { userId: { type: 'string', - required: true, + required: false, description: "The user's id", label: 'User ID', default: { diff --git a/packages/destination-actions/src/destinations/gleap/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/gleap/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..7886a1d8c0 --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-gleap destination: identifyContact action - all fields 1`] = ` +Object { + "companyId": "5(nuZw&pud4S", + "companyName": "5(nuZw&pud4S", + "createdAt": "2021-02-01T00:00:00.000Z", + "email": "perum@rakunar.dm", + "lang": "5(nuZw&pud4S", + "lastActivity": "2021-02-01T00:00:00.000Z", + "lastPageView": Object { + "date": "2021-02-01T00:00:00.000Z", + "page": "5(nuZw&pud4S", + }, + "name": "5(nuZw&pud4S 5(nuZw&pud4S", + "phone": "5(nuZw&pud4S", + "plan": "5(nuZw&pud4S", + "testType": "5(nuZw&pud4S", + "userId": "5(nuZw&pud4S", + "value": -3360299155456, +} +`; + +exports[`Testing snapshot for actions-gleap destination: identifyContact action - required fields 1`] = ` +Object { + "userId": "5(nuZw&pud4S", +} +`; + +exports[`Testing snapshot for actions-gleap destination: trackEvent action - all fields 1`] = ` +Object { + "events": Array [ + Object { + "data": Object { + "page": "5h*@HiWdC&Q2#9YhdZ&0", + }, + "date": "2021-02-01T00:00:00.000Z", + "name": "pageView", + "userId": "5h*@HiWdC&Q2#9YhdZ&0", + }, + ], +} +`; + +exports[`Testing snapshot for actions-gleap destination: trackEvent action - required fields 1`] = ` +Object { + "events": Array [ + Object { + "data": Object {}, + "date": "2021-02-01T00:00:00.000Z", + "name": "pageView", + "userId": "5h*@HiWdC&Q2#9YhdZ&0", + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/gleap/__tests__/index.test.ts b/packages/destination-actions/src/destinations/gleap/__tests__/index.test.ts new file mode 100644 index 0000000000..bc32598daa --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/__tests__/index.test.ts @@ -0,0 +1,46 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) +const endpoint = 'https://api.gleap.io' + +describe('Gleap (actions)', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock(endpoint).get('/admin/auth').reply(200, {}) + const authData = { + apiToken: '1234' + } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + + it('should fail on authentication failure', async () => { + nock(endpoint).get('/admin/auth').reply(404, {}) + const authData = { + apiToken: '1234' + } + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError( + new Error('Credentials are invalid: 404 Not Found') + ) + }) + }) + + describe('onDelete', () => { + it('should delete a user with a given userId', async () => { + const userId = '9999' + const event = createTestEvent({ userId: '9999' }) + nock(endpoint).delete(`/admin/contacts/${userId}`).reply(200, {}) + + if (testDestination.onDelete) { + const response = await testDestination.onDelete(event, { + apiToken: '1234' + }) + expect(response.status).toBe(200) + expect(response.data).toMatchObject({}) + } + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/gleap/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/gleap/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..a8234e54dc --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-gleap' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/gleap/generated-types.ts b/packages/destination-actions/src/destinations/gleap/generated-types.ts new file mode 100644 index 0000000000..c04e1a03ac --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Found in `Project settings` -> `Secret API token`. + */ + apiToken: string +} diff --git a/packages/destination-actions/src/destinations/gleap/identifyContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/gleap/identifyContact/__tests__/index.test.ts new file mode 100644 index 0000000000..9e00403d93 --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/identifyContact/__tests__/index.test.ts @@ -0,0 +1,23 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const endpoint = 'https://api.gleap.io' + +describe('Gleap.identifyContact', () => { + it('should identify a user', async () => { + const event = createTestEvent({ + traits: { name: 'example user', email: 'user@example.com', userId: 'example-129394' } + }) + + nock(`${endpoint}`).post(`/admin/identify`).reply(200, {}) + + const responses = await testDestination.testAction('identifyContact', { + event, + useDefaultMappings: true + }) + + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/gleap/identifyContact/generated-types.ts b/packages/destination-actions/src/destinations/gleap/identifyContact/generated-types.ts new file mode 100644 index 0000000000..c9bacf21ab --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/identifyContact/generated-types.ts @@ -0,0 +1,62 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * A unique identifier for the contact. + */ + userId: string + /** + * The contact's first name. + */ + firstName?: string + /** + * The contact's last name. + */ + lastName?: string + /** + * The contact's email address. + */ + email?: string + /** + * The contact's phone number. + */ + phone?: string + /** + * The contact's company name. + */ + companyName?: string + /** + * The contact's compan ID + */ + companyId?: string + /** + * The user's language. + */ + lang?: string + /** + * The user's subscription plan. + */ + plan?: string + /** + * The user's value. + */ + value?: number + /** + * The page where the contact was last seen. + */ + lastPageView?: string + /** + * The time specified for when a contact signed up. + */ + createdAt?: string | number + /** + * The time when the contact was last seen. + */ + lastActivity?: string | number + /** + * The custom attributes which are set for the contact. + */ + customAttributes?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/gleap/identifyContact/index.ts b/packages/destination-actions/src/destinations/gleap/identifyContact/index.ts new file mode 100644 index 0000000000..bdecc4704d --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/identifyContact/index.ts @@ -0,0 +1,164 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import omit from 'lodash/omit' +import pick from 'lodash/pick' + +const action: ActionDefinition = { + title: 'Identify Contact', + description: 'Create or update a contact in Gleap', + defaultSubscription: 'type = "identify"', + fields: { + userId: { + type: 'string', + required: true, + description: 'A unique identifier for the contact.', + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + firstName: { + type: 'string', + description: "The contact's first name.", + label: 'First name', + default: { + '@path': '$.properties.first_name' + } + }, + lastName: { + type: 'string', + description: "The contact's last name.", + label: 'Last name', + default: { + '@path': '$.properties.last_name' + } + }, + email: { + type: 'string', + description: "The contact's email address.", + label: 'Email Address', + format: 'email', + default: { '@path': '$.traits.email' } + }, + phone: { + label: 'Phone Number', + description: "The contact's phone number.", + type: 'string', + default: { + '@path': '$.traits.phone' + } + }, + companyName: { + label: 'Company Name', + description: "The contact's company name.", + type: 'string', + default: { + '@path': '$.traits.company.name' + } + }, + companyId: { + label: 'Company ID', + description: "The contact's compan ID", + type: 'string', + default: { + '@path': '$.traits.company.id' + } + }, + lang: { + label: 'Language', + description: "The user's language.", + type: 'string', + required: false, + default: { '@path': '$.context.locale' } + }, + plan: { + label: 'Subscription Plan', + description: "The user's subscription plan.", + type: 'string', + required: false, + default: { '@path': '$.traits.plan' } + }, + value: { + label: 'User Value', + description: "The user's value.", + type: 'number', + required: false + }, + lastPageView: { + label: 'Last Page View', + type: 'string', + description: 'The page where the contact was last seen.', + default: { + '@path': '$.context.page.url' + } + }, + createdAt: { + label: 'Signed Up Timestamp', + type: 'datetime', + description: 'The time specified for when a contact signed up.' + }, + lastActivity: { + label: 'Last Seen Timestamp', + type: 'datetime', + description: 'The time when the contact was last seen.', + default: { + '@path': '$.timestamp' + } + }, + customAttributes: { + label: 'Custom Attributes', + description: 'The custom attributes which are set for the contact.', + type: 'object', + defaultObjectUI: 'keyvalue', + default: { + '@path': '$.traits' + } + } + }, + perform: async (request, { payload }) => { + // Map the payload to the correct format. + const defaultUserFields = [ + 'userId', + 'email', + 'phone', + 'companyName', + 'companyId', + 'lang', + 'plan', + 'value', + 'createdAt', + 'lastActivity' + ] + + const identifyPayload: any = { + // Add the name if it exists. + ...(payload.firstName || payload.lastName + ? { + name: `${payload.firstName} ${payload.lastName}`.trim() + } + : {}), + + // Pick the default user fields. + ...pick(payload, defaultUserFields), + + // Add custom data but omit the default user fields. + ...omit(payload.customAttributes, [...defaultUserFields, 'firstName', 'lastName']) + } + + // Map the lastPageView and lastActivity to the correct format. + if (payload.lastPageView) { + identifyPayload.lastPageView = { + page: payload.lastPageView, + date: payload.lastActivity + } + } + + return request('https://api.gleap.io/admin/identify', { + method: 'POST', + json: identifyPayload + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/gleap/index.ts b/packages/destination-actions/src/destinations/gleap/index.ts new file mode 100644 index 0000000000..0abfcbb33b --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/index.ts @@ -0,0 +1,57 @@ +import { DestinationDefinition, IntegrationError } from '@segment/actions-core' +import type { Settings } from './generated-types' +import identifyContact from './identifyContact' +import trackEvent from './trackEvent' + +const destination: DestinationDefinition = { + name: 'Gleap (Action)', + slug: 'gleap-cloud-actions', + description: 'Send Segment analytics events and user profile data to Gleap', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + apiToken: { + type: 'string', + label: 'Secret API token', + description: 'Found in `Project settings` -> `Secret API token`.', + required: true + } + }, + testAuthentication: async (request) => { + // The auth endpoint checks if the API token is valid + // https://api.gleap.io/admin/auth. + + return await request('https://api.gleap.io/admin/auth') + } + }, + extendRequest({ settings }) { + return { + headers: { + 'Api-Token': settings.apiToken + } + } + }, + + /** + * Delete a contact from Gleap when a user is deleted in Segment. Use the `userId` to find the contact in Gleap. + */ + onDelete: async (request, { payload }) => { + const userId = payload.userId as string + if (userId) { + return request(`https://api.gleap.io/admin/contacts/${userId}`, { + method: 'DELETE' + }) + } else { + throw new IntegrationError('No unique contact found', 'Contact not found', 404) + } + }, + + actions: { + identifyContact, + trackEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/gleap/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/gleap/trackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..36e3f8051a --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/trackEvent/__tests__/index.test.ts @@ -0,0 +1,21 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const endpoint = 'https://api.gleap.io' + +describe('Gleap.trackEvent', () => { + it('should create an event with name and userId', async () => { + const event = createTestEvent({ event: 'Segment Test Event Name 3', userId: 'user1234' }) + + nock(`${endpoint}`).post(`/admin/track`).reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + useDefaultMappings: true + }) + + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/gleap/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/gleap/trackEvent/generated-types.ts new file mode 100644 index 0000000000..4920980b99 --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/trackEvent/generated-types.ts @@ -0,0 +1,30 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the event that occurred. Names are treated as case insensitive. Periods and dollar signs in event names are replaced with hyphens. + */ + eventName?: string + /** + * The type of the Segment event + */ + type: string + /** + * The associated page url of the Segment event + */ + pageUrl?: string + /** + * The time the event took place in ISO 8601 format. Segment will convert to Unix before sending to Gleap. + */ + date: string | number + /** + * Your identifier for the user who performed the event. User ID is required. + */ + userId: string + /** + * Optional metadata describing the event. Each event can contain up to ten metadata key-value pairs. If you send more than ten keys, Gleap will ignore the rest. + */ + data?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/gleap/trackEvent/index.ts b/packages/destination-actions/src/destinations/gleap/trackEvent/index.ts new file mode 100644 index 0000000000..9ba53f5bc4 --- /dev/null +++ b/packages/destination-actions/src/destinations/gleap/trackEvent/index.ts @@ -0,0 +1,119 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const preparePayload = (payload: Payload) => { + const event = { + name: payload.eventName, + date: payload.date, + data: payload.data, + userId: payload.userId + } + + if (payload.type === 'page') { + event.name = 'pageView' + event.data = { + page: payload.pageUrl ?? payload.eventName + } + } else if (payload.type === 'screen') { + event.name = 'pageView' + event.data = { + page: payload.eventName + } + } + + return event +} + +const sendEvents = async (request: any, events: any[]) => { + return request('https://api.gleap.io/admin/track', { + method: 'POST', + json: { + events: events + } + }) +} + +const action: ActionDefinition = { + title: 'Track Event', + description: 'Submit an event to Gleap.', + defaultSubscription: 'type = "track" or type = "page" or type = "screen"', + fields: { + eventName: { + type: 'string', + required: false, + description: + 'The name of the event that occurred. Names are treated as case insensitive. Periods and dollar signs in event names are replaced with hyphens.', + label: 'Event Name', + default: { + '@if': { + exists: { '@path': '$.event' }, + then: { '@path': '$.event' }, + else: { '@path': '$.name' } + } + } + }, + type: { + type: 'string', + unsafe_hidden: true, + required: true, + description: 'The type of the Segment event', + label: 'Event Type', + choices: [ + { label: 'track', value: 'track' }, + { label: 'page', value: 'page' }, + { label: 'screen', value: 'screen' } + ], + default: { + '@path': '$.type' + } + }, + pageUrl: { + label: 'Event Page URL', + description: 'The associated page url of the Segment event', + type: 'string', + format: 'uri', + required: false, + unsafe_hidden: true, + default: { '@path': '$.context.page.url' } + }, + date: { + type: 'datetime', + required: true, + description: + 'The time the event took place in ISO 8601 format. Segment will convert to Unix before sending to Gleap.', + label: 'Event Timestamp', + default: { + '@path': '$.timestamp' + } + }, + userId: { + type: 'string', + required: true, + description: 'Your identifier for the user who performed the event. User ID is required.', + label: 'User ID', + default: { + '@path': '$.userId' + } + }, + data: { + type: 'object', + description: + 'Optional metadata describing the event. Each event can contain up to ten metadata key-value pairs. If you send more than ten keys, Gleap will ignore the rest.', + label: 'Event Metadata', + default: { + '@path': '$.properties' + } + } + }, + perform: async (request, { payload }) => { + const event = preparePayload(payload) + return sendEvents(request, [event]) + }, + performBatch: async (request, { payload }) => { + const events = payload.map(preparePayload) + return sendEvents(request, events) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addPaymentInfo.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addPaymentInfo.test.ts index 3f7dc4eee1..9cf0ac75ab 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addPaymentInfo.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addPaymentInfo.test.ts @@ -1078,4 +1078,90 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should correctly append consent fields', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Payment Info Entered', + userId: 'abc123', + anonymousId: 'anon-2134', + type: 'track', + properties: { + products: [ + { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + ] + } + }) + + const responses = await testDestination.testAction('addPaymentInfo', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_id: { + '@path': '$.userId' + }, + params: { + Test_key: 'test_value' + }, + items: [ + { + item_name: { + '@path': `$.properties.products.0.name` + }, + item_id: { + '@path': `$.properties.products.0.product_id` + }, + currency: { + '@path': `$.properties.products.0.currency` + }, + price: { + '@path': `$.properties.products.0.price` + }, + quantity: { + '@path': `$.properties.products.0.quantity` + } + } + ], + data_stream_type: DataStreamType.Web, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: false + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(201) + + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + } + `) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"user_id\\":\\"abc123\\",\\"events\\":[{\\"name\\":\\"add_payment_info\\",\\"params\\":{\\"items\\":[{\\"item_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"item_id\\":\\"12345abcde\\",\\"currency\\":\\"USD\\",\\"price\\":12.99,\\"quantity\\":1}],\\"Test_key\\":\\"test_value\\"}}],\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToCart.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToCart.test.ts index fe841dcf71..49a3091956 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToCart.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToCart.test.ts @@ -876,4 +876,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Product Added', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('addToCart', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"add_to_cart\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"items\\":[{\\"item_id\\":\\"12345abcde\\",\\"item_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"price\\":12.99,\\"quantity\\":1}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToWishlist.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToWishlist.test.ts index 33a7fcd57f..acc4178fc2 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToWishlist.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/addToWishlist.test.ts @@ -571,4 +571,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Added to Wishlist', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('addToWishlist', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"add_to_wishlist\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"items\\":[{\\"item_id\\":\\"12345abcde\\",\\"item_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"price\\":12.99,\\"quantity\\":1}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/beginCheckout.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/beginCheckout.test.ts index 76f8d49c08..8065eac68c 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/beginCheckout.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/beginCheckout.test.ts @@ -691,4 +691,72 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append user_properties correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Checkout Started', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1, + products: [ + { + product_id: '507f1f77bcf86cd799439011', + sku: '45790-32', + name: 'Monopoly: 3rd Edition', + price: 19, + quantity: 1, + category: 'Games', + url: 'https://www.example.com/product/path', + image_url: 'https://www.example.com/product/path.jpg' + } + ] + } + }) + const responses = await testDestination.testAction('beginCheckout', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + items: [ + { + item_name: { + '@path': `$.properties.products.0.name` + }, + item_category: { + '@path': `$.properties.products.0.category` + } + } + ], + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"begin_checkout\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"items\\":[{\\"item_name\\":\\"Monopoly: 3rd Edition\\",\\"item_category\\":\\"Games\\"}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/customEvent.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/customEvent.test.ts index b1cae8acd4..5ba13588ca 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/customEvent.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/customEvent.test.ts @@ -560,4 +560,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Some Event Here', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('customEvent', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"abc123\\",\\"events\\":[{\\"name\\":\\"Some_Event_Here\\",\\"params\\":{\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/generateLead.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/generateLead.test.ts index 17b8d12403..9dc699d5ac 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/generateLead.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/generateLead.test.ts @@ -443,4 +443,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Generate Lead', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('generateLead', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"generate_lead\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/login.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/login.test.ts index d6d15ab714..754f12a74c 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/login.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/login.test.ts @@ -303,4 +303,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Login', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('login', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"login\\",\\"params\\":{\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/pageView.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/pageView.test.ts index f149bfa26a..643bd9ee6e 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/pageView.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/pageView.test.ts @@ -384,4 +384,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Page', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('pageView', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"abc123\\",\\"events\\":[{\\"name\\":\\"page_view\\",\\"params\\":{\\"page_location\\":\\"https://segment.com/academy/\\",\\"page_title\\":\\"Analytics Academy\\",\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/purchase.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/purchase.test.ts index ef53038656..1203caa1f3 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/purchase.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/purchase.test.ts @@ -875,4 +875,97 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append user_properties correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Order Completed', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + affiliation: 'TI Online Store', + order_number: '5678dd9087-78', + coupon: 'SUMMER_FEST', + currency: 'EUR', + products: [ + { + product_id: 'pid-123456', + sku: 'SKU-123456', + name: 'Tour t-shirt', + quantity: 2, + coupon: 'MOUNTAIN', + brand: 'Canvas', + category: 'T-Shirt', + variant: 'Black', + price: 19.98 + } + ], + revenue: 5.99, + shipping: 1.5, + tax: 3.0, + total: 24.48 + } + }) + const responses = await testDestination.testAction('purchase', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + transaction_id: { + '@path': '$.properties.order_number' + }, + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + items: [ + { + item_name: { + '@path': `$.properties.products.0.name` + }, + item_id: { + '@path': `$.properties.products.0.product_id` + }, + quantity: { + '@path': `$.properties.products.0.quantity` + }, + coupon: { + '@path': `$.properties.products.0.coupon` + }, + item_brand: { + '@path': `$.properties.products.0.brand` + }, + item_category: { + '@path': `$.properties.products.0.category` + }, + item_variant: { + '@path': `$.properties.products.0.variant` + }, + price: { + '@path': `$.properties.products.0.price` + } + } + ], + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"purchase\\",\\"params\\":{\\"affiliation\\":\\"TI Online Store\\",\\"coupon\\":\\"SUMMER_FEST\\",\\"currency\\":\\"EUR\\",\\"items\\":[{\\"item_name\\":\\"Tour t-shirt\\",\\"item_id\\":\\"pid-123456\\",\\"quantity\\":2,\\"coupon\\":\\"MOUNTAIN\\",\\"item_brand\\":\\"Canvas\\",\\"item_category\\":\\"T-Shirt\\",\\"item_variant\\":\\"Black\\",\\"price\\":19.98}],\\"transaction_id\\":\\"5678dd9087-78\\",\\"shipping\\":1.5,\\"value\\":24.48,\\"tax\\":3,\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/refund.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/refund.test.ts index 2e7b09bb8a..43ab53e60c 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/refund.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/refund.test.ts @@ -657,4 +657,53 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append user_properties correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Order Refunded', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + order_number: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('refund', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + transaction_id: { + '@path': '$.properties.order_number' + }, + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"refund\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"transaction_id\\":\\"12345abcde\\",\\"items\\":[],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/removeFromCart.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/removeFromCart.test.ts index 4a7591d90c..82999061a9 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/removeFromCart.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/removeFromCart.test.ts @@ -557,4 +557,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Product Removed', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('removeFromCart', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"remove_from_cart\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"items\\":[{\\"item_id\\":\\"12345abcde\\",\\"item_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"price\\":12.99,\\"quantity\\":1}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/search.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/search.test.ts index cb0b656c41..0ae4ce5cef 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/search.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/search.test.ts @@ -316,4 +316,57 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Search', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + query: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('search', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + engagement_time_msec: 2, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + search_term: { + '@path': '$.properties.query' + }, + timestamp_micros: { + '@path': '$.timestamp' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: false + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"search\\",\\"params\\":{\\"search_term\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"engagement_time_msec\\":2}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectItem.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectItem.test.ts index d5b4d1e517..f1c26b5911 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectItem.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectItem.test.ts @@ -552,4 +552,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Select Item', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('selectItem', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"select_item\\",\\"params\\":{\\"items\\":[{\\"item_id\\":\\"12345abcde\\",\\"item_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"price\\":12.99,\\"quantity\\":1}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectPromotion.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectPromotion.test.ts index c2c897d193..815b7415d2 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectPromotion.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/selectPromotion.test.ts @@ -659,4 +659,58 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Select Promotion', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('selectPromotion', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + items: { + item_id: { + '@path': '$.properties.id' + }, + promotion_name: { + '@path': '$.properties.name' + } + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"select_promotion\\",\\"params\\":{\\"promotion_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"items\\":[{\\"item_id\\":\\"12345abcde\\",\\"promotion_name\\":\\"Quadruple Stack Oreos, 52 ct\\"}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/signUp.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/signUp.test.ts index e7aca1b0a2..108f78d319 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/signUp.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/signUp.test.ts @@ -339,4 +339,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Signup', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('signUp', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"sign_up\\",\\"params\\":{\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewCart.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewCart.test.ts index 055b7b5d53..b2d82a8db5 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewCart.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewCart.test.ts @@ -627,4 +627,65 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Cart Viewed', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + currency: 'USD', + promotion_id: 'promo_1', + creative: 'top_banner_2', + name: '75% store-wide shoe sale', + position: 'home_banner_top', + products: [ + { + product_id: '507f1f77bcf86cd799439011', + sku: '45790-32', + name: 'Monopoly: 3rd Edition', + price: 19, + quantity: 1, + currency: 'USD', + category: 'Games', + promotion: 'SUPER SUMMER SALE; 3% off', + slot: '2', + promo_id: '12345', + creative_name: 'Sale' + } + ] + } + }) + const responses = await testDestination.testAction('viewCart', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"view_cart\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"items\\":[{\\"item_id\\":\\"507f1f77bcf86cd799439011\\",\\"item_name\\":\\"Monopoly: 3rd Edition\\",\\"item_category\\":\\"Games\\",\\"price\\":19,\\"quantity\\":1}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItem.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItem.test.ts index 5a66803347..5a51553b24 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItem.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItem.test.ts @@ -488,4 +488,50 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Item Viewed', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + }) + const responses = await testDestination.testAction('viewItem', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: true + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"view_item\\",\\"params\\":{\\"currency\\":\\"USD\\",\\"items\\":[{\\"item_id\\":\\"12345abcde\\",\\"item_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"price\\":12.99,\\"quantity\\":1}],\\"engagement_time_msec\\":1}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItemList.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItemList.test.ts index 1e812d385d..09e2afaeec 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItemList.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewItemList.test.ts @@ -628,4 +628,80 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correctly', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + + const event = createTestEvent({ + event: 'Product List Viewed', + userId: 'abc123', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-2134', + type: 'track', + properties: { + products: [ + { + product_id: '12345abcde', + name: 'Quadruple Stack Oreos, 52 ct', + currency: 'USD', + price: 12.99, + quantity: 1 + } + ] + } + }) + const responses = await testDestination.testAction('viewItemList', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.anonymousId' + }, + data_stream_type: DataStreamType.Web, + timestamp_micros: { + '@path': '$.timestamp' + }, + engagement_time_msec: 2, + user_properties: { + hello: 'world', + a: '1', + b: '2', + c: '3' + }, + items: [ + { + item_name: { + '@path': `$.properties.products.0.name` + }, + item_id: { + '@path': `$.properties.products.0.product_id` + }, + currency: { + '@path': `$.properties.products.0.currency` + }, + price: { + '@path': `$.properties.products.0.price` + }, + quantity: { + '@path': `$.properties.products.0.quantity` + } + } + ], + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(201) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"anon-2134\\",\\"events\\":[{\\"name\\":\\"view_item_list\\",\\"params\\":{\\"items\\":[{\\"item_name\\":\\"Quadruple Stack Oreos, 52 ct\\",\\"item_id\\":\\"12345abcde\\",\\"currency\\":\\"USD\\",\\"price\\":12.99,\\"quantity\\":1}],\\"engagement_time_msec\\":2}}],\\"user_properties\\":{\\"hello\\":{\\"value\\":\\"world\\"},\\"a\\":{\\"value\\":\\"1\\"},\\"b\\":{\\"value\\":\\"2\\"},\\"c\\":{\\"value\\":\\"3\\"}},\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewPromotion.test.ts b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewPromotion.test.ts index 277c8c43d4..92423d9697 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewPromotion.test.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/__tests__/viewPromotion.test.ts @@ -747,4 +747,97 @@ describe('GA4', () => { ).rejects.toThrowError('Client ID is required for web streams') }) }) + + it('should append consent correct', async () => { + nock('https://www.google-analytics.com/mp/collect') + .post(`?measurement_id=${measurementId}&api_secret=${apiSecret}`) + .reply(201, {}) + const event = createTestEvent({ + event: 'Promotion Viewed', + userId: '3456fff', + timestamp: '2022-06-22T22:20:58.905Z', + anonymousId: 'anon-567890', + type: 'track', + properties: { + promotion_id: 'promo_1', + creative: 'top_banner_2', + name: '75% store-wide shoe sale', + position: 'home_banner_top', + products: [ + { + product_id: '507f1f77bcf86cd799439011', + sku: '45790-32', + name: 'Monopoly: 3rd Edition', + price: 19, + quantity: 1, + category: 'Games', + promotion: 'SUPER SUMMER SALE; 3% off', + slot: '2', + promo_id: '12345', + creative_name: 'Sale' + } + ] + } + }) + const responses = await testDestination.testAction('viewPromotion', { + event, + settings: { + apiSecret, + measurementId + }, + mapping: { + client_id: { + '@path': '$.userId' + }, + creative_slot: { + '@path': '$.properties.creative' + }, + promotion_id: { + '@path': '$.properties.promotion_id' + }, + promotion_name: { + '@path': '$.properties.name' + }, + timestamp_micros: { + '@path': '$.timestamp' + }, + engagement_time_msec: 2, + location_id: { + '@path': '$.properties.promotion_id' + }, + items: [ + { + item_name: { + '@path': `$.properties.products.0.name` + }, + item_id: { + '@path': `$.properties.products.0.product_id` + }, + promotion_name: { + '@path': `$.properties.products.0.promotion` + }, + creative_slot: { + '@path': `$.properties.products.0.slot` + }, + promotion_id: { + '@path': `$.properties.products.0.promo_id` + }, + creative_name: { + '@path': `$.properties.products.0.creative_name` + } + } + ], + ad_user_data_consent: 'GRANTED', + ad_personalization_consent: 'GRANTED' + }, + useDefaultMappings: false + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(201) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"client_id\\":\\"3456fff\\",\\"events\\":[{\\"name\\":\\"view_promotion\\",\\"params\\":{\\"creative_slot\\":\\"top_banner_2\\",\\"location_id\\":\\"promo_1\\",\\"promotion_id\\":\\"promo_1\\",\\"promotion_name\\":\\"75% store-wide shoe sale\\",\\"items\\":[{\\"item_name\\":\\"Monopoly: 3rd Edition\\",\\"item_id\\":\\"507f1f77bcf86cd799439011\\",\\"promotion_name\\":\\"SUPER SUMMER SALE; 3% off\\",\\"creative_slot\\":\\"2\\",\\"promotion_id\\":\\"12345\\",\\"creative_name\\":\\"Sale\\"}],\\"engagement_time_msec\\":2}}],\\"timestamp_micros\\":1655936458905000,\\"consent\\":{\\"ad_user_data\\":\\"GRANTED\\",\\"ad_personalization\\":\\"GRANTED\\"}}"` + ) + }) }) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/generated-types.ts index f874e836a9..d3b48405ad 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/generated-types.ts @@ -134,4 +134,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/index.ts index 16c8ef0fa3..5dadbc231f 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/addPaymentInfo/index.ts @@ -9,7 +9,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { user_id, @@ -25,7 +26,9 @@ import { timestamp_micros, params, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' const action: ActionDefinition = { @@ -48,7 +51,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -120,7 +125,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/addToCart/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/addToCart/generated-types.ts index 1e650e3ae5..fd7887a602 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/addToCart/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/addToCart/generated-types.ts @@ -126,4 +126,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/addToCart/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/addToCart/index.ts index 40dda3242b..e101bcdcf2 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/addToCart/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/addToCart/index.ts @@ -9,7 +9,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -23,7 +24,9 @@ import { engagement_time_msec, timestamp_micros, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' const action: ActionDefinition = { @@ -44,7 +47,9 @@ const action: ActionDefinition = { value: { ...value }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -94,7 +99,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/generated-types.ts index 6e8497d90a..b833bd072b 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/generated-types.ts @@ -126,4 +126,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/index.ts index 3d8a876796..97253885a3 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/addToWishlist/index.ts @@ -9,7 +9,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -23,7 +24,9 @@ import { engagement_time_msec, timestamp_micros, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' const action: ActionDefinition = { @@ -44,7 +47,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -107,7 +112,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/generated-types.ts index 5d03048215..3a00402260 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/generated-types.ts @@ -130,4 +130,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/index.ts index ad397e0474..7c1ab94b85 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/beginCheckout/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -21,7 +22,9 @@ import { timestamp_micros, engagement_time_msec, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -48,7 +51,9 @@ const action: ActionDefinition = { value: { ...value }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -99,7 +104,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/customEvent/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/customEvent/generated-types.ts index d0b6b87b50..ac3f892596 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/customEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/customEvent/generated-types.ts @@ -45,4 +45,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/customEvent/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/customEvent/index.ts index f0cf101f45..cccc7002f1 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/customEvent/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/customEvent/index.ts @@ -7,7 +7,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { @@ -19,7 +20,9 @@ import { engagement_time_msec, timestamp_micros, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType } from '../ga4-types' @@ -62,7 +65,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: { ...params } + params: { ...params }, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -89,7 +94,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/ga4-functions.ts b/packages/destination-actions/src/destinations/google-analytics-4/ga4-functions.ts index 3148c12857..9493b04b69 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/ga4-functions.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/ga4-functions.ts @@ -1,6 +1,7 @@ import { ErrorCodes, IntegrationError, PayloadValidationError, RequestClient } from '@segment/actions-core' import { CURRENCY_ISO_CODES } from './constants' import { DataStreamParams } from './ga4-types' +import { Consent } from './ga4-types' // Google expects currency to be a 3-letter ISO 4217 format export function verifyCurrency(currency: string): void { @@ -108,3 +109,16 @@ export async function sendData(request: RequestClient, search_params: string, pa json: payload }) } + +export const formatConsent = (consent: Consent): object | undefined => { + if (!consent.ad_user_data_consent && !consent.ad_personalization_consent) { + return undefined + } + + return { + consent: { + ad_user_data: consent.ad_user_data_consent, + ad_personalization: consent.ad_personalization_consent + } + } +} diff --git a/packages/destination-actions/src/destinations/google-analytics-4/ga4-properties.ts b/packages/destination-actions/src/destinations/google-analytics-4/ga4-properties.ts index 25adf9c339..9156d8c87d 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/ga4-properties.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/ga4-properties.ts @@ -359,3 +359,23 @@ export const data_stream_type: InputField = { 'The type of data stream this data belongs in. This can either be a web stream or a mobile app stream (iOS or Android). Possible values: "Web" (default) and "Mobile App".', default: DataStreamType.Web } + +export const ad_user_data_consent: InputField = { + label: 'Ad User Data Consent State', + description: + 'Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED.', + type: 'string', + choices: [ + { label: 'Granted', value: 'GRANTED' }, + { label: 'Denied', value: 'DENIED' } + ] +} +export const ad_personalization_consent: InputField = { + label: 'Ad Personalization Consent State', + description: 'Sets consent for personalized advertising. Must be either GRANTED or DENIED.', + type: 'string', + choices: [ + { label: 'Granted', value: 'GRANTED' }, + { label: 'Denied', value: 'DENIED' } + ] +} diff --git a/packages/destination-actions/src/destinations/google-analytics-4/ga4-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/ga4-types.ts index d4a80f7673..1062cc6c14 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/ga4-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/ga4-types.ts @@ -37,3 +37,8 @@ export interface DataStreamParams { // only one of app_instance_id or client_id is allowed identifier: { app_instance_id: string; client_id?: never } | { client_id: string; app_instance_id?: never } } + +export interface Consent { + ad_personalization_consent?: string + ad_user_data_consent?: string +} diff --git a/packages/destination-actions/src/destinations/google-analytics-4/generateLead/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/generateLead/generated-types.ts index 46b9bb55f0..d0bb0932a1 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/generateLead/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/generateLead/generated-types.ts @@ -45,4 +45,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/generateLead/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/generateLead/index.ts index fe643e1686..4d71d1d819 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/generateLead/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/generateLead/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -21,7 +22,9 @@ import { engagement_time_msec, timestamp_micros, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType } from '../ga4-types' @@ -39,7 +42,9 @@ const action: ActionDefinition = { value: { ...value }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -75,7 +80,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/login/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/login/generated-types.ts index 93d29718a1..3701f8446f 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/login/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/login/generated-types.ts @@ -41,4 +41,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/login/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/login/index.ts index 4c923b5a52..95047bb56c 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/login/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/login/index.ts @@ -7,7 +7,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -18,7 +19,9 @@ import { engagement_time_msec, timestamp_micros, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType } from '../ga4-types' @@ -39,7 +42,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { @@ -66,7 +71,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/pageView/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/pageView/generated-types.ts index afde27939b..ddceff3556 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/pageView/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/pageView/generated-types.ts @@ -49,4 +49,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/pageView/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/pageView/index.ts index 9cf5cd5701..2c8a6bca03 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/pageView/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/pageView/index.ts @@ -7,7 +7,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -18,7 +19,9 @@ import { engagement_time_msec, timestamp_micros, data_stream_type, - app_instance_id + app_instance_id, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType } from '../ga4-types' @@ -58,7 +61,9 @@ const action: ActionDefinition = { } }, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -86,7 +91,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/purchase/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/purchase/generated-types.ts index a4fb0535bc..ad8fec57b4 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/purchase/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/purchase/generated-types.ts @@ -146,4 +146,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/purchase/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/purchase/index.ts index fe0c2eace6..0953d2215f 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/purchase/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/purchase/index.ts @@ -8,7 +8,8 @@ import { convertTimestamp, getWebStreamParams, getMobileStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import { @@ -28,7 +29,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' // https://segment.com/docs/connections/spec/ecommerce/v2/#order-completed @@ -58,7 +61,9 @@ const action: ActionDefinition = { value: { ...value, default: { '@path': '$.properties.total' } }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -111,7 +116,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/refund/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/refund/generated-types.ts index e379bb2acd..490df8fc69 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/refund/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/refund/generated-types.ts @@ -146,4 +146,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/refund/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/refund/index.ts index 7670d74622..e64c1813a5 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/refund/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/refund/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { coupon, @@ -24,7 +25,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -56,7 +59,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -125,7 +130,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/generated-types.ts index 6e8497d90a..b833bd072b 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/generated-types.ts @@ -126,4 +126,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/index.ts index 5ac715c840..85d17a9ca7 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/removeFromCart/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -20,7 +21,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -44,7 +47,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -108,7 +113,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/search/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/search/generated-types.ts index 8dc978bdad..6aa293445e 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/search/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/search/generated-types.ts @@ -41,4 +41,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/search/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/search/index.ts index 1ec6e16b21..24e4a0f830 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/search/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/search/index.ts @@ -7,7 +7,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -18,7 +19,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType } from '../ga4-types' @@ -43,7 +46,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -69,7 +74,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/selectItem/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/selectItem/generated-types.ts index ba35867251..48287de64b 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/selectItem/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/selectItem/generated-types.ts @@ -126,4 +126,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/selectItem/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/selectItem/index.ts index c8eaccb4fe..73f5037c01 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/selectItem/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/selectItem/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -21,7 +22,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' const action: ActionDefinition = { @@ -50,7 +53,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -96,7 +101,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/generated-types.ts index d758ba7910..ad9f82270d 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/generated-types.ts @@ -154,4 +154,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/index.ts index bc6f58dd10..9d9fb29922 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/selectPromotion/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { creative_name, @@ -23,7 +24,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, PromotionProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -68,7 +71,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -117,7 +122,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/signUp/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/signUp/generated-types.ts index c2a03428f4..93269e8fd8 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/signUp/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/signUp/generated-types.ts @@ -41,4 +41,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/signUp/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/signUp/index.ts index 5ae65cf70f..fdf4d718fc 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/signUp/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/signUp/index.ts @@ -7,7 +7,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -18,7 +19,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType } from '../ga4-types' @@ -42,7 +45,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -68,7 +73,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewCart/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewCart/generated-types.ts index 6e8497d90a..b833bd072b 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewCart/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewCart/generated-types.ts @@ -126,4 +126,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewCart/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewCart/index.ts index 395fb7b817..f38c149962 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewCart/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewCart/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -20,7 +21,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -44,7 +47,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -103,7 +108,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewItem/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewItem/generated-types.ts index 6e8497d90a..b833bd072b 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewItem/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewItem/generated-types.ts @@ -126,4 +126,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewItem/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewItem/index.ts index f1087bf894..da5cc7c8d5 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewItem/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewItem/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -20,7 +21,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -44,7 +47,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -94,7 +99,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/generated-types.ts index a4aed89a5c..b7fe5a54a0 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/generated-types.ts @@ -126,4 +126,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/index.ts index ebddb41ac0..2e2a9101e6 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewItemList/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { formatUserProperties, @@ -18,7 +19,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, ProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -60,7 +63,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { const data_stream_type = payload.data_stream_type ?? DataStreamType.Web @@ -106,7 +111,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/generated-types.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/generated-types.ts index 30bb19081f..d0cac63fd9 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/generated-types.ts @@ -154,4 +154,12 @@ export interface Payload { params?: { [k: string]: unknown } + /** + * Sets consent for sending user data to Google for advertising purposes. Must be either GRANTED or DENIED. + */ + ad_user_data_consent?: string + /** + * Sets consent for personalized advertising. Must be either GRANTED or DENIED. + */ + ad_personalization_consent?: string } diff --git a/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/index.ts b/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/index.ts index 6b6979a401..e429d87462 100644 --- a/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/index.ts +++ b/packages/destination-actions/src/destinations/google-analytics-4/viewPromotion/index.ts @@ -6,7 +6,8 @@ import { convertTimestamp, getMobileStreamParams, getWebStreamParams, - sendData + sendData, + formatConsent } from '../ga4-functions' import { creative_name, @@ -23,7 +24,9 @@ import { engagement_time_msec, timestamp_micros, app_instance_id, - data_stream_type + data_stream_type, + ad_user_data_consent, + ad_personalization_consent } from '../ga4-properties' import { DataStreamParams, DataStreamType, PromotionProductItem } from '../ga4-types' import type { Settings } from '../generated-types' @@ -76,7 +79,9 @@ const action: ActionDefinition = { }, user_properties: user_properties, engagement_time_msec: engagement_time_msec, - params: params + params: params, + ad_user_data_consent: ad_user_data_consent, + ad_personalization_consent: ad_personalization_consent }, perform: (request, { payload, settings }) => { @@ -124,7 +129,11 @@ const action: ActionDefinition = { } ], ...formatUserProperties(payload.user_properties), - timestamp_micros: convertTimestamp(payload.timestamp_micros) + timestamp_micros: convertTimestamp(payload.timestamp_micros), + ...formatConsent({ + ad_personalization_consent: payload.ad_personalization_consent, + ad_user_data_consent: payload.ad_user_data_consent + }) } return sendData(request, stream_params.search_params, request_object) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/functions.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/functions.test.ts new file mode 100644 index 0000000000..770ab9c6e7 --- /dev/null +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/functions.test.ts @@ -0,0 +1,114 @@ +import { createTestIntegration, DynamicFieldResponse } from '@segment/actions-core' +import { Features } from '@segment/actions-core/mapping-kit' +import nock from 'nock' +import { CANARY_API_VERSION } from '../functions' +import destination from '../index' + +const testDestination = createTestIntegration(destination) + +const auth = { + refreshToken: 'xyz321', + accessToken: 'abc123' +} + +describe('.getConversionActionId', () => { + it('should dynamically fetch event keys for uploadClickConversion action', async () => { + const settings = { + customerId: '12345678' + } + const payload = {} + const responses = (await testDestination.testDynamicField('uploadClickConversion', 'conversion_action', { + settings, + payload, + auth + })) as DynamicFieldResponse + + expect(responses.choices.length).toBeGreaterThanOrEqual(0) + }) +}) + +describe('.getConversionActionId', () => { + it('should dynamically fetch event keys for uploadCallConversion action', async () => { + const settings = { + customerId: '12345678' + } + const payload = {} + const responses = (await testDestination.testDynamicField('uploadCallConversion', 'conversion_action', { + settings, + payload, + auth + })) as DynamicFieldResponse + + expect(responses.choices.length).toBeGreaterThanOrEqual(0) + }) +}) + +describe('.getConversionActionId', () => { + it('should dynamically fetch event keys for uploadConversionAdjustment action', async () => { + const settings = { + customerId: '12345678' + } + const payload = {} + const responses = (await testDestination.testDynamicField('uploadConversionAdjustment', 'conversion_action', { + settings, + payload, + auth + })) as DynamicFieldResponse + + expect(responses.choices.length).toBeGreaterThanOrEqual(0) + }) + + it('should use Canary API Version when feature flag is ON', async () => { + const settings = { + customerId: '123-456-7890' + } + // When Flag is ON, will use Canary API Version. + const features: Features = { 'google-enhanced-canary-version': true } + nock(`https://googleads.googleapis.com/${CANARY_API_VERSION}/customers/1234567890/googleAds:searchStream`) + .post('') + .reply(201, [ + { + results: [ + { + conversionAction: { + resourceName: 'customers/1234567890/conversionActions/819597798', + id: '819597798', + name: 'Purchase' + } + }, + { + conversionAction: { + resourceName: 'customers/1234567890/conversionActions/1055693999', + id: '1055693999', + name: 'Page view' + } + }, + { + conversionAction: { + resourceName: 'customers/1234567890/conversionActions/1055694122', + id: '1055694122', + name: 'Add to cart' + } + } + ], + fieldMask: 'conversionAction.id,conversionAction.name', + requestId: 'u6QgrVJQCSKQrTXx0j4tAg' + } + ]) + + const payload = {} + const responses = (await testDestination.testDynamicField('uploadConversionAdjustment', 'conversion_action', { + settings, + payload, + auth, + features + })) as DynamicFieldResponse + + expect(responses.choices.length).toBe(3) + expect(responses.choices).toStrictEqual([ + { value: '819597798', label: 'Purchase' }, + { value: '1055693999', label: 'Page view' }, + { value: '1055694122', label: 'Add to cart' } + ]) + }) +}) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadCallConversion.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadCallConversion.test.ts index 6da3ab89be..daca41ea3d 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadCallConversion.test.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadCallConversion.test.ts @@ -205,7 +205,12 @@ describe('GoogleEnhancedConversions', () => { const responses = await testDestination.testAction('uploadCallConversion', { event, - mapping: { conversion_action: '12345', caller_id: '+1234567890', call_timestamp: timestamp }, + mapping: { + conversion_action: '12345', + caller_id: '+1234567890', + call_timestamp: timestamp, + ad_user_data_consent_state: 'GRANTED' + }, useDefaultMappings: true, settings: { customerId @@ -216,7 +221,49 @@ describe('GoogleEnhancedConversions', () => { }) expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"callerId\\":\\"+1234567890\\",\\"callStartDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\"}],\\"partialFailure\\":true}"` + `"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"callerId\\":\\"+1234567890\\",\\"callStartDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\",\\"consent\\":{\\"adUserData\\":\\"GRANTED\\"}}],\\"partialFailure\\":true}"` + ) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(201) + }) + + it('Deny User Data and Personalised Consent State', async () => { + const event = createTestEvent({ + timestamp, + event: 'Test Event', + properties: { + email: 'test@gmail.com', + orderId: '1234', + total: '200', + currency: 'USD' + } + }) + + nock(`https://googleads.googleapis.com/${CANARY_API_VERSION}/customers/${customerId}:uploadCallConversions`) + .post('') + .reply(201, { results: [{}] }) + + const responses = await testDestination.testAction('uploadCallConversion', { + event, + mapping: { + conversion_action: '12345', + caller_id: '+1234567890', + call_timestamp: timestamp, + ad_user_data_consent_state: 'DENIED', + ad_personalization_consent_state: 'DENIED' + }, + useDefaultMappings: true, + settings: { + customerId + }, + features: { + [FLAGON_NAME]: true + } + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"callerId\\":\\"+1234567890\\",\\"callStartDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\",\\"consent\\":{\\"adUserData\\":\\"DENIED\\",\\"adPersonalization\\":\\"DENIED\\"}}],\\"partialFailure\\":true}"` ) expect(responses.length).toBe(1) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadClickConversion.test.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadClickConversion.test.ts index 2376bdd374..7e59dd16ae 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadClickConversion.test.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/__tests__/uploadClickConversion.test.ts @@ -378,7 +378,10 @@ describe('GoogleEnhancedConversions', () => { const responses = await testDestination.testAction('uploadClickConversion', { event, - mapping: { conversion_action: '12345' }, + mapping: { + conversion_action: '12345', + ad_personalization_consent_state: 'GRANTED' + }, useDefaultMappings: true, settings: { customerId @@ -386,7 +389,7 @@ describe('GoogleEnhancedConversions', () => { }) expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"orderId\\":\\"1234\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\",\\"cartData\\":{\\"items\\":[{\\"productId\\":\\"1234\\",\\"quantity\\":3,\\"unitPrice\\":10.99}]},\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"}]}],\\"partialFailure\\":true}"` + `"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"orderId\\":\\"1234\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\",\\"cartData\\":{\\"items\\":[{\\"productId\\":\\"1234\\",\\"quantity\\":3,\\"unitPrice\\":10.99}]},\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"}],\\"consent\\":{\\"adPersonalization\\":\\"GRANTED\\"}}],\\"partialFailure\\":true}"` ) expect(responses.length).toBe(1) @@ -432,5 +435,49 @@ describe('GoogleEnhancedConversions', () => { expect(e.message).toBe("Email provided doesn't seem to be in a valid format.") } }) + + it('Deny User Data and Personalised Consent State', async () => { + const event = createTestEvent({ + timestamp, + event: 'Test Event', + properties: { + gclid: '54321', + email: '87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674', //'test@gmail.com', + orderId: '1234', + total: '200', + currency: 'USD', + products: [ + { + product_id: '1234', + quantity: 3, + price: 10.99 + } + ] + } + }) + + nock(`https://googleads.googleapis.com/${API_VERSION}/customers/${customerId}:uploadClickConversions`) + .post('') + .reply(201, { results: [{}] }) + + const responses = await testDestination.testAction('uploadClickConversion', { + event, + mapping: { + conversion_action: '12345', + ad_user_data_consent_state: 'DENIED', + ad_personalization_consent_state: 'DENIED' + }, + useDefaultMappings: true, + settings: { + customerId + } + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"conversions\\":[{\\"conversionAction\\":\\"customers/1234/conversionActions/12345\\",\\"conversionDateTime\\":\\"2021-06-10 18:08:04+00:00\\",\\"orderId\\":\\"1234\\",\\"conversionValue\\":200,\\"currencyCode\\":\\"USD\\",\\"cartData\\":{\\"items\\":[{\\"productId\\":\\"1234\\",\\"quantity\\":3,\\"unitPrice\\":10.99}]},\\"userIdentifiers\\":[{\\"hashedEmail\\":\\"87924606b4131a8aceeeae8868531fbb9712aaa07a5d3a756b26ce0f5d6ca674\\"}],\\"consent\\":{\\"adUserData\\":\\"DENIED\\",\\"adPersonalization\\":\\"DENIED\\"}}],\\"partialFailure\\":true}"` + ) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(201) + }) }) }) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts index c7fa329282..dce2d6197d 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/functions.ts @@ -1,12 +1,25 @@ import { createHash } from 'crypto' -import { ConversionCustomVariable, PartialErrorResponse, QueryResponse } from './types' -import { ModifiedResponse, RequestClient, IntegrationError, PayloadValidationError } from '@segment/actions-core' +import { + ConversionCustomVariable, + PartialErrorResponse, + QueryResponse, + ConversionActionId, + ConversionActionResponse +} from './types' +import { + ModifiedResponse, + RequestClient, + IntegrationError, + PayloadValidationError, + DynamicFieldResponse, + APIError +} from '@segment/actions-core' import { StatsContext } from '@segment/actions-core/destination-kit' import { Features } from '@segment/actions-core/mapping-kit' import { fullFormats } from 'ajv-formats/dist/formats' -export const API_VERSION = 'v12' -export const CANARY_API_VERSION = 'v13' +export const API_VERSION = 'v15' +export const CANARY_API_VERSION = 'v15' export const FLAGON_NAME = 'google-enhanced-canary-version' export function formatCustomVariables( @@ -69,6 +82,62 @@ export async function getCustomVariables( ) } +export async function getConversionActionId( + customerId: string | undefined, + auth: any, + request: RequestClient, + features: Features | undefined, + statsContext: StatsContext | undefined +): Promise> { + return request( + `https://googleads.googleapis.com/${getApiVersion( + features, + statsContext + )}/customers/${customerId}/googleAds:searchStream`, + { + method: 'post', + headers: { + authorization: `Bearer ${auth?.accessToken}`, + 'developer-token': `${process.env.ADWORDS_DEVELOPER_TOKEN}` + }, + json: { + query: `SELECT conversion_action.id, conversion_action.name FROM conversion_action` + } + } + ) +} + +export async function getConversionActionDynamicData( + request: RequestClient, + settings: any, + auth: any, + features: Features | undefined, + statsContext: StatsContext | undefined +): Promise { + try { + // remove '-' from CustomerId + settings.customerId = settings.customerId.replace(/-/g, '') + const results = await getConversionActionId(settings.customerId, auth, request, features, statsContext) + + const res: Array = JSON.parse(results.content) + const choices = res[0].results.map((input: ConversionActionId) => { + return { value: input.conversionAction.id, label: input.conversionAction.name } + }) + return { + choices + } + } catch (err) { + return { + choices: [], + nextPage: '', + error: { + message: (err as APIError).message ?? 'Unknown error', + code: (err as APIError).status + '' ?? 'Unknown error' + } + } + } +} + /* Ensures there is no error when using Google's partialFailure mode See here: https://developers.google.com/google-ads/api/docs/best-practices/partial-failures */ diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts index e052b13509..1bd370b09d 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/types.ts @@ -12,6 +12,20 @@ export interface ConversionCustomVariable { } } +export interface ConversionActionId { + conversionAction: { + resourceName: string + id: string + name: string + } +} + +export interface ConversionActionResponse { + results: Array + fieldMask: string + requestId: string +} + export interface QueryResponse { results: Array } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/generated-types.ts index 7e9d4930d6..d2531920da 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/generated-types.ts @@ -2,7 +2,7 @@ export interface Payload { /** - * The ID of the conversion action associated with this conversion. To find the Conversion Action ID, click on your conversion in Google Ads and get the value for `ctId` in the URL. For example, if the URL is `https://ads.google.com/aw/conversions/detail?ocid=00000000&ctId=570000000`, your Conversion Action ID is `570000000`. + * The ID of the conversion action associated with this conversion. */ conversion_action: number /** @@ -31,4 +31,12 @@ export interface Payload { custom_variables?: { [k: string]: unknown } + /** + * This represents consent for ad user data. For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent). + */ + ad_user_data_consent_state?: string + /** + * This represents consent for ad personalization. This can only be set for OfflineUserDataJobService and UserDataService.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent). + */ + ad_personalization_consent_state?: string } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/index.ts index d932107cca..fe533263ac 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadCallConversion/index.ts @@ -1,4 +1,4 @@ -import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import { ActionDefinition, DynamicFieldResponse, PayloadValidationError, RequestClient } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { @@ -6,7 +6,8 @@ import { formatCustomVariables, getCustomVariables, getApiVersion, - handleGoogleErrors + handleGoogleErrors, + getConversionActionDynamicData } from '../functions' import { PartialErrorResponse } from '../types' import { ModifiedResponse } from '@segment/actions-core' @@ -17,10 +18,10 @@ const action: ActionDefinition = { fields: { conversion_action: { label: 'Conversion Action ID', - description: - 'The ID of the conversion action associated with this conversion. To find the Conversion Action ID, click on your conversion in Google Ads and get the value for `ctId` in the URL. For example, if the URL is `https://ads.google.com/aw/conversions/detail?ocid=00000000&ctId=570000000`, your Conversion Action ID is `570000000`.', + description: 'The ID of the conversion action associated with this conversion.', type: 'number', - required: true + required: true, + dynamic: true }, caller_id: { label: 'Caller ID', @@ -69,6 +70,37 @@ const action: ActionDefinition = { type: 'object', additionalProperties: true, defaultObjectUI: 'keyvalue:only' + }, + ad_user_data_consent_state: { + label: 'Ad User Data Consent State', + description: + 'This represents consent for ad user data. For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent).', + type: 'string', + choices: [ + { label: 'GRANTED', value: 'GRANTED' }, + { label: 'DENIED', value: 'DENIED' }, + { label: 'UNSPECIFIED', value: 'UNSPECIFIED' } + ] + }, + ad_personalization_consent_state: { + label: 'Ad Personalization Consent State', + type: 'string', + description: + 'This represents consent for ad personalization. This can only be set for OfflineUserDataJobService and UserDataService.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent).', + choices: [ + { label: 'GRANTED', value: 'GRANTED' }, + { label: 'DENIED', value: 'DENIED' }, + { label: 'UNSPECIFIED', value: 'UNSPECIFIED' } + ] + } + }, + + dynamicFields: { + conversion_action: async ( + request: RequestClient, + { settings, auth, features, statsContext } + ): Promise => { + return getConversionActionDynamicData(request, settings, auth, features, statsContext) } }, perform: async (request, { auth, settings, payload, features, statsContext }) => { @@ -91,6 +123,20 @@ const action: ActionDefinition = { currencyCode: payload.currency } + // Add Consent Signals 'adUserData' if it is defined + if (payload.ad_user_data_consent_state) { + request_object['consent'] = { + adUserData: payload.ad_user_data_consent_state + } + } + + // Add Consent Signals 'adPersonalization' if it is defined + if (payload.ad_personalization_consent_state) { + request_object['consent'] = { + ...request_object['consent'], + adPersonalization: payload.ad_personalization_consent_state + } + } // Retrieves all of the custom variables that the customer has created in their Google Ads account if (payload.custom_variables) { const customVariableIds = await getCustomVariables(settings.customerId, auth, request, features, statsContext) diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/generated-types.ts index 222f60ca7d..e2ba66413e 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/generated-types.ts @@ -2,7 +2,7 @@ export interface Payload { /** - * The ID of the conversion action associated with this conversion. To find the Conversion Action ID, click on your conversion in Google Ads and get the value for `ctId` in the URL. For example, if the URL is `https://ads.google.com/aw/conversions/detail?ocid=00000000&ctId=570000000`, your Conversion Action ID is `570000000`. + * The ID of the conversion action associated with this conversion. */ conversion_action: number /** @@ -84,4 +84,12 @@ export interface Payload { custom_variables?: { [k: string]: unknown } + /** + * This represents consent for ad user data.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent). + */ + ad_user_data_consent_state?: string + /** + * This represents consent for ad personalization. This can only be set for OfflineUserDataJobService and UserDataService.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent). + */ + ad_personalization_consent_state?: string } diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/index.ts index 21b17910c1..b9e80ad869 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadClickConversion/index.ts @@ -1,4 +1,10 @@ -import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import { + ActionDefinition, + PayloadValidationError, + ModifiedResponse, + RequestClient, + DynamicFieldResponse +} from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { CartItem, PartialErrorResponse } from '../types' @@ -9,9 +15,9 @@ import { handleGoogleErrors, convertTimestamp, getApiVersion, - commonHashedEmailValidation + commonHashedEmailValidation, + getConversionActionDynamicData } from '../functions' -import { ModifiedResponse } from '@segment/actions-core' const action: ActionDefinition = { title: 'Upload Click Conversion', @@ -19,10 +25,10 @@ const action: ActionDefinition = { fields: { conversion_action: { label: 'Conversion Action ID', - description: - 'The ID of the conversion action associated with this conversion. To find the Conversion Action ID, click on your conversion in Google Ads and get the value for `ctId` in the URL. For example, if the URL is `https://ads.google.com/aw/conversions/detail?ocid=00000000&ctId=570000000`, your Conversion Action ID is `570000000`.', + description: 'The ID of the conversion action associated with this conversion.', type: 'number', - required: true + required: true, + dynamic: true }, gclid: { label: 'GCLID', @@ -184,6 +190,37 @@ const action: ActionDefinition = { type: 'object', additionalProperties: true, defaultObjectUI: 'keyvalue:only' + }, + ad_user_data_consent_state: { + label: 'Ad User Data Consent State', + description: + 'This represents consent for ad user data.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent).', + type: 'string', + choices: [ + { label: 'GRANTED', value: 'GRANTED' }, + { label: 'DENIED', value: 'DENIED' }, + { label: 'UNSPECIFIED', value: 'UNSPECIFIED' } + ] + }, + ad_personalization_consent_state: { + label: 'Ad Personalization Consent State', + type: 'string', + description: + 'This represents consent for ad personalization. This can only be set for OfflineUserDataJobService and UserDataService.For more information on consent, refer to [Google Ads API Consent](https://developers.google.com/google-ads/api/rest/reference/rest/v15/Consent).', + choices: [ + { label: 'GRANTED', value: 'GRANTED' }, + { label: 'DENIED', value: 'DENIED' }, + { label: 'UNSPECIFIED', value: 'UNSPECIFIED' } + ] + } + }, + + dynamicFields: { + conversion_action: async ( + request: RequestClient, + { settings, auth, features, statsContext } + ): Promise => { + return getConversionActionDynamicData(request, settings, auth, features, statsContext) } }, perform: async (request, { auth, settings, payload, features, statsContext }) => { @@ -226,6 +263,20 @@ const action: ActionDefinition = { }, userIdentifiers: [] } + // Add Consent Signals 'adUserData' if it is defined + if (payload.ad_user_data_consent_state) { + request_object['consent'] = { + adUserData: payload.ad_user_data_consent_state + } + } + + // Add Consent Signals 'adPersonalization' if it is defined + if (payload.ad_personalization_consent_state) { + request_object['consent'] = { + ...request_object['consent'], + adPersonalization: payload.ad_personalization_consent_state + } + } // Retrieves all of the custom variables that the customer has created in their Google Ads account if (payload.custom_variables) { diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/generated-types.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/generated-types.ts index 2fdfdfd376..f53bb3c5fc 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/generated-types.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/generated-types.ts @@ -2,7 +2,7 @@ export interface Payload { /** - * The ID of the conversion action associated with this conversion. To find the Conversion Action ID, click on your conversion in Google Ads and get the value for `ctId` in the URL. For example, if the URL is `https://ads.google.com/aw/conversions/detail?ocid=00000000&ctId=570000000`, your Conversion Action ID is `570000000`. + * The ID of the conversion action associated with this conversion. */ conversion_action: number /** diff --git a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/index.ts b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/index.ts index 7801da3bc1..870d86c125 100644 --- a/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/index.ts +++ b/packages/destination-actions/src/destinations/google-enhanced-conversions/uploadConversionAdjustment/index.ts @@ -1,5 +1,12 @@ -import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' -import { hash, handleGoogleErrors, convertTimestamp, getApiVersion, commonHashedEmailValidation } from '../functions' +import { ActionDefinition, DynamicFieldResponse, PayloadValidationError, RequestClient } from '@segment/actions-core' +import { + hash, + handleGoogleErrors, + convertTimestamp, + getApiVersion, + commonHashedEmailValidation, + getConversionActionDynamicData +} from '../functions' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { PartialErrorResponse } from '../types' @@ -11,10 +18,10 @@ const action: ActionDefinition = { fields: { conversion_action: { label: 'Conversion Action ID', - description: - 'The ID of the conversion action associated with this conversion. To find the Conversion Action ID, click on your conversion in Google Ads and get the value for `ctId` in the URL. For example, if the URL is `https://ads.google.com/aw/conversions/detail?ocid=00000000&ctId=570000000`, your Conversion Action ID is `570000000`.', + description: 'The ID of the conversion action associated with this conversion.', type: 'number', - required: true + required: true, + dynamic: true }, adjustment_type: { label: 'Adjustment Type', @@ -22,6 +29,8 @@ const action: ActionDefinition = { 'The adjustment type. See [Google’s documentation](https://developers.google.com/google-ads/api/reference/rpc/v11/ConversionAdjustmentTypeEnum.ConversionAdjustmentType) for details on each type.', type: 'string', choices: [ + { label: `UNSPECIFIED`, value: 'UNSPECIFIED' }, + { label: `UNKNOWN`, value: 'UNKNOWN' }, { label: `RETRACTION`, value: 'RETRACTION' }, { label: 'RESTATEMENT', value: 'RESTATEMENT' }, { label: `ENHANCEMENT`, value: 'ENHANCEMENT' } @@ -66,13 +75,31 @@ const action: ActionDefinition = { label: 'Restatement Value', description: 'The restated conversion value. This is the value of the conversion after restatement. For example, to change the value of a conversion from 100 to 70, an adjusted value of 70 should be reported. Required for RESTATEMENT adjustments.', - type: 'number' + type: 'number', + depends_on: { + conditions: [ + { + fieldKey: 'adjustment_type', + operator: 'is_not', + value: ['RETRACTION'] + } + ] + } }, restatement_currency_code: { label: 'Restatement Currency Code', description: 'The currency of the restated value. If not provided, then the default currency from the conversion action is used, and if that is not set then the account currency is used. This is the ISO 4217 3-character currency code, e.g. USD or EUR.', - type: 'string' + type: 'string', + depends_on: { + conditions: [ + { + fieldKey: 'adjustment_type', + operator: 'is_not', + value: ['RETRACTION'] + } + ] + } }, email_address: { label: 'Email Address', @@ -197,6 +224,14 @@ const action: ActionDefinition = { } } }, + dynamicFields: { + conversion_action: async ( + request: RequestClient, + { settings, auth, features, statsContext } + ): Promise => { + return getConversionActionDynamicData(request, settings, auth, features, statsContext) + } + }, perform: async (request, { settings, payload, features, statsContext }) => { /* Enforcing this here since Customer ID is required for the Google Ads API but not for the Enhanced Conversions API. */ diff --git a/packages/destination-actions/src/destinations/google-sheets/postSheet/index.ts b/packages/destination-actions/src/destinations/google-sheets/postSheet/index.ts index 5f3040a1f9..63757f3072 100644 --- a/packages/destination-actions/src/destinations/google-sheets/postSheet/index.ts +++ b/packages/destination-actions/src/destinations/google-sheets/postSheet/index.ts @@ -21,7 +21,8 @@ const action: ActionDefinition = { label: 'Operation Type', description: "Describes the nature of the operation being performed. Only supported values are 'new' and 'updated'.", - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.event' } }, diff --git a/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/__tests__/index.test.ts index 3bc5e3f58c..79d39cbc38 100644 --- a/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/__tests__/index.test.ts @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, DynamicFieldResponse } from '@segment/actions-core' import Destination from '../../index' import { HUBSPOT_BASE_URL } from '../../properties' @@ -401,4 +401,72 @@ describe('HubSpot.sendCustomBehavioralEvent', () => { expect(responses[0].status).toBe(204) expect(responses[0].options.json).toMatchSnapshot() }) + + it('should dynamically fetch eventNames', async () => { + nock(HUBSPOT_BASE_URL) + .get(`/events/v3/event-definitions`) + .reply(200, { + total: 2, + results: [ + { + labels: { + singular: 'Viewed Car', + plural: null + }, + description: 'An event that fires when visitor views a car listing in the online inventory', + archived: false, + primaryObjectId: '0-1', + trackingType: 'MANUAL', + name: 'viewed_car', + id: '22036509', + fullyQualifiedName: 'pe24288748_viewed_car', + primaryObject: null, + createdAt: '2023-12-29T09:19:48.711Z', + objectTypeId: '6-22036509', + properties: [], + associations: [], + createdUserId: 1229008 + }, + { + labels: { + singular: 'Car features', + plural: null + }, + description: 'An event that fires when visitor views a car features', + archived: false, + primaryObjectId: '0-1', + trackingType: 'MANUAL', + name: 'car_features', + id: '22436142', + fullyQualifiedName: 'pe24288748_car_features', + primaryObject: null, + createdAt: '2024-01-10T12:31:49.368Z', + objectTypeId: '6-22436142', + properties: [], + associations: [], + createdUserId: 1229008 + } + ] + }) + + //Dynamically Fetch eventNames + const eventNameResponses = (await testDestination.executeDynamicField('sendCustomBehavioralEvent', 'eventName', { + payload: {}, + settings: {} + })) as DynamicFieldResponse + + expect(eventNameResponses.choices.length).toBe(2) + expect(eventNameResponses.choices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + label: 'viewed_car', + value: 'pe24288748_viewed_car' + }), + expect.objectContaining({ + label: 'car_features', + value: 'pe24288748_car_features' + }) + ]) + ) + }) }) diff --git a/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/index.ts b/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/index.ts index a4259d9bf0..c67a3f1bc1 100644 --- a/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/index.ts +++ b/packages/destination-actions/src/destinations/hubspot/sendCustomBehavioralEvent/index.ts @@ -1,8 +1,9 @@ -import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import { ActionDefinition, DynamicFieldResponse, PayloadValidationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import { HUBSPOT_BASE_URL } from '../properties' import type { Payload } from './generated-types' -import { flattenObject, transformEventName } from '../utils' +import { flattenObject, transformEventName, GetCustomEventResponse } from '../utils' +import { HubSpotError } from '../errors' interface CustomBehavioralEvent { eventName: string @@ -23,6 +24,7 @@ const action: ActionDefinition = { description: 'The internal event name assigned by HubSpot. This can be found in your HubSpot account. Events must be predefined in HubSpot. Please input the full internal event name including the `pe` prefix (i.e. `pe_event_name`). Learn how to find the internal name in [HubSpot’s documentation](https://knowledge.hubspot.com/analytics-tools/create-custom-behavioral-events).', type: 'string', + dynamic: true, required: true }, occurredAt: { @@ -67,9 +69,32 @@ const action: ActionDefinition = { defaultObjectUI: 'keyvalue:only' } }, - perform: (request, { payload, settings, features }) => { - const shouldTransformEventName = features && features['actions-hubspot-event-name'] - const eventName = shouldTransformEventName ? transformEventName(payload.eventName) : payload.eventName + dynamicFields: { + eventName: async (request): Promise => { + try { + const result: GetCustomEventResponse = await request(`${HUBSPOT_BASE_URL}/events/v3/event-definitions`, { + method: 'get', + skipResponseCloning: true + }) + const choices = result.data.results.map((event) => { + return { value: event.fullyQualifiedName, label: event.name } + }) + return { + choices + } + } catch (err) { + return { + choices: [], + error: { + message: (err as HubSpotError)?.response?.data?.message ?? 'Unknown error', + code: (err as HubSpotError)?.response?.data?.category ?? 'Unknown code' + } + } + } + } + }, + perform: (request, { payload, settings }) => { + const eventName = transformEventName(payload.eventName) const event: CustomBehavioralEvent = { eventName: eventName, diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts index ee1b416882..0d18fdfcfe 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__helpers__/test-utils.ts @@ -7,6 +7,7 @@ export type BatchContactListItem = { firstname: string lastname: string lifecyclestage?: string | undefined + additionalemail?: string | null } export const createBatchTestEvents = (batchContactList: BatchContactListItem[]) => @@ -51,6 +52,7 @@ export const generateBatchReadResponse = (batchContactList: BatchContactListItem properties: { createdate: '2023-07-06T12:47:47.626Z', email: contact.email, + hs_additional_emails: contact?.additionalemail ?? null, hs_object_id: contact.id, lastmodifieddate: '2023-07-06T12:48:02.784Z' } diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap index 906ae8f77a..81fd85adf7 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/__snapshots__/index.test.ts.snap @@ -1,5 +1,87 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`HubSpot.upsertContactBatch Should update contact on the basis of secondary email if it's not getting mapped with Primary email addresses 1`] = ` +Object { + "afterResponse": Array [ + [Function], + [Function], + [Function], + ], + "beforeRequest": Array [ + [Function], + ], + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\",\\"hs_additional_emails\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"secondaryemail@gmail.com\\"}]}", + "headers": Headers { + Symbol(map): Object { + "authorization": Array [ + "Bearer undefined", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, + }, + "json": Object { + "idProperty": "email", + "inputs": Array [ + Object { + "id": "secondaryemail@gmail.com", + }, + ], + "properties": Array [ + "email", + "lifecyclestage", + "hs_additional_emails", + ], + }, + "method": "POST", + "signal": AbortSignal {}, + "skipResponseCloning": true, + "statsContext": Object {}, + "throwHttpErrors": true, + "timeout": 10000, +} +`; + +exports[`HubSpot.upsertContactBatch Should update contact on the basis of secondary email if it's not getting mapped with Primary email addresses 2`] = ` +Object { + "errors": Array [], + "numErrors": 0, + "results": Array [ + Object { + "id": "113", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "userthree@somecompany.com", + "hs_additional_emails": "secondaryemail@gmail.com;secondaryemail+2@gmail.com", + "hs_object_id": "113", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + }, + }, + ], + "status": "COMPLETE", +} +`; + +exports[`HubSpot.upsertContactBatch Should update contact on the basis of secondary email if it's not getting mapped with Primary email addresses 3`] = ` +Object { + "results": Array [ + Object { + "id": "113", + "properties": Object { + "createdate": "2023-07-06T12:47:47.626Z", + "email": "secondaryemail@gmail.com", + "firstname": "User", + "lastmodifieddate": "2023-07-06T12:48:02.784Z", + "lastname": "Three", + "lifecyclestage": "subscriber", + }, + }, + ], + "status": "COMPLETE", +} +`; + exports[`HubSpot.upsertContactBatch should create and update contact successfully 1`] = ` Object { "afterResponse": Array [ @@ -10,7 +92,7 @@ Object { "beforeRequest": Array [ [Function], ], - "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"},{\\"id\\":\\"usertwo@somecompany.com\\"},{\\"id\\":\\"userthree@somecompany.com\\"},{\\"id\\":\\"userfour@somecompany.com\\"}]}", + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\",\\"hs_additional_emails\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"},{\\"id\\":\\"usertwo@somecompany.com\\"},{\\"id\\":\\"userthree@somecompany.com\\"},{\\"id\\":\\"userfour@somecompany.com\\"}]}", "headers": Headers { Symbol(map): Object { "authorization": Array [ @@ -40,6 +122,7 @@ Object { "properties": Array [ "email", "lifecyclestage", + "hs_additional_emails", ], }, "method": "POST", @@ -73,6 +156,7 @@ Object { "properties": Object { "createdate": "2023-07-06T12:47:47.626Z", "email": "userthree@somecompany.com", + "hs_additional_emails": null, "hs_object_id": "103", "lastmodifieddate": "2023-07-06T12:48:02.784Z", }, @@ -82,6 +166,7 @@ Object { "properties": Object { "createdate": "2023-07-06T12:47:47.626Z", "email": "userfour@somecompany.com", + "hs_additional_emails": null, "hs_object_id": "104", "lastmodifieddate": "2023-07-06T12:48:02.784Z", }, @@ -161,7 +246,7 @@ Object { "beforeRequest": Array [ [Function], ], - "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"},{\\"id\\":\\"usertwo@somecompany.com\\"}]}", + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\",\\"hs_additional_emails\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"},{\\"id\\":\\"usertwo@somecompany.com\\"}]}", "headers": Headers { Symbol(map): Object { "authorization": Array [ @@ -185,6 +270,7 @@ Object { "properties": Array [ "email", "lifecyclestage", + "hs_additional_emails", ], }, "method": "POST", @@ -257,7 +343,7 @@ Object { "beforeRequest": Array [ [Function], ], - "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"}]}", + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\",\\"hs_additional_emails\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userone@somecompany.com\\"}]}", "headers": Headers { Symbol(map): Object { "authorization": Array [ @@ -278,6 +364,7 @@ Object { "properties": Array [ "email", "lifecyclestage", + "hs_additional_emails", ], }, "method": "POST", @@ -299,6 +386,7 @@ Object { "properties": Object { "createdate": "2023-07-06T12:47:47.626Z", "email": "userone@somecompany.com", + "hs_additional_emails": null, "hs_object_id": "103", "lastmodifieddate": "2023-07-06T12:48:02.784Z", }, @@ -375,7 +463,7 @@ Object { "beforeRequest": Array [ [Function], ], - "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userthree@somecompany.com\\"},{\\"id\\":\\"userfour@somecompany.com\\"}]}", + "body": "{\\"properties\\":[\\"email\\",\\"lifecyclestage\\",\\"hs_additional_emails\\"],\\"idProperty\\":\\"email\\",\\"inputs\\":[{\\"id\\":\\"userthree@somecompany.com\\"},{\\"id\\":\\"userfour@somecompany.com\\"}]}", "headers": Headers { Symbol(map): Object { "authorization": Array [ @@ -399,6 +487,7 @@ Object { "properties": Array [ "email", "lifecyclestage", + "hs_additional_emails", ], }, "method": "POST", @@ -420,6 +509,7 @@ Object { "properties": Object { "createdate": "2023-07-06T12:47:47.626Z", "email": "userthree@somecompany.com", + "hs_additional_emails": null, "hs_object_id": "103", "lastmodifieddate": "2023-07-06T12:48:02.784Z", }, @@ -429,6 +519,7 @@ Object { "properties": Object { "createdate": "2023-07-06T12:47:47.626Z", "email": "userfour@somecompany.com", + "hs_additional_emails": null, "hs_object_id": "104", "lastmodifieddate": "2023-07-06T12:48:02.784Z", }, diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts index 2f9edadb11..7aefb04ba5 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/__tests__/index.test.ts @@ -555,7 +555,7 @@ describe('HubSpot.upsertContactBatch', () => { nock(HUBSPOT_BASE_URL) .post( `/crm/v3/objects/contacts/batch/update`, - '{"inputs":[{"id":"103","properties":{"company":"Some Company","phone":"+13134561129","address":"Vancover st","city":"San Francisco","state":"California","country":"USA","zip":"600001","email":"userone@somecompany.com","website":"somecompany.com","lifecyclestage":"subscriber","graduation_date":1664533942262}}]}' + '{"inputs":[{"id":"103","properties":{"company":"Some Company","phone":"+13134561129","address":"Vancover st","city":"San Francisco","state":"California","country":"USA","zip":"600001","website":"somecompany.com","lifecyclestage":"subscriber","graduation_date":1664533942262}}]}' ) .reply( 200, @@ -650,4 +650,59 @@ describe('HubSpot.upsertContactBatch', () => { }) ).rejects.toThrowError("'lastname' Property does not exist") }) + + test("Should update contact on the basis of secondary email if it's not getting mapped with Primary email addresses", async () => { + //Each Contact can have multiple email addresses,one as Primary and others as Secondary. + const updateContactList = [ + { + id: '113', + email: 'secondaryemail@gmail.com', + firstname: 'User', + lastname: 'Three', + lifecyclestage: 'subscriber', + additionalemail: 'secondaryemail@gmail.com;secondaryemail+2@gmail.com' + } + ] + const events = createBatchTestEvents([...updateContactList]) + + // Mock: Read Contact Using Email + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/read`) + .reply( + 200, + generateBatchReadResponse([ + { + id: '113', + email: 'userthree@somecompany.com', + firstname: 'User', + lastname: 'Three', + lifecyclestage: 'subscriber', + additionalemail: 'secondaryemail@gmail.com;secondaryemail+2@gmail.com' + } + ]) + ) + + // Mock: Update Contact + nock(HUBSPOT_BASE_URL) + .post(`/crm/v3/objects/contacts/batch/update`) + .reply(200, generateBatchCreateResponse(updateContactList)) + + const mapping = { + properties: { + graduation_date: { + '@path': '$.traits.graduation_date' + } + } + } + + const testBatchResponses = await testDestination.testBatchAction('upsertContact', { + mapping, + useDefaultMappings: true, + events + }) + + expect(testBatchResponses[0].options).toMatchSnapshot() + expect(testBatchResponses[0].data).toMatchSnapshot() + expect(testBatchResponses[1].data).toMatchSnapshot() + }) }) diff --git a/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts b/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts index 7d95f689f7..138c1cc695 100644 --- a/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts +++ b/packages/destination-actions/src/destinations/hubspot/upsertContact/index.ts @@ -4,6 +4,7 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { HUBSPOT_BASE_URL } from '../properties' import { flattenObject } from '../utils' +import split from 'lodash/split' interface ContactProperties { company?: string | undefined @@ -32,7 +33,7 @@ interface ContactUpdateRequestPayload { interface ContactSuccessResponse { id: string - properties: Record + properties: Record } interface ContactErrorResponse { @@ -264,6 +265,7 @@ const action: ActionDefinition = { if (action === 'create') { createList.push(payload) } else if (action === 'update') { + delete payload['properties']['email'] updateList.push({ id: payload.id as string, properties: payload.properties @@ -305,7 +307,7 @@ async function updateContact(request: RequestClient, email: string, properties: async function readContactsBatch(request: RequestClient, emails: string[]) { const requestPayload = { - properties: ['email', 'lifecyclestage'], + properties: ['email', 'lifecyclestage', 'hs_additional_emails'], idProperty: 'email', inputs: emails.map((email) => ({ id: email @@ -376,17 +378,8 @@ function updateActionsForBatchedContacts( // Throw any other error responses // Case 1: Loop over results if there are any if (readResponse.data?.results && readResponse.data.results.length > 0) { - for (const result of readResponse.data.results) { - // Set the action to update for contacts that exist in HubSpot - contactsUpsertMap[result.properties.email].action = 'update' - - // Set the id for contacts that exist in HubSpot - contactsUpsertMap[result.properties.email].payload.id = result.id - - // Re-index the payload with ID - contactsUpsertMap[result.id] = { ...contactsUpsertMap[result.properties.email] } - delete contactsUpsertMap[result.properties.email] - } + //create and map payload to update contact + contactsUpsertMap = createPayloadToUpdateContact(readResponse, contactsUpsertMap) } // Case 2: Loop over errors if there are any @@ -416,7 +409,8 @@ async function checkAndRetryUpdatingLifecycleStage( const retryLifeCycleStagePayload: ContactUpdateRequestPayload[] = [] for (const result of updateContactResponse.data.results) { - const desiredLifeCycleStage = contactsUpsertMap[result.id].payload.properties.lifecyclestage + const key = Object.keys(contactsUpsertMap).find((key) => contactsUpsertMap[key].payload.id == result.id) + const desiredLifeCycleStage = key ? contactsUpsertMap[key]?.payload?.properties?.lifecyclestage : null const currentLifeCycleStage = result.properties.lifecyclestage if (desiredLifeCycleStage && desiredLifeCycleStage !== currentLifeCycleStage) { @@ -444,4 +438,30 @@ async function checkAndRetryUpdatingLifecycleStage( await updateContactsBatch(request, retryLifeCycleStagePayload) } } + +function createPayloadToUpdateContact( + readResponse: BatchContactResponse, + contactsUpsertMap: Record +) { + for (const result of readResponse.data.results) { + let email: string | undefined | null + //Each Hubspot Contact can have mutiple email addresses ,one as primary and others as secondary emails + if (!contactsUpsertMap[`${result.properties.email}`]) { + // If contact is not getting mapped with Primary email then checking it in secondary email for same contact. + if (result.properties.hs_additional_emails) { + const secondaryEmails = split(result.properties.hs_additional_emails, ';') + email = Object.keys(contactsUpsertMap).find((key) => secondaryEmails.includes(key)) + } + } else { + email = result.properties.email + } + if (email) { + // Set the action to update for contacts that exist in HubSpot + contactsUpsertMap[email].action = 'update' + // Set the id for contacts that exist in HubSpot + contactsUpsertMap[email].payload.id = result.id + } + } + return contactsUpsertMap +} export default action diff --git a/packages/destination-actions/src/destinations/hubspot/utils.ts b/packages/destination-actions/src/destinations/hubspot/utils.ts index 915209da4e..8e69a393ce 100644 --- a/packages/destination-actions/src/destinations/hubspot/utils.ts +++ b/packages/destination-actions/src/destinations/hubspot/utils.ts @@ -110,3 +110,13 @@ export interface AssociationLabel { export interface GetAssociationLabelResponse { results: AssociationLabel[] } +export interface GetCustomEventsResult { + name: string + fullyQualifiedName: string +} +export interface GetCustomEventResponse { + data: { + total: number + results: GetCustomEventsResult[] + } +} diff --git a/packages/destination-actions/src/destinations/hyperengage/__tests__/hyperengage.test.ts b/packages/destination-actions/src/destinations/hyperengage/__tests__/hyperengage.test.ts new file mode 100644 index 0000000000..582fc8e332 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/__tests__/hyperengage.test.ts @@ -0,0 +1,30 @@ +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import nock from 'nock' + +export const apiKey = 'testApiKey' +export const workspaceIdentifier = 'testApiIdentifier' + +const testDestination = createTestIntegration(Definition) +beforeAll(() => { + nock.disableNetConnect() +}) + +afterAll(() => { + nock.enableNetConnect() + nock.cleanAll() +}) + +describe('Hyperengage', () => { + describe('testAuthentication', () => { + test('should validate workspaceIdentifier and apiKey', async () => { + nock('https://api.hyperengage.io/api/v1/verify_api_key') + .post(/.*/, { + api_key: apiKey, + workspace_identifier: workspaceIdentifier + }) + .reply(200, { message: 'Mocked response' }) + await expect(testDestination.testAuthentication({ apiKey, workspaceIdentifier })).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/hyperengage/__tests__/validateInput.test.ts b/packages/destination-actions/src/destinations/hyperengage/__tests__/validateInput.test.ts new file mode 100644 index 0000000000..00417afb92 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/__tests__/validateInput.test.ts @@ -0,0 +1,96 @@ +import { validateInput } from '../validateInput' + +const fakeTrackData = { + event_id: 'test-message-cz380xxe9kn', + page_title: 'Title', + event_name: 'test', + event_type: 'track', + properties: { + required: 'false' + }, + timestamp: '2023-09-11T08:06:11.192Z', + user_id: 'test', + account_id: 'testAccount' +} + +const fakeIdentifyData = { + event_id: 'test-message-cz380xxe9kn', + page_title: 'Title', + event_name: 'test', + event_type: 'identify', + name: 'testUser', + email: 'testEmail', + traits: { + required: 'false' + }, + timestamp: '2023-09-11T08:06:11.192Z', + user_id: 'test', + account_id: 'testAccount' +} + +const fakeGroupData = { + event_id: 'test-message-cz380xxe9kn', + page_title: 'Title', + event_name: 'test', + event_type: 'group', + name: 'Test account', + plan: 'temporary', + industry: 'test industry', + website: 'test website', + traits: { + required: 'false' + }, + timestamp: '2023-09-11T08:06:11.192Z', + timezone: 'Europe/Amsterdam', + user_id: 'test', + account_id: 'testAccount' +} + +const settings = { + workspaceIdentifier: 'testWorkspaceId', + apiKey: 'testApiKey' +} + +describe('validateInput', () => { + describe('test common payload', () => { + it('should return converted payload', () => { + const payload = validateInput(settings, fakeIdentifyData, 'user_identify') + expect(payload.api_key).toBe(settings.apiKey) + expect(payload.workspace_key).toBe(settings.workspaceIdentifier) + expect(payload.doc_encoding).toBe('UTF-8') + expect(payload.src).toBe('segment_api') + }) + }) + + describe('test identify payload', () => { + it('should return converted payload', async () => { + const payload = validateInput(settings, fakeIdentifyData, 'user_identify') + expect(payload.user_id).toEqual(fakeIdentifyData.user_id) + expect(payload.traits.email).toEqual(fakeIdentifyData.email) + expect(payload.traits.name).toEqual(fakeIdentifyData.name) + expect(payload.traits).toHaveProperty('required') + }) + }) + + describe('test group payload', () => { + it('should return converted payload', async () => { + const payload = validateInput(settings, fakeGroupData, 'account_identify') + expect(payload.account_id).toEqual(fakeGroupData.account_id) + expect(payload.user_id).toEqual(fakeGroupData.user_id) + expect(payload.traits.plan_name).toEqual(fakeGroupData.plan) + expect(payload.traits.industry).toEqual(fakeGroupData.industry) + expect(payload.traits.website).toEqual(fakeGroupData.website) + expect(payload.traits).toHaveProperty('required') + expect(payload.local_tz_offset).toEqual(60) + }) + }) + + describe('test track payload', () => { + it('should return converted payload', async () => { + let payload = validateInput(settings, fakeGroupData, 'account_identify') + expect(payload.event_type).toEqual('account_identify') + payload = validateInput(settings, fakeTrackData, 'track') + expect(payload.event_type).toEqual('test') + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/hyperengage/commonFields.ts b/packages/destination-actions/src/destinations/hyperengage/commonFields.ts new file mode 100644 index 0000000000..60a4ecce9b --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/commonFields.ts @@ -0,0 +1,164 @@ +import { ActionDefinition } from '@segment/actions-core' +import { Settings } from '../encharge/generated-types' + +export const commonFields: ActionDefinition['fields'] = { + anonymous_id: { + type: 'string', + allowNull: true, + required: false, + description: 'User Anonymous id', + label: 'Anonymous ID', + default: { '@path': '$.anonymousId' } + }, + event_id: { + type: 'string', + required: false, + description: 'The ID of the event.', + label: 'Event ID', + default: { '@path': '$.messageId' } + }, + doc_path: { + type: 'string', + required: false, + description: 'The path of the document.', + label: 'Document Path', + default: { '@path': '$.context.page.path' } + }, + doc_search: { + type: 'string', + required: false, + description: 'The search query of the document.', + label: 'Document Search', + default: { '@path': '$.context.page.search' } + }, + doc_title: { + type: 'string', + required: false, + description: 'The title of the page where the event occurred.', + label: 'Page Title', + default: { '@path': '$.context.page.title' } + }, + url: { + type: 'string', + required: false, + description: 'The URL of the page where the event occurred.', + label: 'URL', + default: { '@path': '$.context.page.url' } + }, + referer: { + type: 'string', + required: false, + description: 'The referrer of the page where the event occurred.', + label: 'Referrer', + default: { '@path': '$.context.page.referrer' } + }, + user_agent: { + type: 'string', + required: false, + description: 'The user agent of the browser.', + label: 'User Agent', + default: { '@path': '$.context.userAgent' } + }, + user_language: { + type: 'string', + required: false, + description: 'The language of the browser.', + label: 'User Language', + default: { '@path': '$.context.locale' } + }, + utc_time: { + type: 'string', + required: false, + description: 'The time of the event in UTC.', + label: 'UTC Time', + default: { '@path': '$.timestamp' } + }, + utm: { + type: 'object', + required: false, + description: 'Information about the UTM parameters.', + label: 'UTM', + properties: { + source: { + label: 'Source', + description: 'The source of the campaign.', + type: 'string' + }, + medium: { + label: 'Medium', + description: 'The medium of the campaign.', + type: 'string' + }, + name: { + label: 'Name', + description: 'The name of the campaign.', + type: 'string' + }, + term: { + label: 'Term', + description: 'The term of the campaign.', + type: 'string' + }, + content: { + label: 'Content', + description: 'The content of the campaign.', + type: 'string' + } + }, + default: { + source: { '@path': '$.context.campaign.source' }, + medium: { '@path': '$.context.campaign.medium' }, + name: { '@path': '$.context.campaign.name' }, + term: { '@path': '$.context.campaign.term' }, + content: { '@path': '$.context.campaign.content' } + } + }, + screen: { + type: 'object', + required: false, + description: 'Information about the screen.', + label: 'Screen', + properties: { + height: { + label: 'Height', + description: 'The height of the screen.', + type: 'integer' + }, + width: { + label: 'Width', + description: 'The width of the screen.', + type: 'integer' + }, + density: { + label: 'Density', + description: 'The density of the screen.', + type: 'number' + } + }, + default: { + height: { '@path': '$.context.screen.height' }, + width: { '@path': '$.context.screen.width' }, + density: { '@path': '$.context.screen.density' } + } + }, + timezone: { + type: 'string', + required: false, + description: 'The timezone of the browser.', + label: 'Timezone', + default: { + '@if': { + exists: { '@path': '$.context.timezone' }, + then: { '@path': '$.context.timezone' }, + else: { '@path': '$.properties.timezone' } + } + } + }, + source_ip: { + type: 'string', + required: false, + description: 'The IP address of the user.', + label: 'IP Address', + default: { '@path': '$.context.ip' } + } +} diff --git a/packages/destination-actions/src/destinations/hyperengage/generated-types.ts b/packages/destination-actions/src/destinations/hyperengage/generated-types.ts new file mode 100644 index 0000000000..660b447668 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Hyperengage API key located in the Integration Settings page. + */ + apiKey: string + /** + * Your Hyperengage workspace identifier located in the Integration Settings page. + */ + workspaceIdentifier: string +} diff --git a/packages/destination-actions/src/destinations/hyperengage/group/__tests__/index.test.ts b/packages/destination-actions/src/destinations/hyperengage/group/__tests__/index.test.ts new file mode 100644 index 0000000000..ce30720874 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/group/__tests__/index.test.ts @@ -0,0 +1,106 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +beforeAll(() => { + nock.disableNetConnect() +}) + +afterAll(() => { + nock.enableNetConnect() + nock.cleanAll() +}) + +const heGroupMapping = { + account_id: { + '@path': '$.groupId' + }, + name: { + '@if': { + exists: { '@path': '$.traits.name' }, + then: { '@path': '$.traits.name' }, + else: { '@path': '$.properties.name' } + } + }, + created_at: { + '@path': '$.traits.created_at' + }, + traits: { + '@path': '$.traits' + }, + plan: { + '@path': '$.traits.plan' + }, + industry: { + '@path': '$.traits.industry' + }, + website: { + '@path': '$.traits.website' + } +} + +describe('Hyperengage.group', () => { + test('Should throw an error if `account_id or` `name` is not defined', async () => { + const event = createTestEvent({ + type: 'group', + traits: { + email: 'test@company.com' + }, + groupId: 'test@test.com' + }) + + await expect( + testDestination.testAction('group', { + event, + mapping: heGroupMapping + }) + ).rejects.toThrowError() + }) + + test('Should throw an error if workspaceIdentifier or apiKey is not defined', async () => { + const event = createTestEvent({ + type: 'group', + traits: { + name: 'test' + }, + groupId: '123456' + }) + + await expect( + testDestination.testAction('group', { + event, + mapping: heGroupMapping, + settings: { + workspaceIdentifier: '', + apiKey: '' + } + }) + ).rejects.toThrowError() + }) + + test('Should send an group event to Hyperengage', async () => { + // Mock: Segment group Call + nock('https://events.hyperengage.io').post('/api/v1/s2s/event?token=apiKey').reply(200, { success: true }) + + const event = createTestEvent({ + type: 'group', + traits: { + name: 'test' + }, + groupId: '123456' + }) + + const responses = await testDestination.testAction('group', { + event, + mapping: heGroupMapping, + settings: { + workspaceIdentifier: 'identifier', + apiKey: 'apiKey' + } + }) + + expect(responses[0].status).toEqual(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/hyperengage/group/generated-types.ts b/packages/destination-actions/src/destinations/hyperengage/group/generated-types.ts new file mode 100644 index 0000000000..880ae08712 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/group/generated-types.ts @@ -0,0 +1,128 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The External ID of the account to send properties for + */ + account_id: string + /** + * The ID associated with the user + */ + user_id?: string + /** + * The Account name + */ + name: string + /** + * The timestamp when the account was created, represented in the ISO-8601 date format. For instance, "2023-09-26T15:30:00Z". + */ + created_at?: string + /** + * The properties of the account + */ + traits?: { + [k: string]: unknown + } + /** + * Subscription plan the account is associated with + */ + plan?: string + /** + * The account industry + */ + industry?: string + /** + * The account website + */ + website?: string + /** + * User Anonymous id + */ + anonymous_id?: string | null + /** + * The ID of the event. + */ + event_id?: string + /** + * The path of the document. + */ + doc_path?: string + /** + * The search query of the document. + */ + doc_search?: string + /** + * The title of the page where the event occurred. + */ + doc_title?: string + /** + * The URL of the page where the event occurred. + */ + url?: string + /** + * The referrer of the page where the event occurred. + */ + referer?: string + /** + * The user agent of the browser. + */ + user_agent?: string + /** + * The language of the browser. + */ + user_language?: string + /** + * The time of the event in UTC. + */ + utc_time?: string + /** + * Information about the UTM parameters. + */ + utm?: { + /** + * The source of the campaign. + */ + source?: string + /** + * The medium of the campaign. + */ + medium?: string + /** + * The name of the campaign. + */ + name?: string + /** + * The term of the campaign. + */ + term?: string + /** + * The content of the campaign. + */ + content?: string + } + /** + * Information about the screen. + */ + screen?: { + /** + * The height of the screen. + */ + height?: number + /** + * The width of the screen. + */ + width?: number + /** + * The density of the screen. + */ + density?: number + } + /** + * The timezone of the browser. + */ + timezone?: string + /** + * The IP address of the user. + */ + source_ip?: string +} diff --git a/packages/destination-actions/src/destinations/hyperengage/group/index.ts b/packages/destination-actions/src/destinations/hyperengage/group/index.ts new file mode 100644 index 0000000000..54cf68aa2f --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/group/index.ts @@ -0,0 +1,102 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { validateInput } from '../validateInput' +import { commonFields } from '../commonFields' + +const action: ActionDefinition = { + title: 'Group', + description: 'Send group calls to Hyperengage.', + defaultSubscription: 'type = "group"', + fields: { + account_id: { + type: 'string', + required: true, + description: 'The External ID of the account to send properties for', + label: 'Account id', + default: { + '@if': { + exists: { '@path': '$.context.group_id' }, + then: { '@path': '$.context.group_id' }, + else: { '@path': '$.groupId' } + } + } + }, + user_id: { + type: 'string', + description: 'The ID associated with the user', + label: 'User ID', + default: { '@path': '$.userId' } + }, + name: { + type: 'string', + required: true, + description: 'The Account name', + label: 'Account name', + default: { + '@if': { + exists: { '@path': '$.traits.name' }, + then: { '@path': '$.traits.name' }, + else: { '@path': '$.properties.name' } + } + } + }, + created_at: { + type: 'string', + required: false, + description: + 'The timestamp when the account was created, represented in the ISO-8601 date format. For instance, "2023-09-26T15:30:00Z".', + label: 'Account created at', + default: { + '@if': { + exists: { '@path': '$.traits.created_at' }, + then: { '@path': '$.traits.created_at' }, + else: { '@path': '$.traits.createdAt' } + } + } + }, + traits: { + type: 'object', + required: false, + description: 'The properties of the account', + label: 'Account properties', + default: { '@path': '$.traits' } + }, + plan: { + type: 'string', + required: false, + description: 'Subscription plan the account is associated with', + label: 'Account subscription plan', + default: { + '@if': { + exists: { '@path': '$.traits.plan' }, + then: { '@path': '$.traits.plan' }, + else: { '@path': '$.traits.plan_name' } + } + } + }, + industry: { + type: 'string', + required: false, + description: 'The account industry', + label: 'Account industry', + default: { '@path': '$.traits.industry' } + }, + website: { + type: 'string', + required: false, + description: 'The account website', + label: 'Account website', + default: { '@path': '$.traits.website' } + }, + ...commonFields + }, + perform: (request, data) => { + return request(`https://events.hyperengage.io/api/v1/s2s/event?token=${data.settings.apiKey}`, { + method: 'post', + json: validateInput(data.settings, data.payload, 'account_identify') + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/hyperengage/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/hyperengage/identify/__tests__/index.test.ts new file mode 100644 index 0000000000..136cdd94a0 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/identify/__tests__/index.test.ts @@ -0,0 +1,211 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +beforeAll(() => { + nock.disableNetConnect() +}) + +afterAll(() => { + nock.enableNetConnect() + nock.cleanAll() +}) + +const heIdentifyMapping = { + user_id: { + '@path': '$.userId' + }, + name: { + '@if': { + exists: { '@path': '$.traits.name' }, + then: { '@path': '$.traits.name' }, + else: { '@path': '$.properties.name' } + } + }, + first_name: { + '@if': { + exists: { '@path': '$.traits.first_name' }, + then: { '@path': '$.traits.first_name' }, + else: { '@path': '$.properties.first_name' } + } + }, + last_name: { + '@if': { + exists: { '@path': '$.traits.last_name' }, + then: { '@path': '$.traits.last_name' }, + else: { '@path': '$.properties.last_name' } + } + }, + email: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + created_at: { + '@path': '$.traits.created_at' + }, + traits: { + '@path': '$.traits' + } +} + +describe('Hyperengage.identify', () => { + test('Should throw an error if `user_id` is not defined', async () => { + const event = createTestEvent({ + type: 'identify', + traits: { + name: 'test', + email: 'test@company.com' + }, + properties: { + timezone: 'America/New_York' + } + }) + + await expect( + testDestination.testAction('identify', { + event, + mapping: heIdentifyMapping, + settings: { + workspaceIdentifier: 'identifier', + apiKey: 'apiKey' + } + }) + ).rejects.toThrowError() + }) + + test('Should throw an error if both `name` and `first_name` & `last_name` are not defined', async () => { + const event = createTestEvent({ + type: 'identify', + traits: { + email: 'test@company.com' + }, + properties: { + timezone: 'America/New_York' + }, + userId: '123456' + }) + + await expect( + testDestination.testAction('identify', { + event, + mapping: heIdentifyMapping, + settings: { + workspaceIdentifier: 'identifier', + apiKey: 'apiKey' + } + }) + ).rejects.toThrowError() + }) + + test('Should not throw error if name is defined and first and last name are not', async () => { + nock('https://events.hyperengage.io').post('/api/v1/s2s/event?token=apiKey').reply(200, { success: true }) + const event = createTestEvent({ + type: 'identify', + traits: { + name: 'test', + email: 'test@company.com' + }, + properties: { + timezone: 'America/New_York' + }, + userId: '123456' + }) + + await expect( + testDestination.testAction('identify', { + event, + mapping: heIdentifyMapping, + settings: { + workspaceIdentifier: 'identifier', + apiKey: 'apiKey' + } + }) + ).resolves.not.toThrowError() + }) + + test('Should not throw error if first_name and last_name are defined and name is not', async () => { + nock('https://events.hyperengage.io').post('/api/v1/s2s/event?token=apiKey').reply(200, { success: true }) + const event = createTestEvent({ + type: 'identify', + traits: { + first_name: 'test', + last_name: 'test', + email: 'test@company.com' + }, + properties: { + timezone: 'America/New_York' + }, + userId: '123456' + }) + + await expect( + testDestination.testAction('identify', { + event, + mapping: heIdentifyMapping, + settings: { + workspaceIdentifier: 'identifier', + apiKey: 'apiKey' + } + }) + ).resolves.not.toThrowError() + }) + + test('Should throw an error if workspaceIdentifier or apiKey is not defined', async () => { + const event = createTestEvent({ + type: 'identify', + traits: { + name: 'test', + email: 'test@company.com' + }, + properties: { + timezone: 'America/New_York' + }, + userId: '123456' + }) + + await expect( + testDestination.testAction('identify', { + event, + mapping: heIdentifyMapping, + settings: { + workspaceIdentifier: '', + apiKey: '' + } + }) + ).rejects.toThrowError() + }) + + test('Should send an identify event to Hyperengage', async () => { + // Mock: Segment Identify Call + nock('https://events.hyperengage.io').post('/api/v1/s2s/event?token=apiKey').reply(200, { success: true }) + + const event = createTestEvent({ + type: 'identify', + traits: { + name: 'test', + email: 'test@company.com' + }, + properties: { + timezone: 'America/New_York' + }, + userId: '123456' + }) + + const responses = await testDestination.testAction('identify', { + event, + mapping: heIdentifyMapping, + useDefaultMappings: true, + settings: { + workspaceIdentifier: 'identifier', + apiKey: 'apiKey' + } + }) + + expect(responses[0].status).toEqual(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/hyperengage/identify/generated-types.ts b/packages/destination-actions/src/destinations/hyperengage/identify/generated-types.ts new file mode 100644 index 0000000000..ccbb2d14da --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/identify/generated-types.ts @@ -0,0 +1,128 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The External ID of the user + */ + user_id: string + /** + * The user's name + */ + name?: string | null + /** + * The user's first name. This field is mandatory if you're not providing a name field + */ + first_name?: string | null + /** + * The user's last name. This field is mandatory if you're not providing a name field + */ + last_name?: string | null + /** + * The user's email address + */ + email?: string + /** + * The account id, to uniquely identify the account associated with the user + */ + account_id?: string + /** + * The timestamp when the user was created, represented in the ISO-8601 date format. For instance, "2023-09-26T15:30:00Z". + */ + created_at?: string + /** + * Properties to associate with the user + */ + traits?: { + [k: string]: unknown + } + /** + * User Anonymous id + */ + anonymous_id?: string | null + /** + * The ID of the event. + */ + event_id?: string + /** + * The path of the document. + */ + doc_path?: string + /** + * The search query of the document. + */ + doc_search?: string + /** + * The title of the page where the event occurred. + */ + doc_title?: string + /** + * The URL of the page where the event occurred. + */ + url?: string + /** + * The referrer of the page where the event occurred. + */ + referer?: string + /** + * The user agent of the browser. + */ + user_agent?: string + /** + * The language of the browser. + */ + user_language?: string + /** + * The time of the event in UTC. + */ + utc_time?: string + /** + * Information about the UTM parameters. + */ + utm?: { + /** + * The source of the campaign. + */ + source?: string + /** + * The medium of the campaign. + */ + medium?: string + /** + * The name of the campaign. + */ + name?: string + /** + * The term of the campaign. + */ + term?: string + /** + * The content of the campaign. + */ + content?: string + } + /** + * Information about the screen. + */ + screen?: { + /** + * The height of the screen. + */ + height?: number + /** + * The width of the screen. + */ + width?: number + /** + * The density of the screen. + */ + density?: number + } + /** + * The timezone of the browser. + */ + timezone?: string + /** + * The IP address of the user. + */ + source_ip?: string +} diff --git a/packages/destination-actions/src/destinations/hyperengage/identify/index.ts b/packages/destination-actions/src/destinations/hyperengage/identify/index.ts new file mode 100644 index 0000000000..344143e373 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/identify/index.ts @@ -0,0 +1,119 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { validateInput } from '../validateInput' +import { commonFields } from '../commonFields' + +const action: ActionDefinition = { + title: 'Identify', + description: 'Send identify calls to Hyperengage.', + defaultSubscription: 'type = "identify"', + platform: 'cloud', + fields: { + user_id: { + type: 'string', + required: true, + description: 'The External ID of the user', + label: 'User ID', + default: { '@path': '$.userId' } + }, + name: { + type: 'string', + required: false, + description: "The user's name", + allowNull: true, + label: 'Name', + default: { + '@if': { + exists: { '@path': '$.traits.name' }, + then: { '@path': '$.traits.name' }, + else: { '@path': '$.properties.name' } + } + } + }, + first_name: { + type: 'string', + required: false, + allowNull: true, + description: "The user's first name. This field is mandatory if you're not providing a name field", + label: 'First name', + default: { + '@if': { + exists: { '@path': '$.traits.first_name' }, + then: { '@path': '$.traits.first_name' }, + else: { '@path': '$.properties.first_name' } + } + } + }, + last_name: { + type: 'string', + required: false, + allowNull: true, + description: "The user's last name. This field is mandatory if you're not providing a name field", + label: 'Last name', + default: { + '@if': { + exists: { '@path': '$.traits.last_name' }, + then: { '@path': '$.traits.last_name' }, + else: { '@path': '$.properties.last_name' } + } + } + }, + email: { + type: 'string', + required: false, + description: "The user's email address", + label: 'Email address', + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + account_id: { + type: 'string', + required: false, + description: 'The account id, to uniquely identify the account associated with the user', + label: 'Account id', + default: { + '@if': { + exists: { '@path': '$.context.group_id' }, + then: { '@path': '$.context.group_id' }, + else: { '@path': '$.groupId' } + } + } + }, + created_at: { + type: 'string', + required: false, + description: + 'The timestamp when the user was created, represented in the ISO-8601 date format. For instance, "2023-09-26T15:30:00Z".', + label: 'Created at', + default: { + '@if': { + exists: { '@path': '$.traits.created_at' }, + then: { '@path': '$.traits.created_at' }, + else: { '@path': '$.traits.createdAt' } + } + } + }, + traits: { + type: 'object', + label: 'Traits', + description: 'Properties to associate with the user', + required: false, + default: { '@path': '$.traits' } + }, + ...commonFields + }, + perform: (request, data) => { + return request(`https://events.hyperengage.io/api/v1/s2s/event?token=${data.settings.apiKey}`, { + method: 'post', + json: validateInput(data.settings, data.payload, 'user_identify') + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/hyperengage/index.ts b/packages/destination-actions/src/destinations/hyperengage/index.ts new file mode 100644 index 0000000000..af23f8339a --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/index.ts @@ -0,0 +1,72 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { defaultValues } from '@segment/actions-core' +import identify from './identify' +import group from './group' +import track from './track' + +const presets: DestinationDefinition['presets'] = [ + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'track', + mapping: defaultValues(track.fields), + type: 'automatic' + }, + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identify', + mapping: defaultValues(identify.fields), + type: 'automatic' + }, + { + name: 'Group', + subscribe: 'type = "group"', + partnerAction: 'group', + mapping: defaultValues(group.fields), + type: 'automatic' + } +] + +const destination: DestinationDefinition = { + name: 'Hyperengage (Actions)', + slug: 'actions-hyperengage', + mode: 'cloud', + description: 'Hyperengage actions destination, to connect your product usage data from Segment to Hyperengage', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + type: 'string', + label: 'API Key', + description: 'Your Hyperengage API key located in the Integration Settings page.', + required: true + }, + workspaceIdentifier: { + type: 'string', + label: 'Workspace Identifier', + description: 'Your Hyperengage workspace identifier located in the Integration Settings page.', + required: true + } + }, + testAuthentication: async (request, { settings }) => { + return await request('https://api.hyperengage.io/api/v1/verify_api_key', { + method: 'post', + json: { + api_key: `${settings.apiKey}`, + workspace_identifier: `${settings.workspaceIdentifier}` + } + }) + } + }, + + presets, + actions: { + identify, + group, + track + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/hyperengage/track/__tests__/index.test.ts b/packages/destination-actions/src/destinations/hyperengage/track/__tests__/index.test.ts new file mode 100644 index 0000000000..ab8977280b --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/track/__tests__/index.test.ts @@ -0,0 +1,93 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +beforeAll(() => { + nock.disableNetConnect() +}) + +afterAll(() => { + nock.enableNetConnect() + nock.cleanAll() +}) + +const heTrackMapping = { + event_name: { + '@path': '$.event' + }, + properties: { + '@path': '$.properties' + }, + user_id: { + '@path': '$.userId' + }, + account_id: { + '@path': '$.groupId' + } +} + +describe('Hyperengage.track', () => { + test('Should throw an error if `event_name` is not defined', async () => { + const event = createTestEvent({ + type: 'track', + properties: { + recency: 'Now' + }, + event: 'Test_Event' + }) + + await expect( + testDestination.testAction('track', { + event, + mapping: heTrackMapping + }) + ).rejects.toThrowError() + }) + + test('Should throw an error if workspaceIdentifier or apiKey is not defined', async () => { + const event = createTestEvent({ + type: 'track', + properties: { + recency: 'Now' + }, + event: 'Test_Event' + }) + + await expect( + testDestination.testAction('track', { + event, + mapping: heTrackMapping, + settings: { + workspaceIdentifier: '', + apiKey: '' + } + }) + ).rejects.toThrowError() + }) + + test('Should send an track event to Hyperengage', async () => { + // Mock: Segment track Call + nock('https://events.hyperengage.io').post('/api/v1/s2s/event?token=apiKey').reply(200, { success: true }) + + const event = createTestEvent({ + type: 'track', + properties: { + recency: 'Now' + }, + event: 'Test_Event' + }) + + const responses = await testDestination.testAction('track', { + event, + mapping: heTrackMapping, + settings: { + workspaceIdentifier: 'identifier', + apiKey: 'apiKey' + } + }) + + expect(responses[0].status).toEqual(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/hyperengage/track/generated-types.ts b/packages/destination-actions/src/destinations/hyperengage/track/generated-types.ts new file mode 100644 index 0000000000..c11f816ded --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/track/generated-types.ts @@ -0,0 +1,112 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the event + */ + event_name: string + /** + * The user id, to uniquely identify the user associated with the event + */ + user_id: string + /** + * The account id, to uniquely identify the account associated with the user + */ + account_id?: string + /** + * The properties of the track call + */ + properties?: { + [k: string]: unknown + } + /** + * User Anonymous id + */ + anonymous_id?: string | null + /** + * The ID of the event. + */ + event_id?: string + /** + * The path of the document. + */ + doc_path?: string + /** + * The search query of the document. + */ + doc_search?: string + /** + * The title of the page where the event occurred. + */ + doc_title?: string + /** + * The URL of the page where the event occurred. + */ + url?: string + /** + * The referrer of the page where the event occurred. + */ + referer?: string + /** + * The user agent of the browser. + */ + user_agent?: string + /** + * The language of the browser. + */ + user_language?: string + /** + * The time of the event in UTC. + */ + utc_time?: string + /** + * Information about the UTM parameters. + */ + utm?: { + /** + * The source of the campaign. + */ + source?: string + /** + * The medium of the campaign. + */ + medium?: string + /** + * The name of the campaign. + */ + name?: string + /** + * The term of the campaign. + */ + term?: string + /** + * The content of the campaign. + */ + content?: string + } + /** + * Information about the screen. + */ + screen?: { + /** + * The height of the screen. + */ + height?: number + /** + * The width of the screen. + */ + width?: number + /** + * The density of the screen. + */ + density?: number + } + /** + * The timezone of the browser. + */ + timezone?: string + /** + * The IP address of the user. + */ + source_ip?: string +} diff --git a/packages/destination-actions/src/destinations/hyperengage/track/index.ts b/packages/destination-actions/src/destinations/hyperengage/track/index.ts new file mode 100644 index 0000000000..495803544b --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/track/index.ts @@ -0,0 +1,56 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { validateInput } from '../validateInput' +import { commonFields } from '../commonFields' + +const action: ActionDefinition = { + title: 'Track', + description: 'Send track calls to Hyperengage.', + defaultSubscription: 'type = "track"', + fields: { + event_name: { + type: 'string', + required: true, + description: 'The name of the event', + label: 'Event name', + default: { '@path': '$.event' } + }, + user_id: { + type: 'string', + required: true, + description: 'The user id, to uniquely identify the user associated with the event', + label: 'User id', + default: { '@path': '$.userId' } + }, + account_id: { + type: 'string', + required: false, + description: 'The account id, to uniquely identify the account associated with the user', + label: 'Account id', + default: { + '@if': { + exists: { '@path': '$.context.groupId' }, + then: { '@path': '$.context.groupId' }, + else: { '@path': '$.groupId' } + } + } + }, + properties: { + type: 'object', + required: false, + description: 'The properties of the track call', + label: 'Event properties', + default: { '@path': '$.properties' } + }, + ...commonFields + }, + perform: (request, data) => { + return request(`https://events.hyperengage.io/api/v1/s2s/event?token=${data.settings.apiKey}`, { + method: 'post', + json: validateInput(data.settings, data.payload, 'track') + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/hyperengage/validateInput.ts b/packages/destination-actions/src/destinations/hyperengage/validateInput.ts new file mode 100644 index 0000000000..0e22c727d8 --- /dev/null +++ b/packages/destination-actions/src/destinations/hyperengage/validateInput.ts @@ -0,0 +1,108 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { Settings } from './generated-types' +import { PayloadValidationError } from '@segment/actions-core' + +// Convert relevant input properties to Hyperengage properties +export const validateInput = ( + settings: Settings, + input: Record, + event_type: 'track' | 'user_identify' | 'account_identify' +): any => { + const properties: any = { + api_key: settings.apiKey, + workspace_key: settings.workspaceIdentifier, + doc_encoding: 'UTF-8', + src: 'segment_api', + screen_resolution: '0', + account_id: input?.account_id || input?.traits?.companyId || input?.traits?.company?.id, + ids: {}, + event_type: event_type, + ...input + } + delete properties.event_name + + // Get screen_resolution from the input screen width and height + if (input?.screen) { + const { width, height } = input.screen + properties.screen_resolution = `${width || 0}x${height || 0}` + properties.vp_size = `${width || 0}x${height || 0}` + delete properties.screen + } + + // Resolve local_tz_offset property, we can get local_tz_offset from the input context.timezone + if (input?.timezone) { + const offset = new Date() + .toLocaleString('en-US', { timeZone: input.timezone, timeZoneName: 'short' }) + .split(' ')[3] + .slice(3) + properties.local_tz_offset = parseInt(offset) * 60 + delete properties.timezone + } + + // Check if event property is present, we will use it as event_type + if (input?.event_name && event_type === 'track') { + properties.event_type = input?.event_name + delete properties.event_name + } else { + properties.event_type = event_type + } + + // Validate user properties + if (event_type === 'user_identify') { + if (input?.name) { + properties.traits = { + email: input?.email, + name: input?.name, + created_at: input?.created_at, + ...properties.traits + } + } else if (input?.first_name || input?.last_name) { + properties.traits = { + email: input?.email, + name: `${input?.first_name} ${input?.last_name}}`, + created_at: input?.created_at, + ...properties.traits + } + } else { + throw new PayloadValidationError('Either name, or first_name and last_name must be provided.') + } + + // Create object if company_id is present in traits + if (input?.traits?.company) { + properties.company = { + ...input.traits.company + } + delete properties.traits.company + } + // Delete unnecessary user properties + delete properties.email + delete properties.name + delete properties.first_name + delete properties.last_name + delete properties.created_at + } + + // Validate account properties + if (event_type === 'account_identify') { + properties.traits = { + name: input?.name, + created_at: input?.created_at, + plan_name: input?.plan, + industry: input?.industry, + trial_start_date: input?.trial_start, + trial_expiry_date: input?.trial_end, + website: input?.website, + ...properties.traits + } + delete properties.name + delete properties.created_at + delete properties.plan + delete properties.industry + delete properties.trial_start + delete properties.trial_end + delete properties.website + } + + return properties +} diff --git a/packages/destination-actions/src/destinations/index.ts b/packages/destination-actions/src/destinations/index.ts index 347b685560..198c484dee 100644 --- a/packages/destination-actions/src/destinations/index.ts +++ b/packages/destination-actions/src/destinations/index.ts @@ -127,8 +127,40 @@ register('64edec5a4f881f992e432b81', './acoustic-s3tc') register('64edeb2bee24614fe52ede34', './optimizely-advanced-audience-targeting') register('64ede9fe67158afa8de61480', './dynamic-yield-audiences') register('64f703d1f6e9aa0a283ae3e2', './absmartly') +register('6514281004d549fae3fd086a', './yahoo-audiences') register('650bdf1a62fb34ef0a8058e1', './klaviyo') register('6512d7f86bdccc3829fc4ac3', './optimizely-data-platform') +register('651c1db19de92d8e595ff55d', './hyperengage') +register('65256052ac030f823df6c1a5', './trackey') +register('652e765dbea0a2319209d193', './linkedin-conversions') +register('652ea51a327a62b351aa12c0', './kameleoon') +register('65302a514ce4a2f0f14cd426', './marketo-static-lists') +register('65302a3acb309a8a3d5593f2', './display-video-360') +register('6537b4236b16986dba32583e', './apolloio') +register('6537b55db9e94b2e110c9cf9', './movable-ink') +register('6537b5da8f27fd20713a5ba8', './usermotion') +register('6554dc58634812f080d83a23', './canvas') +register('656f2474a919b7e6e4900265', './gleap') +register('659eb79c1141e58effa2153e', './kevel') +register('659eb601f8f615dac18db564', './aggregations-io') +register('659eb6903c4d201ebd9e2f5c', './equals') +register('65ae435952ce3b2244f99e22', './amazon-ads') +register('65b8e9eca1b5903a031c6378', './schematic') +register('65b8e9ae4bc3eee909e05c73', './courier') +register('65b8e9531fc2c458f50fd55d', './tiktok-offline-conversions-sandbox') +register('65b8e9108b442384abfd05f9', './tiktok-conversions-sandbox') +register('65b8e89cd96df17201b04a49', './surveysparrow') +register('65c2465d0d7d550aa8e7e5c6', './avo') +register('65c36c1e127fb2c8188a414c', './stackadapt') +register('65cb48feaca9d46bf269ac4a', './accoil-analytics') +register('65dde5755698cb0dab09b489', './kafka') +register('65e71d50e1191c6273d1df1d', './kevel-audience') +register('65f05e455b125cddd886b793', './moloco-rmp') +register('6578a19fbd1201d21f035156', './responsys') +register('65f9885371de48a7a3f6b4bf', './yotpo') +register('65f98869b73d65a27152e088', './mantle') +register('65f9888628c310646331738a', './chartmogul') + function register(id: MetadataId, destinationPath: string) { // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/packages/destination-actions/src/destinations/insider-audiences/insider-helpers.ts b/packages/destination-actions/src/destinations/insider-audiences/insider-helpers.ts index 51e25eb1fa..4e6640193e 100644 --- a/packages/destination-actions/src/destinations/insider-audiences/insider-helpers.ts +++ b/packages/destination-actions/src/destinations/insider-audiences/insider-helpers.ts @@ -30,7 +30,9 @@ const computedTraitsPayloadForIdentifyCall = function ( identifiers, attributes } - ] + ], + not_append: !data.append_arrays, + platform: 'segment' } return request(API_BASE + UPSERT_ENDPOINT, { @@ -53,7 +55,9 @@ const computedTraitsPayloadForTrackCall = function ( identifiers, events } - ] + ], + not_append: !data.append_arrays, + platform: 'segment' } return request(API_BASE + UPSERT_ENDPOINT, { @@ -83,7 +87,9 @@ const computedAudiencesPayloadForIdentifyCall = function ( identifiers, attributes } - ] + ], + not_append: !data.append_arrays, + platform: 'segment' } return request(API_BASE + UPSERT_ENDPOINT, { @@ -106,7 +112,9 @@ const computedAudiencePayloadForTrackCall = function ( identifiers, events } - ] + ], + not_append: !data.append_arrays, + platform: 'segment' } return request(API_BASE + UPSERT_ENDPOINT, { @@ -130,7 +138,9 @@ const deleteAttributePartial = function (data: Payload) { } } } - ] + ], + not_append: !data.append_arrays, + platform: 'segment' } } @@ -160,12 +170,11 @@ const getIdentifiers = function (data: Payload) { // It will return the attributes for the user const getAttributes = function (data: Payload) { const computationKey = data.custom_audience_name - const attributes = { + return { custom: { segment_audience_name: [computationKey] } } - return attributes } const getTraitAttributes = function (data: Payload) { diff --git a/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/__tests__/index.test.ts index 2b313bfcbf..b498ef0168 100644 --- a/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/__tests__/index.test.ts @@ -15,6 +15,7 @@ describe('processPayload', () => { event_name: 'segment event', segment_computation_action: 'trait', custom_audience_name: 'example_audience', + append_arrays: false, traits_or_props: { example_audience: true, email: 'example@example.com' @@ -46,7 +47,9 @@ describe('processPayload', () => { } ] } - ] + ], + not_append: true, + platform: 'segment' } }) }) @@ -56,6 +59,7 @@ describe('processPayload', () => { { custom_audience_name: 'num_link_clicked_l_60_d', segment_computation_action: 'trait', + append_arrays: false, email: 'example@example.com', phone: '1234567890', anonymous_id: '123', @@ -88,7 +92,9 @@ describe('processPayload', () => { } } } - ] + ], + not_append: true, + platform: 'segment' } }) }) @@ -98,6 +104,7 @@ describe('processPayload', () => { { custom_audience_name: 'example_audience', segment_computation_action: 'trait', + append_arrays: false, email: 'example@example.com', phone: '1234567890', traits_or_props: { @@ -132,7 +139,9 @@ describe('processPayload', () => { } ] } - ] + ], + not_append: true, + platform: 'segment' } }) }) @@ -142,6 +151,7 @@ describe('processPayload', () => { { custom_audience_name: 'demo_squarkai', segment_computation_action: 'audience', + append_arrays: false, email: 'example@example.com', traits_or_props: { demo_squarkai: true, @@ -168,7 +178,9 @@ describe('processPayload', () => { } } } - ] + ], + not_append: true, + platform: 'segment' } }) }) @@ -178,6 +190,7 @@ describe('processPayload', () => { { custom_audience_name: 'demo_squarkai', segment_computation_action: 'audience', + append_arrays: false, email: 'example@example.com', event_name: 'Segment Event', traits_or_props: { @@ -211,7 +224,9 @@ describe('processPayload', () => { } ] } - ] + ], + not_append: true, + platform: 'segment' } }) }) @@ -222,6 +237,7 @@ describe('processPayload', () => { event_type: 'identify', segment_computation_action: 'audience', custom_audience_name: 'example_audience', + append_arrays: false, traits_or_props: { example_audience: false, email: 'example@example.com' @@ -248,7 +264,9 @@ describe('processPayload', () => { } } } - ] + ], + not_append: true, + platform: 'segment' } }) }) @@ -259,6 +277,7 @@ describe('processPayload', () => { event_type: 'identify', segment_computation_action: 'invalid', custom_audience_name: 'example_trait', + append_arrays: false, traits_or_props: { example_trait: 'example_value', email: 'example@example.com' @@ -281,6 +300,7 @@ describe('processPayload', () => { event_type: 'invalid', segment_computation_action: 'audience', custom_audience_name: 'invalid_event_test', + append_arrays: false, traits_or_props: { example_trait: 'example_value', email: 'example@example.com' @@ -304,6 +324,7 @@ describe('processPayload', () => { event_type: 'invalid', segment_computation_action: 'trait', custom_audience_name: 'invalid_event_test', + append_arrays: false, traits_or_props: { example_trait: 'example_value', email: 'example@example.com' diff --git a/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/generated-types.ts b/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/generated-types.ts index 0e50b35a6d..30c41d67f4 100644 --- a/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * Segment computation class used to determine if action is an 'Engage-Audience' */ segment_computation_action: string + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's email address for including/excluding from custom audience */ diff --git a/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/index.ts b/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/index.ts index 79a22df62f..fdaf8a80a3 100644 --- a/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/index.ts +++ b/packages/destination-actions/src/destinations/insider-audiences/insiderAudiences/index.ts @@ -26,6 +26,12 @@ const action: ActionDefinition = { '@path': '$.context.personas.computation_class' } }, + append_arrays: { + label: 'Append Array Fields', + type: 'boolean', + description: 'If enabled, new data for array fields will be appended to the existing values in Insider.', + default: false + }, email: { label: 'Email', description: "User's email address for including/excluding from custom audience", diff --git a/packages/destination-actions/src/destinations/insider/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/__tests__/__snapshots__/snapshot.test.ts.snap index 5487c0a5da..3776af7fdd 100644 --- a/packages/destination-actions/src/destinations/insider/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for actions-insider-cloud destination: cartViewedEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -49,9 +50,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "0xlI!lem[5]2MJvda", + "testType": "0xlI!lem[5]2MJvda", }, "uuid": "0xlI!lem[5]2MJvda", }, + "not_append": true, }, ], } @@ -59,6 +62,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: cartViewedEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -76,9 +80,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "0xlI!lem[5]2MJvda", + "testType": "0xlI!lem[5]2MJvda", }, "uuid": "0xlI!lem[5]2MJvda", }, + "not_append": true, }, ], } @@ -86,6 +92,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: checkoutEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -133,9 +140,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "i2DYKi53!byOli1^))*", + "testType": "i2DYKi53!byOli1^))*", }, "uuid": "i2DYKi53!byOli1^))*", }, + "not_append": true, }, ], } @@ -143,6 +152,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: checkoutEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -160,9 +170,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "i2DYKi53!byOli1^))*", + "testType": "i2DYKi53!byOli1^))*", }, "uuid": "i2DYKi53!byOli1^))*", }, + "not_append": true, }, ], } @@ -170,6 +182,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: orderCompletedEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -217,9 +230,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "%detmPE)QcMI3#y0Y", + "testType": "%detmPE)QcMI3#y0Y", }, "uuid": "%detmPE)QcMI3#y0Y", }, + "not_append": true, }, ], } @@ -227,6 +242,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: orderCompletedEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -244,9 +260,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "%detmPE)QcMI3#y0Y", + "testType": "%detmPE)QcMI3#y0Y", }, "uuid": "%detmPE)QcMI3#y0Y", }, + "not_append": true, }, ], } @@ -254,6 +272,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productAddedEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -301,9 +320,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "pVmlekeqp9EloEHBS", + "testType": "pVmlekeqp9EloEHBS", }, "uuid": "pVmlekeqp9EloEHBS", }, + "not_append": true, }, ], } @@ -311,6 +332,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productAddedEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -328,9 +350,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "pVmlekeqp9EloEHBS", + "testType": "pVmlekeqp9EloEHBS", }, "uuid": "pVmlekeqp9EloEHBS", }, + "not_append": true, }, ], } @@ -338,6 +362,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productListViewedEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -378,9 +403,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "SFVk3AFZB*U7Pg", + "testType": "SFVk3AFZB*U7Pg", }, "uuid": "SFVk3AFZB*U7Pg", }, + "not_append": true, }, ], } @@ -388,6 +415,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productListViewedEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -405,9 +433,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "SFVk3AFZB*U7Pg", + "testType": "SFVk3AFZB*U7Pg", }, "uuid": "SFVk3AFZB*U7Pg", }, + "not_append": true, }, ], } @@ -415,6 +445,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productRemovedEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -462,11 +493,13 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "!Ivq)L", + "testType": "!Ivq)L", }, "email": "hafogro@nonak.bm", "phone_number": "!Ivq)L", "uuid": "!Ivq)L", }, + "not_append": false, }, ], } @@ -474,6 +507,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productRemovedEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -491,9 +525,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "!Ivq)L", + "testType": "!Ivq)L", }, "uuid": "!Ivq)L", }, + "not_append": true, }, ], } @@ -501,6 +537,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productViewedEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -548,11 +585,13 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "jjiPS4iz", + "testType": "jjiPS4iz", }, "email": "dicnuzo@dik.ae", "phone_number": "jjiPS4iz", "uuid": "jjiPS4iz", }, + "not_append": false, }, ], } @@ -560,6 +599,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: productViewedEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -577,9 +617,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "jjiPS4iz", + "testType": "jjiPS4iz", }, "uuid": "jjiPS4iz", }, + "not_append": true, }, ], } @@ -587,6 +629,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: trackEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -637,9 +680,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "74Eoa(TEWXz$1Kje", + "testType": "74Eoa(TEWXz$1Kje", }, "uuid": "74Eoa(TEWXz$1Kje", }, + "not_append": true, }, ], } @@ -647,6 +692,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: trackEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -664,9 +710,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "74Eoa(TEWXz$1Kje", + "testType": "74Eoa(TEWXz$1Kje", }, "uuid": "74Eoa(TEWXz$1Kje", }, + "not_append": true, }, ], } @@ -674,6 +722,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: updateUserProfile action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -698,11 +747,13 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "CA9^h[(o", + "testType": "CA9^h[(o", }, "email": "tofzammi@paremu.ru", "phone_number": "CA9^h[(o", "uuid": "CA9^h[(o", }, + "not_append": false, }, ], } @@ -710,15 +761,18 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: updateUserProfile action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object {}, "identifiers": Object { "custom": Object { "segment_anonymous_id": "CA9^h[(o", + "testType": "CA9^h[(o", }, "uuid": "CA9^h[(o", }, + "not_append": true, }, ], } @@ -726,6 +780,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: userRegisteredEvent action - all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -761,11 +816,13 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "xt]Bf", + "testType": "xt]Bf", }, "email": "gihehena@vidrow.tc", "phone_number": "xt]Bf", "uuid": "xt]Bf", }, + "not_append": false, }, ], } @@ -773,6 +830,7 @@ Object { exports[`Testing snapshot for actions-insider-cloud destination: userRegisteredEvent action - required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -790,9 +848,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "xt]Bf", + "testType": "xt]Bf", }, "uuid": "xt]Bf", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 789c053dc6..52b79474c4 100644 --- a/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's cartViewedEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -49,9 +50,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "uh[f!Dz)ZqkDd$x", + "testType": "uh[f!Dz)ZqkDd$x", }, "uuid": "uh[f!Dz)ZqkDd$x", }, + "not_append": true, }, ], } @@ -59,6 +62,7 @@ Object { exports[`Testing snapshot for Insider's cartViewedEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -76,9 +80,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "uh[f!Dz)ZqkDd$x", + "testType": "uh[f!Dz)ZqkDd$x", }, "uuid": "uh[f!Dz)ZqkDd$x", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/index.test.ts index 8a3031e875..cd568fd6c8 100644 --- a/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/cartViewedEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.cartViewedEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert cart page view event', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -20,4 +20,24 @@ describe('Insider.cartViewedEvent', () => { const responses = await testDestination.testAction('cartViewedEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) }) + it('should insert cart page view events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'cart_page_view', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'cart_page_view', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('cartViewedEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/cartViewedEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/cartViewedEvent/generated-types.ts index 6096c3f9ff..4cd2cf977b 100644 --- a/packages/destination-actions/src/destinations/insider/cartViewedEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/cartViewedEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/cartViewedEvent/index.ts b/packages/destination-actions/src/destinations/insider/cartViewedEvent/index.ts index 14d90b0226..6b2d68113c 100644 --- a/packages/destination-actions/src/destinations/insider/cartViewedEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/cartViewedEvent/index.ts @@ -9,9 +9,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'Cart Viewed Event', @@ -20,8 +22,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, parameters: { ...cart_event_parameters }, products: { ...products }, @@ -32,6 +36,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'cart_page_view') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'cart_page_view') + }) } } diff --git a/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/__snapshots__/snapshot.test.ts.snap index aa8a52b0e4..629a5a389f 100644 --- a/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's checkoutEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -49,11 +50,13 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "Q!Q[jv1Wi&s0", + "testType": "Q!Q[jv1Wi&s0", }, "email": "dobaj@zuzpini.nc", "phone_number": "Q!Q[jv1Wi&s0", "uuid": "Q!Q[jv1Wi&s0", }, + "not_append": false, }, ], } @@ -61,6 +64,7 @@ Object { exports[`Testing snapshot for Insider's checkoutEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -78,9 +82,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "Q!Q[jv1Wi&s0", + "testType": "Q!Q[jv1Wi&s0", }, "uuid": "Q!Q[jv1Wi&s0", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/index.test.ts index dd6878d34c..21bbe72b54 100644 --- a/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/checkoutEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.checkoutEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert checkout page view event with default mapping', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,25 @@ describe('Insider.checkoutEvent', () => { const responses = await testDestination.testAction('checkoutEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should insert checkout page view events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'checkout_page_view', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'checkout_page_view', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('checkoutEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/checkoutEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/checkoutEvent/generated-types.ts index 6096c3f9ff..4cd2cf977b 100644 --- a/packages/destination-actions/src/destinations/insider/checkoutEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/checkoutEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/checkoutEvent/index.ts b/packages/destination-actions/src/destinations/insider/checkoutEvent/index.ts index 4ca7a086ea..75ed162ee2 100644 --- a/packages/destination-actions/src/destinations/insider/checkoutEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/checkoutEvent/index.ts @@ -9,9 +9,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'Checkout Event', @@ -20,8 +22,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, parameters: { ...checkout_event_parameters }, products: { ...products }, @@ -32,6 +36,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'checkout_page_view') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'checkout_page_view') + }) } } diff --git a/packages/destination-actions/src/destinations/insider/insider-helpers.ts b/packages/destination-actions/src/destinations/insider/insider-helpers.ts index 893d7f1cd7..23235dc79c 100644 --- a/packages/destination-actions/src/destinations/insider/insider-helpers.ts +++ b/packages/destination-actions/src/destinations/insider/insider-helpers.ts @@ -24,6 +24,7 @@ export interface upsertUserPayload { custom?: object } attributes: { [key: string]: never } + not_append: boolean events: insiderEvent[] } @@ -35,6 +36,13 @@ export function userProfilePayload(data: UserPayload) { } } + if (data.custom_identifiers) { + identifiers.custom = { + ...identifiers.custom, + ...data.custom_identifiers + } + } + if (data.email_as_identifier) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -67,9 +75,11 @@ export function userProfilePayload(data: UserPayload) { whatsapp_optin: data.whatsappOptin, language: data.language?.replace('-', '_'), custom: data.custom - } + }, + not_append: !data.append_arrays } - ] + ], + platform: 'segment' } } @@ -134,6 +144,13 @@ export function sendTrackEvent( } } + if (data.custom_identifiers) { + identifiers.custom = { + ...identifiers.custom, + ...data.custom_identifiers + } + } + if (data.email_as_identifier && data?.attributes?.email) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -251,5 +268,267 @@ export function sendTrackEvent( payload.events.push(event) } - return { users: [payload] } + payload.not_append = !data.append_arrays + + return { users: [payload], platform: 'segment' } +} + +export function bulkUserProfilePayload(data: UserPayload[]) { + const batchPayload = data.map((userPayload) => { + const not_append = !userPayload.append_arrays + const identifiers = { + uuid: userPayload.uuid, + custom: { + segment_anonymous_id: userPayload.segment_anonymous_id, + ...userPayload.custom_identifiers + } + } + + if (userPayload.email_as_identifier) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + identifiers['email'] = userPayload.email + } + + if (userPayload.phone_number_as_identifier) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + identifiers['phone_number'] = userPayload.phone + } + + const attributes = { + age: userPayload.age, + birthday: userPayload.birthday, + email: userPayload.email, + name: userPayload.firstName, + gender: userPayload.gender, + surname: userPayload.lastName, + phone_number: userPayload.phone, + city: userPayload.city, + country: userPayload.country, + gdpr_optin: userPayload.gdprOptin, + email_optin: userPayload.emailOptin, + sms_optin: userPayload.smsOptin, + whatsapp_optin: userPayload.whatsappOptin, + language: userPayload.language?.replace('-', '_'), + custom: userPayload.custom + } + + Object.keys(attributes).forEach((key) => { + if (attributes[key as keyof typeof attributes] === undefined) { + delete attributes[key as keyof typeof attributes] + } + }) + + Object.keys(identifiers).forEach((key) => { + if (identifiers[key as keyof typeof identifiers] === undefined) { + delete identifiers[key as keyof typeof identifiers] + } + }) + + return { identifiers, attributes, not_append } + }) + + return { users: batchPayload, platform: 'segment' } +} + +export function sendBulkTrackEvents( + dataArray: + | TrackEventPayload[] + | CartViewedEventPayload[] + | CheckoutEventPayload[] + | OrderCompletedEventPayload[] + | ProductAddedEventPayload[] + | ProductListViewedEventPayload[] + | productRemovedEventPayload[] + | productViewedEventPayload[] + | userRegisteredEventPayload[], + event_name?: string +) { + const bulkPayload: upsertUserPayload[] = [] + + dataArray.forEach((data) => { + const addEventParameters = function ( + event: insiderEvent, + data: + | { + url?: string + product_id?: string + taxonomy?: string + name?: string + currency?: string + variant_id?: number + unit_sale_price?: number + unit_price?: number + quantity?: number + product_image_url?: string + event_group_id?: string + referrer?: string + user_agent?: string + [p: string]: unknown + } + | undefined, + parameter: string + ) { + parameter = parameter.toString().toLowerCase().trim().split(' ').join('_') + + if (parameter === 'taxonomy') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + event.event_params[parameter] = [data[parameter]] + } else if (defaultEvents.indexOf(parameter) > -1) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + event.event_params[parameter] = data[parameter] + } else if (data) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + event.event_params.custom[parameter] = data[parameter] + } + + return event + } + + const identifiers = { + uuid: data.uuid, + custom: { + segment_anonymous_id: data.segment_anonymous_id + } + } + + if (data.custom_identifiers) { + identifiers.custom = { + ...identifiers.custom, + ...data.custom_identifiers + } + } + + if (data.email_as_identifier && data?.attributes?.email) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + identifiers['email'] = data?.attributes?.email + } + + if (data.phone_number_as_identifier && data?.attributes?.phone) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + identifiers['phone_number'] = data?.attributes?.phone + } + + const payload: upsertUserPayload = { + identifiers, + attributes: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + custom: {} + }, + not_append: true, + events: [] + } + + const defaultAttributes = [ + 'email', + 'phone', + 'age', + 'birthday', + 'name', + 'gender', + 'surname', + 'city', + 'country', + 'app_version', + 'idfa', + 'model', + 'last_ip', + 'carrier', + 'os_version', + 'platform', + 'timezone', + 'locale' + ] + const defaultEvents = [ + 'campaign_id', + 'campaign_name', + 'url', + 'product_id', + 'user_agent', + 'taxonomy', + 'name', + 'variant_id', + 'unit_sale_price', + 'unit_price', + 'quantity', + 'product_image_url', + 'event_group_id', + 'referrer', + 'currency' + ] + + for (const key of Object.keys(data.attributes || {})) { + const attributeName: string = key.toString().toLowerCase().trim().split(' ').join('_').toString() + + if (attributeName === 'locale' && data.attributes) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + payload.attributes[attributeName as keyof typeof payload.attributes] = data.attributes[attributeName] + ?.split('-') + .join('_') + } else if (defaultAttributes.indexOf(attributeName) > -1 && data.attributes) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + payload.attributes[attributeName as keyof typeof payload.attributes] = data.attributes[attributeName] + } else if (data.attributes) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + payload.attributes.custom[attributeName as keyof typeof payload.attributes.custom] = + data.attributes[attributeName] + } + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const eventName = event_name || data.event_name.toString().toLowerCase().trim().split(' ').join('_').toString() + + let event: insiderEvent = { + event_name: eventName, + timestamp: data.timestamp.toString(), + event_params: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + custom: {} + } + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + for (const key of Object.keys(data.parameters || {})) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + event = addEventParameters(event, data.parameters, key) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (data.products) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + for (const product of data.products) { + let productEvent = event + + for (const key of Object.keys(product || {})) { + productEvent = addEventParameters(productEvent, product, key) + } + + payload.events.push(productEvent) + } + } else { + payload.events.push(event) + } + + payload.not_append = !data.append_arrays + + bulkPayload.push(payload) + }) + + return { users: bulkPayload, platform: 'segment' } } diff --git a/packages/destination-actions/src/destinations/insider/insider-properties.ts b/packages/destination-actions/src/destinations/insider/insider-properties.ts index 298ff34203..bf1c1e3ecd 100644 --- a/packages/destination-actions/src/destinations/insider/insider-properties.ts +++ b/packages/destination-actions/src/destinations/insider/insider-properties.ts @@ -133,6 +133,13 @@ export const phone_number_as_identifier: InputField = { default: true } +export const append_arrays: InputField = { + label: 'Append Array Fields', + type: 'boolean', + description: 'If enabled, new data for array fields will be appended to the existing values in Insider.', + default: false +} + export const uuid: InputField = { label: 'UUID', type: 'string', @@ -149,6 +156,14 @@ export const segment_anonymous_id: InputField = { default: { '@path': '$.anonymousId' } } +export const custom_identifiers: InputField = { + label: 'Custom Identifiers', + type: 'object', + description: 'You can select your custom identifiers for the event.', + default: undefined, + additionalProperties: true +} + export const event_name: InputField = { label: 'Event Name', type: 'string', diff --git a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/__snapshots__/snapshot.test.ts.snap index e5d1d54b61..fdd5fd5d96 100644 --- a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's orderCompletedEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -49,11 +50,13 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "^nSY[JM", + "testType": "^nSY[JM", }, "email": "ciboba@duzehoc.lt", "phone_number": "^nSY[JM", "uuid": "^nSY[JM", }, + "not_append": false, }, ], } @@ -61,6 +64,7 @@ Object { exports[`Testing snapshot for Insider's orderCompletedEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -78,9 +82,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "^nSY[JM", + "testType": "^nSY[JM", }, "uuid": "^nSY[JM", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/index.test.ts index 14590d253c..7e517b7d3c 100644 --- a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.orderCompletedEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert event for confirmation page view', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,25 @@ describe('Insider.orderCompletedEvent', () => { const responses = await testDestination.testAction('orderCompletedEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should insert confirmation page view events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'confirmation_page_view', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'confirmation_page_view', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('orderCompletedEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/generated-types.ts index 6096c3f9ff..4cd2cf977b 100644 --- a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/index.ts b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/index.ts index efd73a0989..48fcbe90b1 100644 --- a/packages/destination-actions/src/destinations/insider/orderCompletedEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/orderCompletedEvent/index.ts @@ -9,9 +9,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'Order Completed Event', @@ -20,8 +22,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, parameters: { ...order_event_parameters }, products: { ...products }, @@ -32,6 +36,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'confirmation_page_view') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'confirmation_page_view') + }) } } diff --git a/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/__snapshots__/snapshot.test.ts.snap index fbdd70f8b4..a9851d91da 100644 --- a/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's productAddedEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -49,9 +50,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "G!)xRN2DwZB33yYCIUOK", + "testType": "G!)xRN2DwZB33yYCIUOK", }, "uuid": "G!)xRN2DwZB33yYCIUOK", }, + "not_append": true, }, ], } @@ -59,6 +62,7 @@ Object { exports[`Testing snapshot for Insider's productAddedEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -76,9 +80,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "G!)xRN2DwZB33yYCIUOK", + "testType": "G!)xRN2DwZB33yYCIUOK", }, "uuid": "G!)xRN2DwZB33yYCIUOK", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/index.test.ts index e8365333c2..d25573a0eb 100644 --- a/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/productAddedEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.productAddedEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert add to cart events', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,25 @@ describe('Insider.productAddedEvent', () => { const responses = await testDestination.testAction('productAddedEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should insert add to cart events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'item_added_to_cart', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'item_added_to_cart', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('productAddedEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/productAddedEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/productAddedEvent/generated-types.ts index 1337dee57d..b76a6a56e0 100644 --- a/packages/destination-actions/src/destinations/insider/productAddedEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/productAddedEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/productAddedEvent/index.ts b/packages/destination-actions/src/destinations/insider/productAddedEvent/index.ts index 1f01b1e517..59e7c4652a 100644 --- a/packages/destination-actions/src/destinations/insider/productAddedEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/productAddedEvent/index.ts @@ -8,9 +8,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'Product Added Event', @@ -19,8 +21,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, parameters: { ...getEventParameteres([ @@ -43,6 +47,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'item_added_to_cart') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'item_added_to_cart') + }) } } diff --git a/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 1e842e2ffd..754f12e3c2 100644 --- a/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's productListViewedEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -42,9 +43,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "XPOGmR%$*4[TgwzNYN", + "testType": "XPOGmR%$*4[TgwzNYN", }, "uuid": "XPOGmR%$*4[TgwzNYN", }, + "not_append": true, }, ], } @@ -52,6 +55,7 @@ Object { exports[`Testing snapshot for Insider's productListViewedEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -69,9 +73,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "XPOGmR%$*4[TgwzNYN", + "testType": "XPOGmR%$*4[TgwzNYN", }, "uuid": "XPOGmR%$*4[TgwzNYN", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/index.test.ts index 1c92c8345d..a14062162c 100644 --- a/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/productListViewedEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.productListViewedEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert listing page view event', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,25 @@ describe('Insider.productListViewedEvent', () => { const responses = await testDestination.testAction('productListViewedEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should insert confirmation page view events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'listing_page_view', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'listing_page_view', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('productListViewedEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/productListViewedEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/productListViewedEvent/generated-types.ts index 989efc83c3..9c9bcec89c 100644 --- a/packages/destination-actions/src/destinations/insider/productListViewedEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/productListViewedEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/productListViewedEvent/index.ts b/packages/destination-actions/src/destinations/insider/productListViewedEvent/index.ts index 2c2b29fc7b..37a335b3a3 100644 --- a/packages/destination-actions/src/destinations/insider/productListViewedEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/productListViewedEvent/index.ts @@ -8,9 +8,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'Product List Viewed Event', @@ -19,8 +21,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, parameters: { ...getEventParameteres(['taxonomy', 'url', 'referrer']) }, attributes: { ...user_attributes } @@ -30,6 +34,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'listing_page_view') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'listing_page_view') + }) } } diff --git a/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/__snapshots__/snapshot.test.ts.snap index c4f6445ad3..16f8a78762 100644 --- a/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's productRemovedEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -49,9 +50,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "Lu!Xj33sF(%SQN", + "testType": "Lu!Xj33sF(%SQN", }, "uuid": "Lu!Xj33sF(%SQN", }, + "not_append": true, }, ], } @@ -59,6 +62,7 @@ Object { exports[`Testing snapshot for Insider's productRemovedEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -76,9 +80,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "Lu!Xj33sF(%SQN", + "testType": "Lu!Xj33sF(%SQN", }, "uuid": "Lu!Xj33sF(%SQN", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/index.test.ts index 8847ae99c3..bdec0b6ea7 100644 --- a/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/productRemovedEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.productRemovedEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert product remove event', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,25 @@ describe('Insider.productRemovedEvent', () => { const responses = await testDestination.testAction('productRemovedEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should insert product remove events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'item_removed_from_cart', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'item_removed_from_cart', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('productRemovedEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/productRemovedEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/productRemovedEvent/generated-types.ts index 1337dee57d..b76a6a56e0 100644 --- a/packages/destination-actions/src/destinations/insider/productRemovedEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/productRemovedEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/productRemovedEvent/index.ts b/packages/destination-actions/src/destinations/insider/productRemovedEvent/index.ts index 64aa8ee2b0..7e8708a546 100644 --- a/packages/destination-actions/src/destinations/insider/productRemovedEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/productRemovedEvent/index.ts @@ -8,9 +8,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'Product Removed Event', @@ -19,8 +21,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, parameters: { ...getEventParameteres([ @@ -43,6 +47,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'item_removed_from_cart') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'item_removed_from_cart') + }) } } diff --git a/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap index de1f8b411b..946ef9b770 100644 --- a/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's productViewedEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -49,9 +50,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "y$Fq1#L9R1N]w7PGv", + "testType": "y$Fq1#L9R1N]w7PGv", }, "uuid": "y$Fq1#L9R1N]w7PGv", }, + "not_append": true, }, ], } @@ -59,6 +62,7 @@ Object { exports[`Testing snapshot for Insider's productViewedEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -76,9 +80,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "y$Fq1#L9R1N]w7PGv", + "testType": "y$Fq1#L9R1N]w7PGv", }, "uuid": "y$Fq1#L9R1N]w7PGv", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/index.test.ts index 15fe058bb4..91a6a9ef85 100644 --- a/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/productViewedEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.productViewedEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert product detail page view event', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,25 @@ describe('Insider.productViewedEvent', () => { const responses = await testDestination.testAction('productViewedEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should insert product detail page view events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'product_detail_page_view', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'product_detail_page_view', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('productViewedEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/productViewedEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/productViewedEvent/generated-types.ts index 1337dee57d..b76a6a56e0 100644 --- a/packages/destination-actions/src/destinations/insider/productViewedEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/productViewedEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/productViewedEvent/index.ts b/packages/destination-actions/src/destinations/insider/productViewedEvent/index.ts index ccf8ac28a8..97f419040a 100644 --- a/packages/destination-actions/src/destinations/insider/productViewedEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/productViewedEvent/index.ts @@ -8,9 +8,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'Product Viewed Event', @@ -19,8 +21,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, parameters: { ...getEventParameteres([ @@ -43,6 +47,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'product_detail_page_view') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'product_detail_page_view') + }) } } diff --git a/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap index a323d86fa1..f805fbfd6f 100644 --- a/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Testing snapshot for Insider's trackEvent destination action: all fields 1`] = `"{\\"users\\":[{\\"identifiers\\":{\\"uuid\\":\\"Ef*GhBp7kEUO\\",\\"custom\\":{\\"segment_anonymous_id\\":\\"Ef*GhBp7kEUO\\"},\\"email\\":\\"tuoc@giciwco.net\\",\\"phone_number\\":\\"Ef*GhBp7kEUO\\"},\\"attributes\\":{\\"custom\\":{},\\"email\\":\\"tuoc@giciwco.net\\",\\"phone\\":\\"Ef*GhBp7kEUO\\",\\"age\\":-10845372872130.56,\\"birthday\\":\\"Ef*GhBp7kEUO\\",\\"name\\":\\"Ef*GhBp7kEUO\\",\\"gender\\":\\"Ef*GhBp7kEUO\\",\\"surname\\":\\"Ef*GhBp7kEUO\\",\\"app_version\\":\\"Ef*GhBp7kEUO\\",\\"idfa\\":\\"Ef*GhBp7kEUO\\",\\"model\\":\\"Ef*GhBp7kEUO\\",\\"last_ip\\":\\"Ef*GhBp7kEUO\\",\\"city\\":\\"Ef*GhBp7kEUO\\",\\"country\\":\\"Ef*GhBp7kEUO\\",\\"carrier\\":\\"Ef*GhBp7kEUO\\",\\"os_version\\":\\"Ef*GhBp7kEUO\\",\\"platform\\":\\"Ef*GhBp7kEUO\\",\\"timezone\\":\\"Ef*GhBp7kEUO\\",\\"locale\\":\\"Ef*GhBp7kEUO\\"},\\"events\\":[{\\"event_name\\":\\"ef*ghbp7keuo\\",\\"timestamp\\":\\"2021-02-01T00:00:00.000Z\\",\\"event_params\\":{\\"custom\\":{},\\"url\\":\\"Ef*GhBp7kEUO\\",\\"currency\\":\\"LTL\\",\\"product_id\\":\\"Ef*GhBp7kEUO\\",\\"taxonomy\\":[\\"Ef*GhBp7kEUO\\"],\\"name\\":\\"Ef*GhBp7kEUO\\",\\"variant_id\\":-10845372872130.56,\\"unit_sale_price\\":-10845372872130.56,\\"unit_price\\":-10845372872130.56,\\"quantity\\":-1084537287213056,\\"product_image_url\\":\\"Ef*GhBp7kEUO\\",\\"event_group_id\\":\\"Ef*GhBp7kEUO\\",\\"referrer\\":\\"Ef*GhBp7kEUO\\",\\"user_agent\\":\\"Ef*GhBp7kEUO\\"}}]}]}"`; +exports[`Testing snapshot for Insider's trackEvent destination action: all fields 1`] = `"{\\"users\\":[{\\"identifiers\\":{\\"uuid\\":\\"Ef*GhBp7kEUO\\",\\"custom\\":{\\"segment_anonymous_id\\":\\"Ef*GhBp7kEUO\\",\\"testType\\":\\"Ef*GhBp7kEUO\\"},\\"email\\":\\"tuoc@giciwco.net\\",\\"phone_number\\":\\"Ef*GhBp7kEUO\\"},\\"attributes\\":{\\"custom\\":{},\\"email\\":\\"tuoc@giciwco.net\\",\\"phone\\":\\"Ef*GhBp7kEUO\\",\\"age\\":-10845372872130.56,\\"birthday\\":\\"Ef*GhBp7kEUO\\",\\"name\\":\\"Ef*GhBp7kEUO\\",\\"gender\\":\\"Ef*GhBp7kEUO\\",\\"surname\\":\\"Ef*GhBp7kEUO\\",\\"app_version\\":\\"Ef*GhBp7kEUO\\",\\"idfa\\":\\"Ef*GhBp7kEUO\\",\\"model\\":\\"Ef*GhBp7kEUO\\",\\"last_ip\\":\\"Ef*GhBp7kEUO\\",\\"city\\":\\"Ef*GhBp7kEUO\\",\\"country\\":\\"Ef*GhBp7kEUO\\",\\"carrier\\":\\"Ef*GhBp7kEUO\\",\\"os_version\\":\\"Ef*GhBp7kEUO\\",\\"platform\\":\\"Ef*GhBp7kEUO\\",\\"timezone\\":\\"Ef*GhBp7kEUO\\",\\"locale\\":\\"Ef*GhBp7kEUO\\"},\\"events\\":[{\\"event_name\\":\\"ef*ghbp7keuo\\",\\"timestamp\\":\\"2021-02-01T00:00:00.000Z\\",\\"event_params\\":{\\"custom\\":{},\\"url\\":\\"Ef*GhBp7kEUO\\",\\"currency\\":\\"LTL\\",\\"product_id\\":\\"Ef*GhBp7kEUO\\",\\"taxonomy\\":[\\"Ef*GhBp7kEUO\\"],\\"name\\":\\"Ef*GhBp7kEUO\\",\\"variant_id\\":-10845372872130.56,\\"unit_sale_price\\":-10845372872130.56,\\"unit_price\\":-10845372872130.56,\\"quantity\\":-1084537287213056,\\"product_image_url\\":\\"Ef*GhBp7kEUO\\",\\"event_group_id\\":\\"Ef*GhBp7kEUO\\",\\"referrer\\":\\"Ef*GhBp7kEUO\\",\\"user_agent\\":\\"Ef*GhBp7kEUO\\"}}],\\"not_append\\":false}],\\"platform\\":\\"segment\\"}"`; -exports[`Testing snapshot for Insider's trackEvent destination action: required fields 1`] = `"{\\"users\\":[{\\"identifiers\\":{\\"uuid\\":\\"Ef*GhBp7kEUO\\",\\"custom\\":{\\"segment_anonymous_id\\":\\"Ef*GhBp7kEUO\\"}},\\"attributes\\":{\\"custom\\":{}},\\"events\\":[{\\"event_name\\":\\"ef*ghbp7keuo\\",\\"timestamp\\":\\"2021-02-01T00:00:00.000Z\\",\\"event_params\\":{\\"custom\\":{}}}]}]}"`; +exports[`Testing snapshot for Insider's trackEvent destination action: required fields 1`] = `"{\\"users\\":[{\\"identifiers\\":{\\"uuid\\":\\"Ef*GhBp7kEUO\\",\\"custom\\":{\\"segment_anonymous_id\\":\\"Ef*GhBp7kEUO\\",\\"testType\\":\\"Ef*GhBp7kEUO\\"}},\\"attributes\\":{\\"custom\\":{}},\\"events\\":[{\\"event_name\\":\\"ef*ghbp7keuo\\",\\"timestamp\\":\\"2021-02-01T00:00:00.000Z\\",\\"event_params\\":{\\"custom\\":{}}}],\\"not_append\\":true}],\\"platform\\":\\"segment\\"}"`; exports[`Testing snapshot for Insider's trackEvent destination action: required fields 2`] = ` Headers { diff --git a/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/index.test.ts index 92cf1e2c8f..4e91221eb8 100644 --- a/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/trackEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.trackEvent', () => { - it('should update user event with default mapping', async () => { + it('should insert test track event', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,25 @@ describe('Insider.trackEvent', () => { const responses = await testDestination.testAction('trackEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should insert test track events in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, { success: 2 }) + + const events = [ + createTestEvent({ + timestamp, + event: 'Test Event', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'Test Event', + anonymousId: 'test2' + }) + ] + + const request = await testDestination.testBatchAction('trackEvent', { events, useDefaultMappings }) + + expect(request[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/trackEvent/generated-types.ts index 6aa1f838a2..f7850a2915 100644 --- a/packages/destination-actions/src/destinations/insider/trackEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/trackEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * The event name */ diff --git a/packages/destination-actions/src/destinations/insider/trackEvent/index.ts b/packages/destination-actions/src/destinations/insider/trackEvent/index.ts index 84950f04d0..f5ba370afa 100644 --- a/packages/destination-actions/src/destinations/insider/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/trackEvent/index.ts @@ -1,7 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { API_BASE, UPSERT_ENDPOINT, sendTrackEvent } from '../insider-helpers' +import { API_BASE, UPSERT_ENDPOINT, sendTrackEvent, sendBulkTrackEvents } from '../insider-helpers' import { email_as_identifier, event_name, @@ -11,7 +11,9 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' const action: ActionDefinition = { @@ -21,8 +23,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, event_name: { ...event_name }, timestamp: { ...timestamp }, parameters: { ...getEventParameteres([]) }, @@ -37,6 +41,12 @@ const action: ActionDefinition = { data.payload.event_name.toString().toLowerCase().trim().split(' ').join('_').toString() ) }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload) + }) } } diff --git a/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/__snapshots__/snapshot.test.ts.snap index 44cf2363eb..d70f29f557 100644 --- a/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider Cloud Mode (Actions)'s updateUserProfile destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -26,11 +27,13 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "I[VT4ujd%6E", + "testType": "I[VT4ujd%6E", }, "email": "hig@mirhag.ws", "phone_number": "I[VT4ujd%6E", "uuid": "I[VT4ujd%6E", }, + "not_append": false, }, ], } @@ -38,15 +41,18 @@ Object { exports[`Testing snapshot for Insider Cloud Mode (Actions)'s updateUserProfile destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object {}, "identifiers": Object { "custom": Object { "segment_anonymous_id": "I[VT4ujd%6E", + "testType": "I[VT4ujd%6E", }, "uuid": "I[VT4ujd%6E", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/index.test.ts index 961b32fefc..09b3dfaf35 100644 --- a/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/updateUserProfile/__tests__/index.test.ts @@ -3,6 +3,7 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' const testDestination = createTestIntegration(Destination) +const useDefaultMappings = true describe('Insider.updateUserProfile', () => { it('should update user profile with default mapping', async () => { @@ -12,7 +13,22 @@ describe('Insider.updateUserProfile', () => { event: 'Identify' }) - const responses = await testDestination.testAction('updateUserProfile', { event }) + const responses = await testDestination.testAction('updateUserProfile', { event, useDefaultMappings }) + expect(responses[0].status).toBe(200) + }) + it('should update user profile with default mapping in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) + + const events = [ + createTestEvent({ + event: 'Identify' + }), + createTestEvent({ + event: 'Identify' + }) + ] + + const responses = await testDestination.testBatchAction('updateUserProfile', { events, useDefaultMappings }) expect(responses[0].status).toBe(200) }) }) diff --git a/packages/destination-actions/src/destinations/insider/updateUserProfile/generated-types.ts b/packages/destination-actions/src/destinations/insider/updateUserProfile/generated-types.ts index d1fe96e11a..cfd8e86880 100644 --- a/packages/destination-actions/src/destinations/insider/updateUserProfile/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/updateUserProfile/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * Age of a user. */ @@ -45,6 +49,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select you custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * City */ diff --git a/packages/destination-actions/src/destinations/insider/updateUserProfile/index.ts b/packages/destination-actions/src/destinations/insider/updateUserProfile/index.ts index 20da9aba12..b495989d57 100644 --- a/packages/destination-actions/src/destinations/insider/updateUserProfile/index.ts +++ b/packages/destination-actions/src/destinations/insider/updateUserProfile/index.ts @@ -1,7 +1,8 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { userProfilePayload, API_BASE, UPSERT_ENDPOINT } from '../insider-helpers' +import { userProfilePayload, API_BASE, UPSERT_ENDPOINT, bulkUserProfilePayload } from '../insider-helpers' +import { append_arrays } from '../insider-properties' const action: ActionDefinition = { title: 'Create or Update a User Profile', @@ -20,6 +21,7 @@ const action: ActionDefinition = { description: 'If true, Phone Number will be sent as identifier to Insider', default: true }, + append_arrays: { ...append_arrays }, age: { label: 'Age', type: 'number', @@ -94,6 +96,12 @@ const action: ActionDefinition = { '@path': '$.anonymousId' } }, + custom_identifiers: { + label: 'Custom Identifiers', + type: 'object', + description: 'You can select you custom identifiers for the event.', + default: undefined + }, city: { label: 'City', type: 'string', @@ -150,6 +158,12 @@ const action: ActionDefinition = { method: 'post', json: userProfilePayload(data.payload) }) + }, + performBatch: (request, { payload }) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: bulkUserProfilePayload(payload) + }) } } diff --git a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/__snapshots__/snapshot.test.ts.snap index fbc4aa9dce..aff1c72e24 100644 --- a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,6 +2,7 @@ exports[`Testing snapshot for Insider's userRegisteredEvent destination action: all fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -37,9 +38,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "9yI*!GTx(Wx$F9", + "testType": "9yI*!GTx(Wx$F9", }, "uuid": "9yI*!GTx(Wx$F9", }, + "not_append": true, }, ], } @@ -47,6 +50,7 @@ Object { exports[`Testing snapshot for Insider's userRegisteredEvent destination action: required fields 1`] = ` Object { + "platform": "segment", "users": Array [ Object { "attributes": Object { @@ -64,9 +68,11 @@ Object { "identifiers": Object { "custom": Object { "segment_anonymous_id": "9yI*!GTx(Wx$F9", + "testType": "9yI*!GTx(Wx$F9", }, "uuid": "9yI*!GTx(Wx$F9", }, + "not_append": true, }, ], } diff --git a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/index.test.ts index 3616af0578..2c069f3f8e 100644 --- a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/__tests__/index.test.ts @@ -8,7 +8,7 @@ const timestamp = '2021-08-17T15:21:15.449Z' const useDefaultMappings = true describe('Insider.userRegisteredEvent', () => { - it('should update user event with default mapping', async () => { + it('should update register user with default mapping', async () => { nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) const event = createTestEvent({ @@ -19,5 +19,24 @@ describe('Insider.userRegisteredEvent', () => { const responses = await testDestination.testAction('userRegisteredEvent', { event, useDefaultMappings }) expect(responses[0].status).toBe(200) - }) + }), + it('should update register user with default mapping in batch', async () => { + nock('https://unification.useinsider.com/api').post('/user/v1/upsert').reply(200, {}) + + const events = [ + createTestEvent({ + timestamp, + event: 'sign_up_confirmation', + anonymousId: 'test' + }), + createTestEvent({ + timestamp, + event: 'sign_up_confirmation', + anonymousId: 'test2' + }) + ] + + const responses = await testDestination.testBatchAction('userRegisteredEvent', { events, useDefaultMappings }) + expect(responses[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/generated-types.ts b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/generated-types.ts index 85e5642d23..9990f46e61 100644 --- a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * If true, Phone Number will be sent as identifier to Insider */ phone_number_as_identifier?: boolean + /** + * If enabled, new data for array fields will be appended to the existing values in Insider. + */ + append_arrays?: boolean /** * User's unique identifier. The UUID string is used as identifier when sending data to Insider. UUID is required if the Anonymous Id field is empty. */ @@ -17,6 +21,12 @@ export interface Payload { * An Anonymous Identifier. The Anonymous Id string is used as identifier when sending data to Insider. Anonymous Id is required if the UUID field is empty. */ segment_anonymous_id?: string + /** + * You can select your custom identifiers for the event. + */ + custom_identifiers?: { + [k: string]: unknown + } /** * When the event occurred */ diff --git a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/index.ts b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/index.ts index 9f12d16d0b..342ae1116c 100644 --- a/packages/destination-actions/src/destinations/insider/userRegisteredEvent/index.ts +++ b/packages/destination-actions/src/destinations/insider/userRegisteredEvent/index.ts @@ -7,9 +7,11 @@ import { segment_anonymous_id, timestamp, user_attributes, - uuid + uuid, + append_arrays, + custom_identifiers } from '../insider-properties' -import { API_BASE, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' +import { API_BASE, sendBulkTrackEvents, sendTrackEvent, UPSERT_ENDPOINT } from '../insider-helpers' const action: ActionDefinition = { title: 'User Registered Event', @@ -18,8 +20,10 @@ const action: ActionDefinition = { fields: { email_as_identifier: { ...email_as_identifier }, phone_number_as_identifier: { ...phone_number_as_identifier }, + append_arrays: { ...append_arrays }, uuid: { ...uuid }, segment_anonymous_id: { ...segment_anonymous_id }, + custom_identifiers: { ...custom_identifiers }, timestamp: { ...timestamp }, attributes: { ...user_attributes } }, @@ -28,6 +32,12 @@ const action: ActionDefinition = { method: 'post', json: sendTrackEvent(data.payload, 'sign_up_confirmation') }) + }, + performBatch: (request, data) => { + return request(`${API_BASE}${UPSERT_ENDPOINT}`, { + method: 'post', + json: sendBulkTrackEvents(data.payload, 'sign_up_confirmation') + }) } } diff --git a/packages/destination-actions/src/destinations/iterable/index.ts b/packages/destination-actions/src/destinations/iterable/index.ts index 408e7238e8..b8b72ab806 100644 --- a/packages/destination-actions/src/destinations/iterable/index.ts +++ b/packages/destination-actions/src/destinations/iterable/index.ts @@ -148,6 +148,18 @@ const destination: DestinationDefinition = { }, type: 'specificEvent', eventSlug: 'warehouse_audience_membership_changed_identify' + }, + { + name: 'Journeys Step Transition Track', + partnerAction: 'trackEvent', + mapping: { + ...defaultValues(trackEvent.fields), + dataFields: { + '@path': '$.properties' + } + }, + type: 'specificEvent', + eventSlug: 'journeys_step_entered_track' } ] } diff --git a/packages/destination-actions/src/destinations/iterable/shared-fields.ts b/packages/destination-actions/src/destinations/iterable/shared-fields.ts index 0fd7b7bf88..50747fc132 100644 --- a/packages/destination-actions/src/destinations/iterable/shared-fields.ts +++ b/packages/destination-actions/src/destinations/iterable/shared-fields.ts @@ -38,6 +38,7 @@ export const USER_PHONE_NUMBER_FIELD: InputField = { label: 'User Phone Number', description: 'User phone number. Must be a valid phone number including country code. e.g. +14158675309', type: 'string', + allowNull: true, required: false, default: { '@path': '$.traits.phone' } } diff --git a/packages/destination-actions/src/destinations/iterable/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/iterable/trackEvent/__tests__/index.test.ts index 451e0aeb91..06b6a4193b 100644 --- a/packages/destination-actions/src/destinations/iterable/trackEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/iterable/trackEvent/__tests__/index.test.ts @@ -121,4 +121,57 @@ describe('Iterable.trackEvent', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) }) + + it('should success with mapping of preset and Journey Step Entered event(presets)', async () => { + const event = createTestEvent({ + type: 'track', + event: 'Journey Step Entered', + properties: { + journey_metadata: { + journey_id: 'test-journey-id', + journey_name: 'test-journey-name', + step_id: 'test-step-id', + step_name: 'test-step-name' + }, + journey_context: { + appointment_booked: { + type: 'track', + event: 'Appointment Booked', + timestamp: '2021-09-01T00:00:00.000Z', + properties: { + appointment_id: 'test-appointment-id', + appointment_date: '2021-09-01T00:00:00.000Z', + appointment_type: 'test-appointment-type' + } + }, + appointment_confirmed: { + type: 'track', + event: 'Appointment Confirmed', + timestamp: '2021-09-01T00:00:00.000Z', + properties: { + appointment_id: 'test-appointment-id', + appointment_date: '2021-09-01T00:00:00.000Z', + appointment_type: 'test-appointment-type' + } + } + } + } + }) + + nock('https://api.iterable.com/api').post('/events/track').reply(200, {}) + + const responses = await testDestination.testAction('trackEvent', { + event, + // Using the mapping of presets with event type 'track' + mapping: { + dataFields: { + '@path': '$.properties' + } + }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) }) diff --git a/packages/destination-actions/src/destinations/iterable/trackPurchase/generated-types.ts b/packages/destination-actions/src/destinations/iterable/trackPurchase/generated-types.ts index b94dc566df..6d5328f178 100644 --- a/packages/destination-actions/src/destinations/iterable/trackPurchase/generated-types.ts +++ b/packages/destination-actions/src/destinations/iterable/trackPurchase/generated-types.ts @@ -30,7 +30,7 @@ export interface Payload { /** * User phone number. Must be a valid phone number including country code. e.g. +14158675309 */ - phoneNumber?: string + phoneNumber?: string | null } /** * Additional event properties. diff --git a/packages/destination-actions/src/destinations/iterable/updateCart/generated-types.ts b/packages/destination-actions/src/destinations/iterable/updateCart/generated-types.ts index 10fcfd1260..7f0c7f0b6f 100644 --- a/packages/destination-actions/src/destinations/iterable/updateCart/generated-types.ts +++ b/packages/destination-actions/src/destinations/iterable/updateCart/generated-types.ts @@ -26,7 +26,7 @@ export interface Payload { /** * User phone number. Must be a valid phone number including country code. e.g. +14158675309 */ - phoneNumber?: string + phoneNumber?: string | null } /** * Individual items in the cart. Each item must contain `id`, `name`, `price`, and `quantity`. Extra values are added to dataFields. diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts index b3f668269e..8fa0f3e87e 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/__tests__/index.test.ts @@ -84,4 +84,36 @@ describe('Iterable.updateUser', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) }) + + it('should allow passing null values for phoneNumber', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'user1234', + traits: { + phone: null, + trait1: null + } + }) + + nock('https://api.iterable.com/api').post('/users/update').reply(200, {}) + + const responses = await testDestination.testAction('updateUser', { + event, + mapping: { + dataFields: { + '@path': '$.traits' + } + }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].options.json).toMatchObject({ + userId: 'user1234', + dataFields: { + phoneNumber: null, + trait1: null + } + }) + }) }) diff --git a/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts b/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts index 42c1e1a2dc..9f044d92cd 100644 --- a/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/iterable/updateUser/generated-types.ts @@ -18,7 +18,7 @@ export interface Payload { /** * User phone number. Must be a valid phone number including country code. e.g. +14158675309 */ - phoneNumber?: string + phoneNumber?: string | null /** * If you'd like to merge (rather than overwrite) a user profile's top-level objects with the values provided for them in the request body, set mergeNestedObjects to true. */ diff --git a/packages/destination-actions/src/destinations/kafka/generated-types.ts b/packages/destination-actions/src/destinations/kafka/generated-types.ts new file mode 100644 index 0000000000..bb57b46af5 --- /dev/null +++ b/packages/destination-actions/src/destinations/kafka/generated-types.ts @@ -0,0 +1,56 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The client ID for your Kafka instance. Defaults to 'segment-actions-kafka-producer'. + */ + clientId: string + /** + * The brokers for your Kafka instance, in the format of `host:port`. E.g. localhost:9092. Accepts a comma delimited string. + */ + brokers: string + /** + * Select the Authentication Mechanism to use. For SCRAM or PLAIN populate the 'Username' and 'Password' fields. For AWS IAM populated the 'AWS Access Key ID' and 'AWS Secret Key' fields. For 'Client Certificate' populated the 'SSL Client Key' and 'SSL Client Certificate' fields + */ + mechanism: string + /** + * The username for your Kafka instance. Should be populated only if using PLAIN or SCRAM Authentication Mechanisms. + */ + username?: string + /** + * The password for your Kafka instance. Should only be populated if using PLAIN or SCRAM Authentication Mechanisms. + */ + password?: string + /** + * The Access Key ID for your AWS IAM instance. Must be populated if using AWS IAM Authentication Mechanism. + */ + accessKeyId?: string + /** + * The Secret Key for your AWS IAM instance. Must be populated if using AWS IAM Authentication Mechanism. + */ + secretAccessKey?: string + /** + * AWS IAM role ARN used for authorization. This field is optional, and should only be populated if using the AWS IAM Authentication Mechanism. + */ + authorizationIdentity?: string + /** + * Indicates if SSL should be enabled. + */ + ssl_enabled: boolean + /** + * The Certificate Authority for your Kafka instance. Exclude the first and last lines from the file. i.e `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`. + */ + ssl_ca?: string + /** + * The Client Key for your Kafka instance. Exclude the first and last lines from the file. i.e `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`. + */ + ssl_key?: string + /** + * The Certificate Authority for your Kafka instance. Exclude the first and last lines from the file. i.e `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`. + */ + ssl_cert?: string + /** + * Whether to reject unauthorized CAs or not. This can be useful when testing, but is unadvised in Production. + */ + ssl_reject_unauthorized_ca: boolean +} diff --git a/packages/destination-actions/src/destinations/kafka/index.ts b/packages/destination-actions/src/destinations/kafka/index.ts new file mode 100644 index 0000000000..7936b83846 --- /dev/null +++ b/packages/destination-actions/src/destinations/kafka/index.ts @@ -0,0 +1,125 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { validate, getTopics } from './utils' +import send from './send' + +const destination: DestinationDefinition = { + name: 'Kafka', + slug: 'actions-kafka', + mode: 'cloud', + description: 'Send data to a Kafka topic', + authentication: { + scheme: 'custom', + fields: { + clientId: { + label: 'Client ID', + description: "The client ID for your Kafka instance. Defaults to 'segment-actions-kafka-producer'.", + type: 'string', + required: true, + default: 'segment-actions-kafka-producer' + }, + brokers: { + label: 'Brokers', + description: + 'The brokers for your Kafka instance, in the format of `host:port`. E.g. localhost:9092. Accepts a comma delimited string.', + type: 'string', + required: true + }, + mechanism: { + label: 'Authentication Mechanism', + description: + "Select the Authentication Mechanism to use. For SCRAM or PLAIN populate the 'Username' and 'Password' fields. For AWS IAM populated the 'AWS Access Key ID' and 'AWS Secret Key' fields. For 'Client Certificate' populated the 'SSL Client Key' and 'SSL Client Certificate' fields", + type: 'string', + required: true, + choices: [ + { label: 'Plain', value: 'plain' }, + { label: 'SCRAM-SHA-256', value: 'scram-sha-256' }, + { label: 'SCRAM-SHA-512', value: 'scram-sha-512' }, + { label: 'AWS IAM', value: 'aws' }, + { label: 'Client Certificate', value: 'client-cert-auth' } + ], + default: 'plain' + }, + username: { + label: 'Username', + description: + 'The username for your Kafka instance. Should be populated only if using PLAIN or SCRAM Authentication Mechanisms.', + type: 'string', + required: false + }, + password: { + label: 'Password', + description: + 'The password for your Kafka instance. Should only be populated if using PLAIN or SCRAM Authentication Mechanisms.', + type: 'password', + required: false + }, + accessKeyId: { + label: 'AWS Access Key ID', + description: + 'The Access Key ID for your AWS IAM instance. Must be populated if using AWS IAM Authentication Mechanism.', + type: 'string', + required: false + }, + secretAccessKey: { + label: 'AWS Secret Key', + description: + 'The Secret Key for your AWS IAM instance. Must be populated if using AWS IAM Authentication Mechanism.', + type: 'password', + required: false + }, + authorizationIdentity: { + label: 'AWS Authorization Identity', + description: + 'AWS IAM role ARN used for authorization. This field is optional, and should only be populated if using the AWS IAM Authentication Mechanism.', + type: 'string', + required: false + }, + ssl_enabled: { + label: 'SSL Enabled', + description: 'Indicates if SSL should be enabled.', + type: 'boolean', + required: true, + default: true + }, + ssl_ca: { + label: 'SSL Certificate Authority', + description: + 'The Certificate Authority for your Kafka instance. Exclude the first and last lines from the file. i.e `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`.', + type: 'string', + required: false + }, + ssl_key: { + label: 'SSL Client Key', + description: + 'The Client Key for your Kafka instance. Exclude the first and last lines from the file. i.e `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`.', + type: 'string', + required: false + }, + ssl_cert: { + label: 'SSL Client Certificate', + description: + 'The Certificate Authority for your Kafka instance. Exclude the first and last lines from the file. i.e `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`.', + type: 'string', + required: false + }, + ssl_reject_unauthorized_ca: { + label: 'SSL - Reject Unauthorized Certificate Authority', + description: + 'Whether to reject unauthorized CAs or not. This can be useful when testing, but is unadvised in Production.', + type: 'boolean', + required: true, + default: true + } + }, + testAuthentication: async (_, { settings }) => { + validate(settings) + return await getTopics(settings) + } + }, + actions: { + send + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/kafka/send/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kafka/send/__tests__/index.test.ts new file mode 100644 index 0000000000..94ad331f2d --- /dev/null +++ b/packages/destination-actions/src/destinations/kafka/send/__tests__/index.test.ts @@ -0,0 +1,243 @@ +import { createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { Kafka, KafkaConfig, Partitioners } from 'kafkajs' + +const testDestination = createTestIntegration(Destination) + +jest.mock('kafkajs', () => { + const mockProducer = { + connect: jest.fn(), + send: jest.fn(), + disconnect: jest.fn() + } + + const mockKafka = { + producer: jest.fn(() => mockProducer) + } + + return { + Kafka: jest.fn(() => mockKafka), + Producer: jest.fn(() => mockProducer), + Partitioners: { + LegacyPartitioner: jest.fn(), + DefaultPartitioner: jest.fn() + } + } +}) + +const testData = { + event: { + type: 'track', + event: 'Test Event', + properties: { + email: 'test@iterable.com' + }, + traits: {}, + timestamp: '2024-02-26T16:53:08.910Z', + sentAt: '2024-02-26T16:53:08.910Z', + receivedAt: '2024-02-26T16:53:08.907Z', + messageId: 'a82f52d9-d8ed-40a8-89e3-b9c04701a5f6', + userId: 'user1234', + anonymousId: 'anonId1234', + context: {} + }, + useDefaultMappings: false, + settings: { + brokers: 'yourBroker', + clientId: 'yourClientId', + mechanism: 'plain', + username: 'yourUsername', + password: 'yourPassword', + partitionerType: 'DefaultPartitioner', + ssl_enabled: true + }, + mapping: { + topic: 'test-topic', + payload: { '@path': '$.' } + } +} + +describe('Kafka.send', () => { + it('kafka library is initialized correctly for SASL plain auth', async () => { + await testDestination.testAction('send', testData as any) + + expect(Kafka).toHaveBeenCalledWith( + { + clientId: 'yourClientId', + brokers: ['yourBroker'], + ssl: true, + sasl: { + mechanism: 'plain', + username: 'yourUsername', + password: 'yourPassword' + } + } + ) + }) + + it('kafka library is initialized correctly for SASL scram-sha-256 auth', async () => { + const testData1 = { + ...testData, + settings: { + ...testData.settings, + mechanism: 'scram-sha-256' + } + } + + await testDestination.testAction('send', testData1 as any) + + expect(Kafka).toHaveBeenCalledWith( + { + clientId: 'yourClientId', + brokers: ['yourBroker'], + ssl: true, + sasl: { + mechanism: 'scram-sha-256', + username: 'yourUsername', + password: 'yourPassword' + } + } + ) + }) + + it('kafka library is initialized correctly for SASL scram-sha-512 auth', async () => { + const testData1 = { + ...testData, + settings: { + ...testData.settings, + mechanism: 'scram-sha-512' + } + } + + await testDestination.testAction('send', testData1 as any) + + expect(Kafka).toHaveBeenCalledWith( + { + clientId: 'yourClientId', + brokers: ['yourBroker'], + ssl: true, + sasl: { + mechanism: 'scram-sha-512', + username: 'yourUsername', + password: 'yourPassword' + } + } + ) + }) + + it('kafka library is initialized correctly for SASL aws auth', async () => { + const testData3 = { + ...testData, + settings: { + ...testData.settings, + mechanism: 'aws', + accessKeyId: 'testAccessKeyId', + secretAccessKey: 'testSecretAccessKey', + authorizationIdentity: 'testAuthorizationIdentity' + } + } + + await testDestination.testAction('send', testData3 as any) + + expect(Kafka).toHaveBeenCalledWith( + { + clientId: 'yourClientId', + brokers: ['yourBroker'], + ssl: true, + sasl: { + mechanism: 'aws', + accessKeyId: 'testAccessKeyId', + secretAccessKey: 'testSecretAccessKey', + authorizationIdentity: 'testAuthorizationIdentity' + } + } + ) + }) + + it('kafka library is initialized correctly when SSL_CA provided', async () => { + const testData4 = { + ...testData, + settings: { + ...testData.settings, + ssl_ca: 'yourCACert', + ssl_reject_unauthorized_ca: true + } + } + + await testDestination.testAction('send', testData4 as any) + + expect(Kafka).toHaveBeenCalledWith( + { + clientId: 'yourClientId', + brokers: ['yourBroker'], + ssl: { + ca: ['-----BEGIN CERTIFICATE-----\nyourCACert\n-----END CERTIFICATE-----'], + rejectUnauthorized: true + }, + sasl: { + mechanism: 'plain', + username: 'yourUsername', + password: 'yourPassword' + } + } + ) + }) + + it('kafka library is initialized correctly when SSL_CA provided and mechanism is client-cert-auth', async () => { + const testData5 = { + ...testData, + settings: { + mechanism: 'client-cert-auth', + brokers: 'yourBroker', + clientId: 'yourClientId', + partitionerType: 'DefaultPartitioner', + ssl_enabled: true, + ssl_ca: 'yourCACert', + ssl_reject_unauthorized_ca: true, + ssl_key: 'yourKey', + ssl_cert: 'yourCert', + } + } + + await testDestination.testAction('send', testData5 as any) + + expect(Kafka).toHaveBeenCalledWith( + { + clientId: 'yourClientId', + brokers: ['yourBroker'], + ssl: { + ca: ['-----BEGIN CERTIFICATE-----\nyourCACert\n-----END CERTIFICATE-----'], + rejectUnauthorized: true, + key: '-----BEGIN PRIVATE KEY-----\nyourKey\n-----END PRIVATE KEY-----', + cert: '-----BEGIN CERTIFICATE-----\nyourCert\n-----END CERTIFICATE-----' + } + } + ) + }) + + it('kafka producer is initialized correctly', async () => { + await testDestination.testAction('send', testData as any) + + expect(new Kafka({} as KafkaConfig).producer).toBeCalledWith({ + createPartitioner: Partitioners.DefaultPartitioner + }) + }) + + it('kafka.producer() send() is called with the correct payload', async () => { + await testDestination.testAction('send', testData as any) + + expect(new Kafka({} as KafkaConfig).producer().send).toBeCalledWith({ + topic: 'test-topic', + messages: [ + { + value: + '{"anonymousId":"anonId1234","context":{},"event":"Test Event","messageId":"a82f52d9-d8ed-40a8-89e3-b9c04701a5f6","properties":{"email":"test@iterable.com"},"receivedAt":"2024-02-26T16:53:08.907Z","sentAt":"2024-02-26T16:53:08.910Z","timestamp":"2024-02-26T16:53:08.910Z","traits":{},"type":"track","userId":"user1234"}', + key: undefined, + headers: undefined, + partition: undefined, + partitionerType: 'DefaultPartitioner' + } + ] + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/kafka/send/generated-types.ts b/packages/destination-actions/src/destinations/kafka/send/generated-types.ts new file mode 100644 index 0000000000..1883e782bd --- /dev/null +++ b/packages/destination-actions/src/destinations/kafka/send/generated-types.ts @@ -0,0 +1,32 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Kafka topic to send messages to. This field auto-populates from your Kafka instance. + */ + topic: string + /** + * The data to send to Kafka + */ + payload: { + [k: string]: unknown + } + /** + * Header data to send to Kafka. Format is Header key, Header value (optional). + */ + headers?: { + [k: string]: unknown + } + /** + * The partition to send the message to (optional) + */ + partition?: number + /** + * The default partition to send the message to (optional) + */ + default_partition?: number + /** + * The key for the message (optional) + */ + key?: string +} diff --git a/packages/destination-actions/src/destinations/kafka/send/index.ts b/packages/destination-actions/src/destinations/kafka/send/index.ts new file mode 100644 index 0000000000..9df3ce20e7 --- /dev/null +++ b/packages/destination-actions/src/destinations/kafka/send/index.ts @@ -0,0 +1,60 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getTopics, sendData } from '../utils' + +const action: ActionDefinition = { + title: 'Send', + description: 'Send data to a Kafka topic', + defaultSubscription: 'type = "track" or type = "identify" or type = "page" or type = "screen" or type = "group"', + fields: { + topic: { + label: 'Topic', + description: 'The Kafka topic to send messages to. This field auto-populates from your Kafka instance.', + type: 'string', + required: true, + dynamic: true + }, + payload: { + label: 'Payload', + description: 'The data to send to Kafka', + type: 'object', + required: true, + default: { '@path': '$.' } + }, + headers: { + label: 'Headers', + description: 'Header data to send to Kafka. Format is Header key, Header value (optional).', + type: 'object', + defaultObjectUI: 'keyvalue:only' + }, + partition: { + label: 'Partition', + description: 'The partition to send the message to (optional)', + type: 'integer' + }, + default_partition: { + label: 'Default Partition', + description: 'The default partition to send the message to (optional)', + type: 'integer' + }, + key: { + label: 'Message Key', + description: 'The key for the message (optional)', + type: 'string' + } + }, + dynamicFields: { + topic: async (_, { settings }) => { + return getTopics(settings) + } + }, + perform: async (_request, { settings, payload }) => { + await sendData(settings, [payload]) + }, + performBatch: async (_request, { settings, payload }) => { + await sendData(settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/kafka/utils.ts b/packages/destination-actions/src/destinations/kafka/utils.ts new file mode 100644 index 0000000000..352dd86131 --- /dev/null +++ b/packages/destination-actions/src/destinations/kafka/utils.ts @@ -0,0 +1,176 @@ +import { Kafka, ProducerRecord, Partitioners, SASLOptions, KafkaConfig, KafkaJSError } from 'kafkajs' +import { DynamicFieldResponse, IntegrationError } from '@segment/actions-core' +import type { Settings } from './generated-types' +import type { Payload } from './send/generated-types' + +export const DEFAULT_PARTITIONER = 'DefaultPartitioner' + +interface Message { + value: string + key?: string + headers?: { [key: string]: string } + partition?: number + partitionerType?: typeof DEFAULT_PARTITIONER +} + +interface TopicMessages { + topic: string + messages: Message[] +} + +interface SSLConfig { + ca: string[] + rejectUnauthorized: boolean + key?: string + cert?: string +} + +export const getTopics = async (settings: Settings): Promise => { + const kafka = getKafka(settings) + const admin = kafka.admin() + await admin.connect() + const topics = await admin.listTopics() + await admin.disconnect() + return { choices: topics.map((topic) => ({ label: topic, value: topic })) } +} + +const getKafka = (settings: Settings) => { + const kafkaConfig = { + clientId: settings.clientId, + brokers: settings.brokers + .trim() + .split(',') + .map((broker) => broker.trim()), + sasl: ((): SASLOptions | undefined => { + switch (settings.mechanism) { + case 'plain': + return { + username: settings?.username, + password: settings?.password, + mechanism: settings.mechanism + } as SASLOptions + case 'scram-sha-256': + case 'scram-sha-512': + return { + username: settings.username, + password: settings.password, + mechanism: settings.mechanism + } as SASLOptions + case 'aws': + return { + accessKeyId: settings.accessKeyId, + secretAccessKey: settings.secretAccessKey, + authorizationIdentity: settings.authorizationIdentity, + mechanism: settings.mechanism + } as SASLOptions + default: + return undefined + } + })(), + ssl: (() => { + if (settings?.ssl_ca) { + const ssl: SSLConfig = { + ca: [`-----BEGIN CERTIFICATE-----\n${settings.ssl_ca.trim()}\n-----END CERTIFICATE-----`], + rejectUnauthorized: settings.ssl_reject_unauthorized_ca + } + if (settings.mechanism === 'client-cert-auth') { + ;(ssl.key = `-----BEGIN PRIVATE KEY-----\n${settings?.ssl_key?.trim()}\n-----END PRIVATE KEY-----`), + (ssl.cert = `-----BEGIN CERTIFICATE-----\n${settings?.ssl_cert?.trim()}\n-----END CERTIFICATE-----`) + } + return ssl + } else if (settings.ssl_enabled) { + return settings.ssl_enabled + } + return undefined + })() + } as unknown as KafkaConfig + + try { + return new Kafka(kafkaConfig) + } catch (error) { + throw new IntegrationError( + `Kafka Connection Error: ${(error as KafkaJSError).message}`, + 'KAFKA_CONNECTION_ERROR', + 400 + ) + } +} + +export const validate = (settings: Settings) => { + if ( + ['plain', 'scram-sha-256', 'scram-sha-512'].includes(settings.mechanism) && + (!settings.username || !settings.password) + ) { + throw new IntegrationError( + 'Username and Password are required for PLAIN and SCRAM authentication mechanisms', + 'SASL_PARAMS_MISSING', + 400 + ) + } + if (['aws'].includes(settings.mechanism) && (!settings.accessKeyId || !settings.secretAccessKey)) { + throw new IntegrationError( + 'AWS Access Key ID and AWS Secret Key are required for AWS authentication mechanism', + 'SASL_AWS_PARAMS_MISSING', + 400 + ) + } + if (['client-cert-auth'].includes(settings.mechanism) && (!settings.ssl_key || !settings.ssl_cert)) { + throw new IntegrationError( + 'SSL Client Key and SSL Client Certificate are required for Client Certificate authentication mechanism', + 'SSL_CLIENT_CERT_AUTH_PARAMS_MISSING', + 400 + ) + } +} + +const getProducer = (settings: Settings) => { + return getKafka(settings).producer({ + createPartitioner: Partitioners.DefaultPartitioner + }) +} + +export const sendData = async (settings: Settings, payload: Payload[]) => { + validate(settings) + + const groupedPayloads: { [topic: string]: Payload[] } = {} + + payload.forEach((p) => { + const { topic } = p + if (!groupedPayloads[topic]) { + groupedPayloads[topic] = [] + } + groupedPayloads[topic].push(p) + }) + + const topicMessages: TopicMessages[] = Object.keys(groupedPayloads).map((topic) => ({ + topic, + messages: groupedPayloads[topic].map( + (payload) => + ({ + value: JSON.stringify(payload.payload), + key: payload.key, + headers: payload?.headers ?? undefined, + partition: payload?.partition ?? payload?.default_partition ?? undefined, + partitionerType: DEFAULT_PARTITIONER + } as Message) + ) + })) + + const producer = getProducer(settings) + + await producer.connect() + + for (const data of topicMessages) { + try { + await producer.send(data as ProducerRecord) + } catch (error) { + throw new IntegrationError( + `Kafka Producer Error: ${(error as KafkaJSError).message}`, + 'KAFKA_PRODUCER_ERROR', + 400 + ) + } + } + + await producer.disconnect() +} diff --git a/packages/destination-actions/src/destinations/kameleoon/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/kameleoon/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..b29c69aa5b --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-kameleoon destination: logEvent action - all fields 1`] = ` +Object { + "context": Object { + "testType": "VA*d2uO)D#", + }, + "event": "VA*d2uO)D#", + "messageId": "VA*d2uO)D#", + "properties": Object { + "kameleoonVisitorCode": "VA*d2uO)D#", + "testType": "VA*d2uO)D#", + }, + "timestamp": Any, + "type": "VA*d2uO)D#", +} +`; + +exports[`Testing snapshot for actions-kameleoon destination: logEvent action - required fields 1`] = ` +Object { + "messageId": "VA*d2uO)D#", + "properties": Object {}, + "timestamp": Any, + "type": "VA*d2uO)D#", +} +`; diff --git a/packages/destination-actions/src/destinations/kameleoon/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kameleoon/__tests__/index.test.ts new file mode 100644 index 0000000000..61f1134301 --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/__tests__/index.test.ts @@ -0,0 +1,38 @@ +import { createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import Definition from '../index' +import type { Settings } from '../generated-types' +import { BASE_URL } from '../properties' + +const CLIENT_ID = 'CLIENT_ID' +const CLIENT_SECRET = 'CLIENT_SECRET' + +const testDestination = createTestIntegration(Definition) + +describe('Kameleoon', () => { + describe('testAuthentication', () => { + it('should validate cation inputs', async () => { + nock(BASE_URL + '/getapikey') + .get(/.*/) + .reply(200, {}) + + const apiKey = { + id: CLIENT_ID, + secret: CLIENT_SECRET + } + const authData: Settings = { + apiKey: Buffer.from(JSON.stringify(apiKey)).toString('base64'), + sitecode: '1q2w3e4r5t' + } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + it('should throw error for invalid sitecode', async () => { + const settings: Settings = { + apiKey: '', + sitecode: '1q2w3e4' + } + await expect(testDestination.testAuthentication(settings)).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/kameleoon/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/kameleoon/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..4e6614fd8a --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/__tests__/snapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-kameleoon' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot({ + timestamp: expect.any(String) + }) + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot({ + timestamp: expect.any(String) + }) + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/kameleoon/generated-types.ts b/packages/destination-actions/src/destinations/kameleoon/generated-types.ts new file mode 100644 index 0000000000..9fa7e7fe04 --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Kameleoon API key. You can generate one using the link in the help doc (https://help.kameleoon.com/setting-up-segment/). + */ + apiKey: string + /** + * Kameleoon project sitecode. You can find this project dashboard (https://help.kameleoon.com/question/how-do-i-find-my-site-id/). + */ + sitecode: string +} diff --git a/packages/destination-actions/src/destinations/kameleoon/index.ts b/packages/destination-actions/src/destinations/kameleoon/index.ts new file mode 100644 index 0000000000..6c05147c5d --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/index.ts @@ -0,0 +1,82 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import logEvent from './logEvent' +import { BASE_URL } from './properties' + +const presets: DestinationDefinition['presets'] = [ + { + name: 'Track Calls', + subscribe: 'type = "track"', + partnerAction: 'logEvent', + mapping: defaultValues(logEvent.fields), + type: 'automatic' + }, + { + name: 'Page Calls', + subscribe: 'type = "page"', + partnerAction: 'logEvent', + mapping: defaultValues(logEvent.fields), + type: 'automatic' + }, + { + name: 'Screen Calls', + subscribe: 'type = "screen"', + partnerAction: 'logEvent', + mapping: defaultValues(logEvent.fields), + type: 'automatic' + }, + { + name: 'Identify Calls', + subscribe: 'type = "identify"', + partnerAction: 'logEvent', + mapping: defaultValues(logEvent.fields), + type: 'automatic' + } +] + +const destination: DestinationDefinition = { + name: 'Kameleoon (Actions)', + slug: 'actions-kameleoon', + mode: 'cloud', + description: 'Send Segment events to Kameleoon', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: + 'Kameleoon API key. You can generate one using the link in the help doc (https://help.kameleoon.com/setting-up-segment/).', + type: 'password', + required: true + }, + sitecode: { + label: 'Sitecode', + description: + 'Kameleoon project sitecode. You can find this project dashboard (https://help.kameleoon.com/question/how-do-i-find-my-site-id/).', + type: 'string', + required: true + } + }, + testAuthentication: (request, { settings }) => { + if (settings.sitecode.toString().length !== 10) { + throw new Error('Invalid project sitecode. Please check your sitecode') + } + + const apiKey = Object.entries(JSON.parse(Buffer.from(settings.apiKey, 'base64').toString('ascii'))) + .map(([key, value]) => key + '=' + value) + .join('&') + + return request(BASE_URL + '/getapikey?' + apiKey, { + method: 'GET' + }) + } + }, + presets, + actions: { + logEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..47dfa6754e --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Kameleoon's logEvent destination action: all fields 1`] = ` +Object { + "context": Object { + "testType": "wNACk[QgaEuFPK", + }, + "event": "wNACk[QgaEuFPK", + "messageId": "wNACk[QgaEuFPK", + "properties": Object { + "kameleoonVisitorCode": "wNACk[QgaEuFPK", + "testType": "wNACk[QgaEuFPK", + }, + "timestamp": Any, + "type": "wNACk[QgaEuFPK", +} +`; + +exports[`Testing snapshot for Kameleoon's logEvent destination action: required fields 1`] = ` +Object { + "messageId": "wNACk[QgaEuFPK", + "properties": Object {}, + "timestamp": Any, + "type": "wNACk[QgaEuFPK", +} +`; diff --git a/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..e6f631d6c5 --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/index.test.ts @@ -0,0 +1,106 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { BASE_URL } from '../../properties' + +const SITE_CODE = 'mysitecode' +const VISITOR_CODE = 'visitorCode' +const CLIENT_ID = 'CLIENT_ID' +const CLIENT_SECRET = 'CLIENT_SECRET' + +const testDestination = createTestIntegration(Destination) + +describe('Kameleoon.logEvent', () => { + it('should work', async () => { + nock(BASE_URL).post('').reply(200, {}) + + const event = createTestEvent({ + type: 'track', + event: 'Test Event', + timestamp: '2023-10-06T10:46:53.902Z', + properties: { + kameleoonVisitorCode: VISITOR_CODE + }, + context: { + active: true, + app: { + name: 'InitechGlobal', + version: '545', + build: '3.0.1.545', + namespace: 'com.production.segment' + }, + campaign: { + name: 'TPS Innovation Newsletter', + source: 'Newsletter', + medium: 'email', + term: 'tps reports', + content: 'image link' + }, + device: { + id: 'B5372DB0-C21E-11E4-8DFC-AA07A5B093DB', + advertisingId: '7A3CBEA0-BDF5-11E4-8DFC-AA07A5B093DB', + adTrackingEnabled: true, + manufacturer: 'Apple', + model: 'iPhone7,2', + name: 'maguro', + type: 'ios' + }, + ip: '8.8.8.8', + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + location: { + city: 'San Francisco', + country: 'United States', + latitude: 40.2964197, + longitude: -76.9411617, + speed: 0 + }, + network: { + bluetooth: false, + carrier: 'T-Mobile US', + cellular: true, + wifi: false + }, + os: { + name: 'iPhone OS', + version: '8.1.3' + }, + page: { + path: '/academy/', + referrer: '', + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + screen: { + width: 320, + height: 568, + density: 2 + }, + groupId: '12345', + timezone: 'Europe/Amsterdam', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + }, + messageId: 'test-message-ikxq2j1u94' + }) + const apiKey = { + id: CLIENT_ID, + secret: CLIENT_SECRET + } + const responses = await testDestination.testAction('logEvent', { + event, + settings: { + apiKey: Buffer.from(JSON.stringify(apiKey)).toString('base64'), + sitecode: SITE_CODE + }, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..52fac41aa2 --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/logEvent/__tests__/snapshot.test.ts @@ -0,0 +1,79 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'logEvent' +const destinationSlug = 'Kameleoon' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot({ + timestamp: expect.any(String) + }) + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot({ + timestamp: expect.any(String) + }) + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/kameleoon/logEvent/generated-types.ts b/packages/destination-actions/src/destinations/kameleoon/logEvent/generated-types.ts new file mode 100644 index 0000000000..0dfc6a0f75 --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/logEvent/generated-types.ts @@ -0,0 +1,36 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The event name + */ + event?: string + /** + * The type of the event + */ + type: string + /** + * Additional event Properties or user Traits to send with the event + */ + properties?: { + [k: string]: unknown + } + /** + * Kameleoon Visitor Code - a unique identifier for the user + */ + kameleoonVisitorCode?: string + /** + * The timestamp of the event + */ + timestamp: string + /** + * Context properties to send with the event + */ + context?: { + [k: string]: unknown + } + /** + * The Segment messageId + */ + messageId: string +} diff --git a/packages/destination-actions/src/destinations/kameleoon/logEvent/index.ts b/packages/destination-actions/src/destinations/kameleoon/logEvent/index.ts new file mode 100644 index 0000000000..ac71864c0b --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/logEvent/index.ts @@ -0,0 +1,103 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import omit from 'lodash/omit' + +import { BASE_URL } from '../properties' + +const action: ActionDefinition = { + title: 'Log Event', + description: 'Send an event to Kameleoon', + defaultSubscription: 'type = "track"', + fields: { + event: { + type: 'string', + required: false, + description: 'The event name', + label: 'Event Name', + default: { + '@if': { + exists: { '@path': '$.event' }, + then: { '@path': '$.event' }, + else: { '@path': '$.name' } + } + } + }, + type: { + label: 'Type', + type: 'string', + required: true, + description: 'The type of the event', + default: { + '@path': '$.type' + } + }, + properties: { + type: 'object', + required: false, + description: 'Additional event Properties or user Traits to send with the event', + label: 'Event properties or user traits', + default: { + '@if': { + exists: { '@path': '$.properties' }, + then: { '@path': '$.properties' }, + else: { '@path': '$.traits' } + } + } + }, + kameleoonVisitorCode: { + type: 'string', + required: false, + description: 'Kameleoon Visitor Code - a unique identifier for the user', + label: 'Kameleoon Visitor Code', + default: { + '@if': { + exists: { '@path': '$.properties.kameleoonVisitorCode' }, + then: { '@path': '$.properties.kameleoonVisitorCode' }, + else: { '@path': '$.traits.kameleoonVisitorCode' } + } + } + }, + timestamp: { + type: 'string', + format: 'date-time', + required: true, + description: 'The timestamp of the event', + label: 'Timestamp', + default: { '@path': '$.timestamp' } + }, + context: { + type: 'object', + required: false, + description: 'Context properties to send with the event', + label: 'Context properties', + default: { '@path': '$.context' } + }, + messageId: { + type: 'string', + required: true, + description: 'The Segment messageId', + label: 'MessageId', + default: { '@path': '$.messageId' } + } + }, + perform: (request, data) => { + const payload = { + ...omit(data.payload, ['kameleoonVisitorCode']), + properties: { + ...(data.payload.properties || {}), + kameleoonVisitorCode: data.payload.kameleoonVisitorCode + } + } + return request(BASE_URL, { + headers: { + authorization: `Basic ${data.settings.apiKey}`, + 'x-segment-settings': data.settings.sitecode + }, + method: 'post', + json: payload + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/kameleoon/properties.ts b/packages/destination-actions/src/destinations/kameleoon/properties.ts new file mode 100644 index 0000000000..2f5deba75d --- /dev/null +++ b/packages/destination-actions/src/destinations/kameleoon/properties.ts @@ -0,0 +1 @@ +export const BASE_URL = 'https://integrations.kameleoon.com/segmentio' diff --git a/packages/destination-actions/src/destinations/kevel-audience/generated-types.ts b/packages/destination-actions/src/destinations/kevel-audience/generated-types.ts new file mode 100644 index 0000000000..57f3dadabc --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel-audience/generated-types.ts @@ -0,0 +1,28 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Kevel Audience root subdomain. For example: "cdp.yourdomain.com". + */ + audienceDomain: string + /** + * Kevel Audience User ID Type to map your Segment User ID to. For example: "crm". + */ + userIdType: string + /** + * The Kevel Audience client ID to identify the event. For example: "brand-name". + */ + clientId: string + /** + * The Kevel Audience site ID to identify the event. For example: "segment-app". + */ + siteId: string + /** + * The Kevel Audience API Key to authorize the requests. Get yours from your Kevel Customer Success representative. + */ + apiKey: string + /** + * The type of event to send to Kevel Audience. For example: "segmentSync". + */ + eventType: string +} diff --git a/packages/destination-actions/src/destinations/kevel-audience/index.ts b/packages/destination-actions/src/destinations/kevel-audience/index.ts new file mode 100644 index 0000000000..7686f91ec3 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel-audience/index.ts @@ -0,0 +1,67 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import syncKevelAudience from './syncKevelAudience' + +const destination: DestinationDefinition = { + name: 'Kevel Audience (Actions)', + slug: 'actions-kevel-audience', + description: + 'Sync Segment user profile traits and Engage Audiences to Kevel Audiences. Only users with a Segment userId will be synced.', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + audienceDomain: { + label: 'Kevel Audience Domain', + description: 'Your Kevel Audience root subdomain. For example: "cdp.yourdomain.com".', + type: 'string', + required: true + }, + userIdType: { + label: 'Kevel Audience User ID Type', + description: 'Kevel Audience User ID Type to map your Segment User ID to. For example: "crm".', + type: 'string', + required: true + }, + clientId: { + label: 'Kevel Audience client ID', + description: 'The Kevel Audience client ID to identify the event. For example: "brand-name".', + type: 'string', + required: true + }, + siteId: { + label: 'Kevel Audience site ID', + description: 'The Kevel Audience site ID to identify the event. For example: "segment-app".', + type: 'string', + required: true + }, + apiKey: { + label: 'Kevel Audience API Key', + description: + 'The Kevel Audience API Key to authorize the requests. Get yours from your Kevel Customer Success representative.', + type: 'string', + required: true + }, + eventType: { + label: 'Event Type', + description: 'The type of event to send to Kevel Audience. For example: "segmentSync".', + type: 'string', + required: true + } + } + }, + extendRequest() { + return { + headers: { + 'Content-Type': 'application/json' + } + } + }, + actions: { + syncKevelAudience + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/__tests__/index.test.ts new file mode 100644 index 0000000000..d49dc326ae --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/__tests__/index.test.ts @@ -0,0 +1,87 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const goodTrackEvent = createTestEvent({ + type: 'track', + userId: 'uid1', + context: { + personas: { + computation_class: 'audience', + computation_key: 'kevel_segment_test_name' + }, + traits: { + email: 'test@email.com' + } + }, + properties: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + } +}) + +const goodIdentifyEvent = createTestEvent({ + type: 'identify', + userId: 'uid1', + context: { + personas: { + computation_class: 'audience', + computation_key: 'kevel_segment_test_name' + } + }, + traits: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + }, + properties: undefined +}) + +describe('KevelAuddience.syncKevelAudience', () => { + it('should not throw an error if the audience creation succeed - track', async () => { + const baseUrl = 'https://tr.domain.brand.com/' + + nock(baseUrl) + .post('/events/server', (body) => body.customData.kevel_segment_test_name === true) + .reply(200) + + await expect( + testDestination.testAction('syncKevelAudience', { + event: goodTrackEvent, + settings: { + audienceDomain: 'domain.brand.com', + userIdType: 'email_sha256', + apiKey: 'api_key', + clientId: 'client_id', + siteId: 'site_id', + eventType: 'segmentSync' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + it('should not throw an error if the audience creation succeed - identify', async () => { + const baseUrl = 'https://tr.domain.brand.com' + + nock(baseUrl) + .post('/events/server', (body) => body.customData.kevel_segment_test_name === true) + .reply(200) + + await expect( + testDestination.testAction('syncKevelAudience', { + event: goodIdentifyEvent, + settings: { + audienceDomain: 'domain.brand.com', + userIdType: 'email_sha256', + apiKey: 'api_key', + clientId: 'client_id', + siteId: 'site_id', + eventType: 'segmentSync' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/generated-types.ts b/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/generated-types.ts new file mode 100644 index 0000000000..9961472af7 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's unique ID + */ + segment_user_id: string + /** + * A computed object for track and identify events. This field should not need to be edited. + */ + traits_or_props: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/index.ts b/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/index.ts new file mode 100644 index 0000000000..3354f84f14 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel-audience/syncKevelAudience/index.ts @@ -0,0 +1,55 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Sync Kevel Audience', + description: + 'Sync Segment user profile traits and Engage Audiences to Kevel Audiences. Only users with a Segment userId will be synced.', + defaultSubscription: 'type = "track" or type = "identify"', + fields: { + segment_user_id: { + label: 'User ID', + description: "The user's unique ID", + type: 'string', + unsafe_hidden: true, + required: true, + default: { '@path': '$.userId' } + }, + traits_or_props: { + label: 'Traits or properties object', + description: 'A computed object for track and identify events. This field should not need to be edited.', + type: 'object', + required: true, + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.properties' }, + then: { '@path': '$.properties' }, + else: { '@path': '$.traits' } + } + } + } + }, + perform: async (request, data) => { + const baseUrl = `https://tr.${data.settings.audienceDomain}/events/server` // TODO event tracker + const payload = { + clientId: data.settings.clientId, + siteId: data.settings.siteId, + type: 'custom', + customType: data.settings.eventType, + user: { + type: data.settings.userIdType, + id: data.payload.segment_user_id + }, + customData: data.payload.traits_or_props + } + + return request(`${baseUrl}`, { + json: payload, + method: 'POST' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/kevel/generated-types.ts b/packages/destination-actions/src/destinations/kevel/generated-types.ts new file mode 100644 index 0000000000..6a0d02c1d4 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Kevel Network ID + */ + networkId: string + /** + * Your Kevel API Key + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/kevel/index.ts b/packages/destination-actions/src/destinations/kevel/index.ts new file mode 100644 index 0000000000..bd9fa22eb4 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/index.ts @@ -0,0 +1,47 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import syncAudience from './syncAudience' + +import syncTraits from './syncTraits' + +const destination: DestinationDefinition = { + name: 'Kevel UserDB (Actions)', + slug: 'actions-kevel', + description: + 'Send Segment user profiles and Audiences to Kevel UserDB for campaign targeting. Only users with a Segment userId will be synced.', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + networkId: { + label: 'Kevel Network ID', + description: 'Your Kevel Network ID', + type: 'string', + required: true + }, + apiKey: { + label: 'Kevel API Key', + description: 'Your Kevel API Key', + type: 'string', + required: true + } + } + }, + extendRequest({ settings }) { + return { + headers: { + 'X-Adzerk-ApiKey': settings.apiKey, + 'Content-Type': 'application/json', + 'X-Adzerk-Sdk-Version': 'adzerk-segment-integration:v1.0' + } + } + }, + actions: { + syncAudience, + syncTraits + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/kevel/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kevel/syncAudience/__tests__/index.test.ts new file mode 100644 index 0000000000..16c5d9b032 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncAudience/__tests__/index.test.ts @@ -0,0 +1,108 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const goodTrackEvent = createTestEvent({ + type: 'track', + userId: 'uid1', + context: { + personas: { + computation_class: 'audience', + computation_key: 'kevel_segment_test_name' + }, + traits: { + email: 'test@email.com' + } + }, + properties: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + } +}) + +const goodIdentifyEvent = createTestEvent({ + type: 'identify', + userId: 'uid1', + context: { + personas: { + computation_class: 'audience', + computation_key: 'kevel_segment_test_name' + } + }, + traits: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + }, + properties: undefined +}) + +const badEvent = createTestEvent({ + userId: 'uid1', + context: { + personas: { + computation_key: 'kevel_segment_test_name' + }, + traits: { + email: 'test@email.com' + } + }, + properties: { + audience_key: 'kevel_segment_test_name', + kevel_segment_test_name: true + } +}) + +describe('Kevel.syncAudience', () => { + it('should not throw an error if the audience creation succeed - track', async () => { + const userId = 'uid1' + const networkId1 = 'networkId1' + const baseUrl = `https://e-${networkId1}.adzerk.net/udb/${networkId1}` + + nock(baseUrl) + .post(`/interests?userKey=${userId}`, JSON.stringify(['kevel_segment_test_name'])) + .reply(200) + + await expect( + testDestination.testAction('syncAudience', { + event: goodTrackEvent, + settings: { + networkId: networkId1, + apiKey: 'apiKey1' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + it('should not throw an error if the audience creation succeed - identify', async () => { + const userId = 'uid1' + const networkId1 = 'networkId1' + const baseUrl = `https://e-${networkId1}.adzerk.net/udb/${networkId1}` + + nock(baseUrl) + .post(`/interests?userKey=${userId}`, JSON.stringify(['kevel_segment_test_name'])) + .reply(200) + + await expect( + testDestination.testAction('syncAudience', { + event: goodIdentifyEvent, + settings: { + networkId: networkId1, + apiKey: 'apiKey1' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) + + it('should throw an error if audience creation event missing mandatory field', async () => { + await expect( + testDestination.testAction('syncAudience', { + event: badEvent, + useDefaultMappings: true + }) + ).rejects.toThrowError("The root value is missing the required field 'segment_computation_action'") + }) +}) diff --git a/packages/destination-actions/src/destinations/kevel/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/kevel/syncAudience/generated-types.ts new file mode 100644 index 0000000000..4277d10241 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncAudience/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Segment Audience name to which user identifier should be added or removed + */ + segment_computation_key: string + /** + * Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'. + */ + segment_computation_action: string + /** + * The user's unique ID + */ + segment_user_id: string + /** + * A computed object for track and identify events. This field should not need to be edited. + */ + traits_or_props: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/kevel/syncAudience/index.ts b/packages/destination-actions/src/destinations/kevel/syncAudience/index.ts new file mode 100644 index 0000000000..29f3b8ef0b --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncAudience/index.ts @@ -0,0 +1,72 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Sync Audience', + description: + "Sync a Segment Engage Audience to a Kevel UserDB Interest. Only users with a Segment userId will be synced. See Kevel's [documentation for more details](https://dev.kevel.com/reference/add-interest-to-user).", + defaultSubscription: 'type = "track" or type = "identify"', + fields: { + segment_computation_key: { + label: 'Audience Key', + description: 'Segment Audience name to which user identifier should be added or removed', + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_key' + } + }, + segment_computation_action: { + label: 'Segment Computation Action', + description: + "Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'.", + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_class' + }, + choices: [{ label: 'audience', value: 'audience' }] + }, + segment_user_id: { + label: 'User ID', + description: "The user's unique ID", + type: 'string', + unsafe_hidden: true, + required: true, + default: { '@path': '$.userId' } + }, + traits_or_props: { + label: 'Traits or properties object', + description: 'A computed object for track and identify events. This field should not need to be edited.', + type: 'object', + required: true, + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.properties' }, + then: { '@path': '$.properties' }, + else: { '@path': '$.traits' } + } + } + } + }, + perform: async (request, data) => { + const settings = data.settings + + const baseUrl = `https://e-${settings.networkId}.adzerk.net/udb/${settings.networkId}` + + const payload = data.payload + + const audienceValue = payload.traits_or_props[payload.segment_computation_key] + + return request(`${baseUrl}/interests?userKey=${payload.segment_user_id}`, { + json: [payload.segment_computation_key], + method: audienceValue ? 'POST' : 'DELETE' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/kevel/syncTraits/__tests__/index.test.ts b/packages/destination-actions/src/destinations/kevel/syncTraits/__tests__/index.test.ts new file mode 100644 index 0000000000..a588212b51 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncTraits/__tests__/index.test.ts @@ -0,0 +1,53 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const goodIdentifyEvent = createTestEvent({ + type: 'identify', + userId: 'uid1', + + traits: { + first_name: 'Billy', + last_name: 'Bob' + } +}) + +describe('Kevel.syncTraits', () => { + it('should fetch and merge traits, and then not throw an error - track', async () => { + const userId = 'uid1' + const networkId1 = 'networkId1' + const baseUrl = `https://e-${networkId1}.adzerk.net/udb/${networkId1}` + + const allTraits = { + age: 24, + first_name: 'Billy', + last_name: 'Bob' + } + + nock(baseUrl) + .get(`/read?userKey=${userId}`) + .reply( + 200, + JSON.stringify({ + custom: { + age: 24 + } + }) + ) + + nock(baseUrl).post(`/customProperties?userKey=${userId}`, JSON.stringify(allTraits)).reply(200) + + await expect( + testDestination.testAction('syncTraits', { + event: goodIdentifyEvent, + settings: { + networkId: networkId1, + apiKey: 'apiKey1' + }, + useDefaultMappings: true + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/kevel/syncTraits/generated-types.ts b/packages/destination-actions/src/destinations/kevel/syncTraits/generated-types.ts new file mode 100644 index 0000000000..3941ff5219 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncTraits/generated-types.ts @@ -0,0 +1,14 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's unique ID + */ + segment_user_id: string + /** + * The user's profile traits / attributes + */ + traits: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/kevel/syncTraits/index.ts b/packages/destination-actions/src/destinations/kevel/syncTraits/index.ts new file mode 100644 index 0000000000..157959ae08 --- /dev/null +++ b/packages/destination-actions/src/destinations/kevel/syncTraits/index.ts @@ -0,0 +1,48 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Sync Traits', + description: + "Sync user profile traits and Audiences from Segment to Kevel UserDB as `customProperties`. See Kevel's [documentation for more details](https://dev.kevel.com/reference/set-custom-properties-alternative).", + defaultSubscription: 'type = "identify"', + fields: { + segment_user_id: { + label: 'User ID', + description: "The user's unique ID", + type: 'string', + required: true, + default: { '@path': '$.userId' } + }, + traits: { + label: 'Traits', + description: "The user's profile traits / attributes", + type: 'object', + required: true, + default: { '@path': '$.traits' } + } + }, + perform: async (request, data) => { + const settings = data.settings + + const baseUrl = `https://e-${settings.networkId}.adzerk.net/udb/${settings.networkId}` + + const payload = data.payload + + const existingResponse = await request(`${baseUrl}/read?userKey=${payload.segment_user_id}`, { + method: 'GET' + }) + + const existingRecord = await existingResponse.json() + + const mergedTraits = { ...existingRecord?.custom, ...payload.traits } + + return request(`${baseUrl}/customProperties?userKey=${payload.segment_user_id}`, { + json: mergedTraits, + method: 'POST' + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/klaviyo/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/klaviyo/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..2f1f93455e --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-klaviyo destination: addProfileToList action - all fields 1`] = ` +Object { + "data": Object { + "attributes": Object { + "email": "mudwoz@zo.ad", + "external_id": "E3nNk", + }, + "type": "profile", + }, +} +`; + +exports[`Testing snapshot for actions-klaviyo destination: orderCompleted action - all fields 1`] = ` +Object { + "data": Object { + "attributes": Object { + "metric": Object { + "data": Object { + "attributes": Object { + "name": "Order Completed", + }, + "type": "metric", + }, + }, + "profile": Object { + "data": Object { + "attributes": Object { + "email": "rafebezuv@micfeoz.zm", + "other_properties": Object { + "testType": "PE*zlOgIPA]mVozMLBaL", + }, + "phone_number": "PE*zlOgIPA]mVozMLBaL", + }, + "type": "profile", + }, + }, + "properties": Object { + "testType": "PE*zlOgIPA]mVozMLBaL", + }, + "time": "2021-02-01T00:00:00.000Z", + "value": 83414764297912.31, + }, + "type": "event", + }, +} +`; + +exports[`Testing snapshot for actions-klaviyo destination: removeProfileFromList action - all fields 1`] = `""`; + +exports[`Testing snapshot for actions-klaviyo destination: trackEvent action - all fields 1`] = ` +Object { + "data": Object { + "attributes": Object { + "metric": Object { + "data": Object { + "attributes": Object { + "name": "mTdOx(Nl)", + }, + "type": "metric", + }, + }, + "profile": Object { + "data": Object { + "attributes": Object { + "email": "ujoeri@ifosi.kp", + "other_properties": Object { + "testType": "mTdOx(Nl)", + }, + "phone_number": "mTdOx(Nl)", + }, + "type": "profile", + }, + }, + "properties": Object { + "testType": "mTdOx(Nl)", + }, + "time": "2021-02-01T00:00:00.000Z", + "value": -35080566000844.8, + }, + "type": "event", + }, +} +`; + +exports[`Testing snapshot for actions-klaviyo destination: upsertProfile action - all fields 1`] = ` +Object { + "data": Object { + "attributes": Object { + "email": "rob@cagtud.eu", + "external_id": "$Fd4HHQmxNI0jTCTt(t", + "first_name": "$Fd4HHQmxNI0jTCTt(t", + "image": "$Fd4HHQmxNI0jTCTt(t", + "last_name": "$Fd4HHQmxNI0jTCTt(t", + "location": Object { + "address1": "$Fd4HHQmxNI0jTCTt(t", + "address2": "$Fd4HHQmxNI0jTCTt(t", + "city": "$Fd4HHQmxNI0jTCTt(t", + "country": "$Fd4HHQmxNI0jTCTt(t", + "latitude": "$Fd4HHQmxNI0jTCTt(t", + "longitude": "$Fd4HHQmxNI0jTCTt(t", + "region": "$Fd4HHQmxNI0jTCTt(t", + "zip": "$Fd4HHQmxNI0jTCTt(t", + }, + "organization": "$Fd4HHQmxNI0jTCTt(t", + "phone_number": "$Fd4HHQmxNI0jTCTt(t", + "properties": Object { + "testType": "$Fd4HHQmxNI0jTCTt(t", + }, + "title": "$Fd4HHQmxNI0jTCTt(t", + }, + "type": "profile", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts index e95aee2dbc..13b8292fee 100644 --- a/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/klaviyo/__tests__/index.test.ts @@ -1,5 +1,5 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { APIError, IntegrationError, createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' const testDestination = createTestIntegration(Definition) @@ -11,6 +11,22 @@ export const settings = { api_key: apiKey } +const createAudienceInput = { + settings: { + api_key: '' + }, + audienceName: '' +} + +const getAudienceInput = { + settings: { + api_key: apiKey + }, + externalId: 'XYZABC' +} + +const audienceName = 'Klaviyo Audience Name' + describe('Klaviyo (actions)', () => { describe('testAuthentication', () => { it('should validate authentication inputs', async () => { @@ -52,4 +68,71 @@ describe('Klaviyo (actions)', () => { } }) }) + + describe('createAudience', () => { + it('should fail if no audience name is set', async () => { + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + + it('should fail if no api key is set in settings', async () => { + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + + it('creates an audience', async () => { + createAudienceInput.audienceName = audienceName + createAudienceInput.settings.api_key = apiKey + + nock(`${API_URL}`) + .post('/lists', { data: { type: 'list', attributes: { name: audienceName } } }) + .matchHeader('Authorization', `Klaviyo-API-Key ${apiKey}`) + .reply(200, { + success: true, + data: { + id: 'XYZABC' + } + }) + + const r = await testDestination.createAudience(createAudienceInput) + expect(r).toEqual({ + externalId: 'XYZABC' + }) + }) + }) + + describe('getAudience', () => { + const listId = getAudienceInput.externalId + it('should succeed when with valid list id', async () => { + nock(`${API_URL}/lists`) + .get(`/${listId}`) + .reply(200, { + success: true, + data: { + id: 'XYZABC' + } + }) + const r = await testDestination.getAudience(getAudienceInput) + expect(r).toEqual({ + externalId: 'XYZABC' + }) + }) + + it('should throw an ApiError when the response is not ok', async () => { + const errorMessage = 'List not found' + nock(`${API_URL}/lists`) + .get(`/${listId}`) + .reply(404, { + success: false, + errors: [ + { + detail: errorMessage + } + ] + }) + + const audiencePromise = testDestination.getAudience(getAudienceInput) + await expect(audiencePromise).rejects.toThrow(APIError) + await expect(audiencePromise).rejects.toHaveProperty('message', errorMessage) + await expect(audiencePromise).rejects.toHaveProperty('status', 404) + }) + }) }) diff --git a/packages/destination-actions/src/destinations/klaviyo/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/klaviyo/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..02fc02cf2f --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/__tests__/snapshot.test.ts @@ -0,0 +1,49 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-klaviyo' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/) + .persist() + .get(/.*/) + .reply(200, { data: ['profileId1', 'profileId2'] }) + nock(/.*/) + .persist() + .post(/.*/) + .reply(200, { data: { id: 'fake-id' } }) + nock(/.*/).persist().put(/.*/).reply(200, {}) + nock(/.*/).persist().delete(/.*/).reply(200, {}) + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/addProfileToList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/__tests__/index.test.ts new file mode 100644 index 0000000000..f1bbb46487 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/__tests__/index.test.ts @@ -0,0 +1,343 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { API_URL } from '../../config' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import { Mock } from 'jest-mock' + +import * as Functions from '../../functions' + +jest.mock('../../functions', () => ({ + ...jest.requireActual('../../functions'), + createImportJobPayload: jest.fn(), + sendImportJobRequest: jest.fn(() => Promise.resolve()) +})) + +const testDestination = createTestIntegration(Definition) + +const apiKey = 'fake-api-key' + +export const settings = { + api_key: apiKey +} +const listId = 'XYZABC' + +const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + } + ] +} + +const profileData = { + data: { + type: 'profile', + attributes: { + email: 'demo@segment.com' + } + } +} + +const importJobPayload = { + data: { + type: 'profile-bulk-import-job', + attributes: { + profiles: { + data: [ + { + type: 'profile', + attributes: { + email: 'valid@example.com', + external_id: 'fake-external-id' + } + } + ] + } + }, + relationships: { + lists: { + data: [{ type: 'list', id: listId }] + } + } + } +} + +describe('Add List To Profile', () => { + it('should throw error if no email or External Id is provided', async () => { + const event = createTestEvent({ + type: 'track', + properties: {} + }) + + await expect( + testDestination.testAction('addProfileToList', { event, settings, useDefaultMappings: true }) + ).rejects.toThrowError(AggregateAjvError) + }) + + it('should add profile to list if successful with email only', async () => { + nock(`${API_URL}`) + .post('/profiles/', profileData) + .reply(200, { + data: { + id: 'XYZABC' + } + }) + + nock(`${API_URL}/lists/${listId}`) + .post('/relationships/profiles/', requestBody) + .reply( + 200, + JSON.stringify({ + content: requestBody + }) + ) + + const event = createTestEvent({ + type: 'track', + userId: '123', + traits: { + email: 'demo@segment.com' + } + }) + const mapping = { + list_id: listId, + email: { + '@path': '$.traits.email' + } + } + await expect( + testDestination.testAction('addProfileToList', { event, mapping, settings }) + ).resolves.not.toThrowError() + }) + + it('should add profile to list if successful with external id only', async () => { + nock(`${API_URL}`) + .post('/profiles/', { data: { type: 'profile', attributes: { external_id: 'testing_123' } } }) + .reply(200, { + data: { + id: 'XYZABC' + } + }) + + nock(`${API_URL}/lists/${listId}`) + .post('/relationships/profiles/', requestBody) + .reply( + 200, + JSON.stringify({ + content: requestBody + }) + ) + + const event = createTestEvent({ + type: 'track', + userId: '123', + properties: { + external_id: 'testing_123' + } + }) + const mapping = { + list_id: listId, + external_id: 'testing_123' + } + + await expect( + testDestination.testAction('addProfileToList', { event, mapping, settings }) + ).resolves.not.toThrowError() + }) + + it('should add profile to list if successful with both email and external id', async () => { + nock(`${API_URL}`) + .post('/profiles/', { + data: { type: 'profile', attributes: { email: 'demo@segment.com', external_id: 'testing_123' } } + }) + .reply(200, { + data: { + id: 'XYZABC' + } + }) + + nock(`${API_URL}/lists/${listId}`) + .post('/relationships/profiles/', requestBody) + .reply( + 200, + JSON.stringify({ + content: requestBody + }) + ) + + const event = createTestEvent({ + type: 'track', + userId: '123', + properties: { + external_id: 'testing_123' + }, + traits: { + email: 'demo@segment.com' + } + }) + const mapping = { + list_id: listId, + external_id: 'testing_123', + email: { + '@path': '$.traits.email' + } + } + + await expect( + testDestination.testAction('addProfileToList', { event, mapping, settings }) + ).resolves.not.toThrowError() + }) + it('should add to list if profile is already created', async () => { + nock(`${API_URL}`) + .post('/profiles/', profileData) + .reply(409, { + errors: [ + { + meta: { + duplicate_profile_id: 'XYZABC' + } + } + ] + }) + + nock(`${API_URL}/lists/${listId}`) + .post('/relationships/profiles/', requestBody) + .reply( + 200, + JSON.stringify({ + content: requestBody + }) + ) + + const event = createTestEvent({ + type: 'track', + userId: '123', + traits: { + email: 'demo@segment.com' + } + }) + const mapping = { + list_id: listId, + email: { + '@path': '$.traits.email' + } + } + await expect( + testDestination.testAction('addProfileToList', { event, mapping, settings }) + ).resolves.not.toThrowError() + }) +}) + +describe('Add Profile To List Batch', () => { + beforeEach(() => { + nock.cleanAll() + jest.resetAllMocks() + }) + afterEach(() => { + jest.resetAllMocks() + }) + + it('should filter out profiles without email or external_id', async () => { + const events = [ + createTestEvent({ + context: { personas: { list_id: '123' }, traits: { email: 'valid@example.com' } } + }), + createTestEvent({ + context: { personas: {}, traits: {} } + }) + ] + + const mapping = { + list_id: listId, + email: { + '@path': '$.context.traits.email' + } + } + + nock(API_URL).post('/profile-bulk-import-jobs/').reply(200, { success: true }) + + await testDestination.testBatchAction('addProfileToList', { + settings, + events, + mapping, + useDefaultMappings: true + }) + + // Check if createImportJobPayload was called with only the valid profile + expect(Functions.createImportJobPayload).toHaveBeenCalledWith( + [ + { + batch_size: 10000, + list_id: listId, + email: 'valid@example.com', + enable_batching: true + } + ], + listId + ) + }) + + it('should create an import job payload with the correct listId', async () => { + const events = [ + createTestEvent({ + context: { personas: { list_id: listId }, traits: { email: 'valid@example.com' } } + }) + ] + const mapping = { + list_id: listId, + external_id: 'fake-external-id', + email: { + '@path': '$.context.traits.email' + } + } + + nock(API_URL).post('/profile-bulk-import-jobs/').reply(200, { success: true }) + + await testDestination.testBatchAction('addProfileToList', { + settings, + events, + mapping, + useDefaultMappings: true + }) + + expect(Functions.createImportJobPayload).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + email: 'valid@example.com', + external_id: 'fake-external-id', + list_id: listId + }) + ]), + listId + ) + }) + + it('should send an import job request with the generated payload', async () => { + const events = [ + createTestEvent({ + context: { personas: { list_id: listId }, traits: { email: 'valid@example.com' } } + }) + ] + const mapping = { + list_id: listId, + external_id: 'fake-external-id', + email: { + '@path': '$.context.traits.email' + } + } + + ;(Functions.createImportJobPayload as Mock).mockImplementation(() => importJobPayload) + nock(API_URL).post('/profile-bulk-import-jobs/', importJobPayload).reply(200, { success: true }) + + await testDestination.testBatchAction('addProfileToList', { + events, + settings, + mapping, + useDefaultMappings: true + }) + + expect(Functions.sendImportJobRequest).toHaveBeenCalledWith(expect.anything(), importJobPayload) + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/addProfileToList/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/generated-types.ts new file mode 100644 index 0000000000..e6149fb403 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/generated-types.ts @@ -0,0 +1,24 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's email to send to Klavio. + */ + email?: string + /** + * 'Insert the ID of the default list that you'd like to subscribe users to when you call .identify().' + */ + list_id: string + /** + * A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID and Email required. + */ + external_id?: string + /** + * When enabled, the action will use the klaviyo batch API. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number +} diff --git a/packages/destination-actions/src/destinations/klaviyo/addProfileToList/index.ts b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/index.ts new file mode 100644 index 0000000000..78360b86b2 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/addProfileToList/index.ts @@ -0,0 +1,35 @@ +import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import { Payload } from './generated-types' +import { createProfile, addProfileToList, createImportJobPayload, sendImportJobRequest } from '../functions' +import { email, external_id, list_id, enable_batching, batch_size } from '../properties' + +const action: ActionDefinition = { + title: 'Add Profile To List', + description: 'Add Profile To List', + defaultSubscription: 'event = "Audience Entered"', + fields: { + email: { ...email }, + list_id: { ...list_id }, + external_id: { ...external_id }, + enable_batching: { ...enable_batching }, + batch_size: { ...batch_size } + }, + perform: async (request, { payload }) => { + const { email, list_id, external_id } = payload + if (!email && !external_id) { + throw new PayloadValidationError('One of Email or External Id is required') + } + const profileId = await createProfile(request, email, external_id) + return await addProfileToList(request, profileId, list_id) + }, + performBatch: async (request, { payload }) => { + // Filtering out profiles that do not contain either an email or external_id. + payload = payload.filter((profile) => profile.email || profile.external_id) + const listId = payload[0]?.list_id + const importJobPayload = createImportJobPayload(payload, listId) + return sendImportJobRequest(request, importJobPayload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/klaviyo/config.ts b/packages/destination-actions/src/destinations/klaviyo/config.ts new file mode 100644 index 0000000000..37fb5a6316 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/config.ts @@ -0,0 +1,2 @@ +export const API_URL = 'https://a.klaviyo.com/api' +export const REVISION_DATE = '2023-09-15' diff --git a/packages/destination-actions/src/destinations/klaviyo/functions.ts b/packages/destination-actions/src/destinations/klaviyo/functions.ts new file mode 100644 index 0000000000..55ac5f8a5f --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/functions.ts @@ -0,0 +1,165 @@ +import { APIError, RequestClient, DynamicFieldResponse } from '@segment/actions-core' +import { API_URL, REVISION_DATE } from './config' +import { + KlaviyoAPIError, + ListIdResponse, + ProfileData, + listData, + ImportJobPayload, + Profile, + GetProfileResponse +} from './types' +import { Payload } from './upsertProfile/generated-types' + +export async function getListIdDynamicData(request: RequestClient): Promise { + try { + const result: ListIdResponse = await request(`${API_URL}/lists/`, { + method: 'get' + }) + const choices = JSON.parse(result.content).data.map((list: { id: string; attributes: { name: string } }) => { + return { value: list.id, label: list.attributes.name } + }) + return { + choices + } + } catch (err) { + return { + choices: [], + nextPage: '', + error: { + message: (err as APIError).message ?? 'Unknown error', + code: (err as APIError).status + '' ?? 'Unknown error' + } + } + } +} + +export async function addProfileToList(request: RequestClient, id: string, list_id: string | undefined) { + const listData: listData = { + data: [ + { + type: 'profile', + id: id + } + ] + } + const list = await request(`${API_URL}/lists/${list_id}/relationships/profiles/`, { + method: 'POST', + json: listData + }) + return list +} + +export async function removeProfileFromList(request: RequestClient, ids: string[], list_id: string) { + const listData: listData = { + data: ids.map((id) => ({ type: 'profile', id })) + } + + const response = await request(`${API_URL}/lists/${list_id}/relationships/profiles/`, { + method: 'DELETE', + json: listData + }) + + return response +} + +export async function createProfile( + request: RequestClient, + email: string | undefined, + external_id: string | undefined +) { + try { + const profileData: ProfileData = { + data: { + type: 'profile', + attributes: { + email, + external_id + } + } + } + + const profile = await request(`${API_URL}/profiles/`, { + method: 'POST', + json: profileData + }) + const rs = await profile.json() + return rs.data.id + } catch (error) { + const { response } = error as KlaviyoAPIError + if (response.status == 409) { + const rs = await response.json() + return rs.errors[0].meta.duplicate_profile_id + } + } +} + +export function buildHeaders(authKey: string) { + return { + Authorization: `Klaviyo-API-Key ${authKey}`, + Accept: 'application/json', + revision: REVISION_DATE, + 'Content-Type': 'application/json' + } +} + +export const createImportJobPayload = (profiles: Payload[], listId?: string): { data: ImportJobPayload } => ({ + data: { + type: 'profile-bulk-import-job', + attributes: { + profiles: { + data: profiles.map(({ list_id, enable_batching, batch_size, ...attributes }) => ({ + type: 'profile', + attributes + })) + } + }, + ...(listId + ? { + relationships: { + lists: { + data: [{ type: 'list', id: listId }] + } + } + } + : {}) + } +}) + +export const sendImportJobRequest = async (request: RequestClient, importJobPayload: { data: ImportJobPayload }) => { + return await request(`${API_URL}/profile-bulk-import-jobs/`, { + method: 'POST', + headers: { + revision: '2023-10-15.pre' + }, + json: importJobPayload + }) +} + +export async function getProfiles( + request: RequestClient, + emails: string[] | undefined, + external_ids: string[] | undefined +): Promise { + const profileIds: string[] = [] + + if (external_ids?.length) { + const filterId = `external_id,["${external_ids.join('","')}"]` + const response = await request(`${API_URL}/profiles/?filter=any(${filterId})`, { + method: 'GET' + }) + const data: GetProfileResponse = await response.json() + profileIds.push(...data.data.map((profile: Profile) => profile.id)) + } + + if (emails?.length) { + const filterEmail = `email,["${emails.join('","')}"]` + const response = await request(`${API_URL}/profiles/?filter=any(${filterEmail})`, { + method: 'GET' + }) + const data: GetProfileResponse = await response.json() + profileIds.push(...data.data.map((profile: Profile) => profile.id)) + } + + return Array.from(new Set(profileIds)) +} diff --git a/packages/destination-actions/src/destinations/klaviyo/index.ts b/packages/destination-actions/src/destinations/klaviyo/index.ts index fa43643603..c0b552fc4e 100644 --- a/packages/destination-actions/src/destinations/klaviyo/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/index.ts @@ -1,11 +1,20 @@ -import type { DestinationDefinition } from '@segment/actions-core' +import { + IntegrationError, + AudienceDestinationDefinition, + PayloadValidationError, + APIError +} from '@segment/actions-core' import type { Settings } from './generated-types' +import { API_URL } from './config' import upsertProfile from './upsertProfile' +import addProfileToList from './addProfileToList' +import removeProfileFromList from './removeProfileFromList' +import trackEvent from './trackEvent' +import orderCompleted from './orderCompleted' +import { buildHeaders } from './functions' -const API_URL = 'https://a.klaviyo.com/api' - -const destination: DestinationDefinition = { +const destination: AudienceDestinationDefinition = { name: 'Klaviyo (Actions)', slug: 'actions-klaviyo', mode: 'cloud', @@ -14,7 +23,7 @@ const destination: DestinationDefinition = { scheme: 'custom', fields: { api_key: { - type: 'string', + type: 'password', label: 'API Key', description: `You can find this by going to Klaviyo's UI and clicking Account > Settings > API Keys > Create API Key`, required: true @@ -48,16 +57,75 @@ const destination: DestinationDefinition = { extendRequest({ settings }) { return { - headers: { - Authorization: `Klaviyo-API-Key ${settings.api_key}`, - Accept: 'application/json', - revision: new Date().toISOString().slice(0, 10) - } + headers: buildHeaders(settings.api_key) } }, + audienceFields: {}, + audienceConfig: { + mode: { + type: 'synced', + full_audience_sync: false + }, + async createAudience(request, createAudienceInput) { + const audienceName = createAudienceInput.audienceName + const apiKey = createAudienceInput.settings.api_key + if (!audienceName) { + throw new PayloadValidationError('Missing audience name value') + } + + if (!apiKey) { + throw new PayloadValidationError('Missing Api Key value') + } + + const response = await request(`${API_URL}/lists`, { + method: 'POST', + headers: buildHeaders(apiKey), + json: { + data: { type: 'list', attributes: { name: audienceName } } + } + }) + const r = await response.json() + return { + externalId: r.data.id + } + }, + async getAudience(request, getAudienceInput) { + const listId = getAudienceInput.externalId + const apiKey = getAudienceInput.settings.api_key + const response = await request(`${API_URL}/lists/${listId}`, { + method: 'GET', + headers: buildHeaders(apiKey), + throwHttpErrors: false + }) + + if (!response.ok) { + const errorResponse = await response.json() + const klaviyoErrorDetail = errorResponse.errors[0].detail + throw new APIError(klaviyoErrorDetail, response.status) + } + + const r = await response.json() + const externalId = r.data.id + if (externalId !== getAudienceInput.externalId) { + throw new IntegrationError( + "Unable to verify ownership over audience. Segment Audience ID doesn't match The Klaviyo List Id.", + 'INVALID_REQUEST_DATA', + 400 + ) + } + + return { + externalId + } + } + }, actions: { - upsertProfile + upsertProfile, + addProfileToList, + removeProfileFromList, + trackEvent, + orderCompleted } } diff --git a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..7cc6800285 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Klaviyo's orderCompleted destination action: all fields 1`] = ` +Object { + "data": Object { + "attributes": Object { + "metric": Object { + "data": Object { + "attributes": Object { + "name": "Order Completed", + }, + "type": "metric", + }, + }, + "profile": Object { + "data": Object { + "attributes": Object { + "email": "tobul@dij.uz", + "other_properties": Object { + "testType": "923^%f]tQn]lN2o", + }, + "phone_number": "923^%f]tQn]lN2o", + }, + "type": "profile", + }, + }, + "properties": Object { + "testType": "923^%f]tQn]lN2o", + }, + "time": "2021-02-01T00:00:00.000Z", + "value": 28946631197982.72, + }, + "type": "event", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/index.test.ts new file mode 100644 index 0000000000..2cc04f7318 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/index.test.ts @@ -0,0 +1,142 @@ +import nock from 'nock' +import { IntegrationError, createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { API_URL } from '../../config' + +const testDestination = createTestIntegration(Definition) +const apiKey = 'fake-api-key' + +export const settings = { + api_key: apiKey +} + +const createProfile = (email: string, phoneNumber: string) => ({ + data: { + type: 'profile', + attributes: { + email, + phone_number: phoneNumber + } + } +}) + +const createMetric = (name: string) => ({ + data: { + type: 'metric', + attributes: { + name + } + } +}) + +const createRequestBody = ( + properties: Record, + value: number, + metricName: string, + profile: { email: string; phone_number: string } +) => ({ + data: { + type: 'event', + attributes: { + properties, + value, + metric: createMetric(metricName), + profile: createProfile(profile.email, profile.phone_number) + } + } +}) + +describe('Order Completed', () => { + it('should throw error if no email or phone_number is provided', async () => { + const event = createTestEvent({ type: 'track' }) + const mapping = { profile: {}, metric_name: 'fake-name', properties: {} } + + await expect(testDestination.testAction('orderCompleted', { event, mapping, settings })).rejects.toThrowError( + IntegrationError + ) + }) + + it('should successfully track event if proper parameters are provided', async () => { + const profile = { email: 'test@example.com', phone_number: '1234567890' } + const properties = { key: 'value' } + const metricName = 'Order Completed' + const value = 10 + + const requestBody = createRequestBody(properties, value, metricName, profile) + + nock(`${API_URL}`).post('/events/', requestBody).reply(200, {}) + + const event = createTestEvent({ + type: 'track', + timestamp: '2022-01-01T00:00:00.000Z' + }) + + const mapping = { profile, metric_name: metricName, properties, value } + + await expect(testDestination.testAction('orderCompleted', { event, mapping, settings })).resolves.not.toThrowError() + }) + + it('should throw an error if the API request fails', async () => { + const profile = { email: 'test@example.com', phone_number: '1234567890' } + const properties = { key: 'value' } + const metricName = 'Order Completed' + const value = 10 + + const requestBody = createRequestBody(properties, value, metricName, profile) + + nock(`${API_URL}`).post('/events/', requestBody).reply(500, {}) + + const event = createTestEvent({ + type: 'track', + timestamp: '2022-01-01T00:00:00.000Z' + }) + + const mapping = { profile, metric_name: metricName, properties, value } + + await expect(testDestination.testAction('orderCompleted', { event, mapping, settings })).rejects.toThrowError( + 'Internal Server Error' + ) + }) + + it('should successfully track event with products array', async () => { + const products = [ + { + value: 10, + properties: { productKey: 'productValue' } + } + ] + + const profile = { email: 'test@example.com', phone_number: '1234567890' } + const properties = { key: 'value' } + const metricName = 'Order Completed' + const value = 10 + + const event = createTestEvent({ + type: 'track', + timestamp: '2022-01-01T00:00:00.000Z' + }) + + const mapping = { + profile, + metric_name: metricName, + properties, + value, + products: products + } + + const requestBodyForEvent = createRequestBody(properties, value, metricName, profile) + + nock(`${API_URL}`).post(`/events/`, requestBodyForEvent).reply(202, {}) + + const requestBodyForProduct = createRequestBody( + { ...products[0].properties, ...properties }, + products[0].value, + 'Ordered Product', + profile + ) + + nock(`${API_URL}`).post(`/events/`, requestBodyForProduct).reply(200, {}) + + await expect(testDestination.testAction('orderCompleted', { event, mapping, settings })).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..77f676c180 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/__tests__/snapshot.test.ts @@ -0,0 +1,42 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'orderCompleted' +const destinationSlug = 'Klaviyo' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/generated-types.ts new file mode 100644 index 0000000000..145dd8de6f --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/generated-types.ts @@ -0,0 +1,54 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Properties of the profile that triggered this event. + */ + profile: { + email?: string + phone_number?: string + other_properties?: { + [k: string]: unknown + } + } + /** + * Properties of this event. + */ + properties: { + [k: string]: unknown + } + /** + * When this event occurred. By default, the time the request was received will be used. + * The time is truncated to the second. The time must be after the year 2000 and can only + * be up to 1 year in the future. + * + */ + time?: string | number + /** + * A numeric value to associate with this event. For example, the dollar amount of a purchase. + */ + value?: number + /** + * A unique identifier for an event. If the unique_id is repeated for the same + * profile and metric, only the first processed event will be recorded. If this is not + * present, this will use the time to the second. Using the default, this limits only one + * event per profile per second. + * + */ + unique_id?: string + /** + * List of products purchased in the order. + */ + products?: { + /** + * A numeric value to associate with this event. For example, the dollar amount of a purchase. + */ + value?: number + /** + * Properties of this event. + */ + properties?: { + [k: string]: unknown + } + }[] +} diff --git a/packages/destination-actions/src/destinations/klaviyo/orderCompleted/index.ts b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/index.ts new file mode 100644 index 0000000000..d159825636 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/orderCompleted/index.ts @@ -0,0 +1,173 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { PayloadValidationError, RequestClient } from '@segment/actions-core' +import { API_URL } from '../config' +import { EventData } from '../types' + +const createEventData = (payload: Payload) => ({ + data: { + type: 'event', + attributes: { + properties: { ...payload.properties }, + time: payload.time, + value: payload.value, + metric: { + data: { + type: 'metric', + attributes: { + name: 'Order Completed' + } + } + }, + profile: { + data: { + type: 'profile', + attributes: { + ...payload.profile + } + } + } + } + } +}) + +const sendProductRequests = async (payload: Payload, orderEventData: EventData, request: RequestClient) => { + if (!payload.products || !Array.isArray(payload.products)) { + return + } + + const productPromises = payload.products.map((product) => { + const productEventData = { + data: { + type: 'event', + attributes: { + properties: { ...product.properties, ...orderEventData.data.attributes.properties }, + value: product.value, + metric: { + data: { + type: 'metric', + attributes: { + name: 'Ordered Product' + } + } + }, + time: orderEventData.data.attributes.time, + profile: orderEventData.data.attributes.profile + } + } + } + + return request(`${API_URL}/events/`, { + method: 'POST', + json: productEventData + }) + }) + + await Promise.all(productPromises) +} + +const action: ActionDefinition = { + title: 'Order Completed', + description: 'Order Completed Event action tracks users Order Completed events and associate it with their profile.', + defaultSubscription: 'type = "track"', + fields: { + profile: { + label: 'Profile', + description: `Properties of the profile that triggered this event.`, + type: 'object', + properties: { + email: { + label: 'Email', + type: 'string' + }, + phone_number: { + label: 'Phone Number', + type: 'string' + }, + other_properties: { + label: 'Other Properties', + type: 'object' + } + }, + required: true + }, + properties: { + description: `Properties of this event.`, + label: 'Properties', + type: 'object', + default: { + '@path': '$.properties' + }, + required: true + }, + time: { + label: 'Time', + description: `When this event occurred. By default, the time the request was received will be used. + The time is truncated to the second. The time must be after the year 2000 and can only + be up to 1 year in the future. + `, + type: 'datetime', + default: { + '@path': '$.timestamp' + } + }, + value: { + label: 'Value', + description: 'A numeric value to associate with this event. For example, the dollar amount of a purchase.', + type: 'number' + }, + unique_id: { + label: 'Unique ID', + description: `A unique identifier for an event. If the unique_id is repeated for the same + profile and metric, only the first processed event will be recorded. If this is not + present, this will use the time to the second. Using the default, this limits only one + event per profile per second. + `, + type: 'string', + default: { + '@path': '$.messageId' + } + }, + products: { + label: 'Products', + description: 'List of products purchased in the order.', + multiple: true, + type: 'object', + properties: { + value: { + label: 'Value', + description: 'A numeric value to associate with this event. For example, the dollar amount of a purchase.', + type: 'number' + }, + properties: { + description: `Properties of this event.`, + label: 'Properties', + type: 'object' + } + } + } + }, + + perform: async (request, { payload }) => { + const { email, phone_number } = payload.profile + + if (!email && !phone_number) { + throw new PayloadValidationError('One of Phone Number or Email is required.') + } + + const eventData = createEventData(payload) + + const event = await request(`${API_URL}/events/`, { + method: 'POST', + json: eventData + }) + + if (event.status == 202 && Array.isArray(payload.products)) { + await sendProductRequests(payload, eventData, request) + } + return event + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/klaviyo/properties.ts b/packages/destination-actions/src/destinations/klaviyo/properties.ts new file mode 100644 index 0000000000..bf6431f06a --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/properties.ts @@ -0,0 +1,44 @@ +import { InputField } from '@segment/actions-core/destination-kit/types' + +export const list_id: InputField = { + label: 'List Id', + description: `'Insert the ID of the default list that you'd like to subscribe users to when you call .identify().'`, + type: 'string', + default: { + '@path': '$.context.personas.external_audience_id' + }, + unsafe_hidden: true, + required: true +} + +export const email: InputField = { + label: 'Email', + description: `The user's email to send to Klavio.`, + type: 'string', + default: { + '@path': '$.context.traits.email' + }, + readOnly: true +} + +export const external_id: InputField = { + label: 'External ID', + description: `A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID and Email required.`, + type: 'string' +} + +export const enable_batching: InputField = { + type: 'boolean', + label: 'Batch Data to Klaviyo', + description: 'When enabled, the action will use the klaviyo batch API.', + default: true +} + +export const batch_size: InputField = { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + required: false, + unsafe_hidden: true, + default: 10000 +} diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/__tests__/index.test.ts new file mode 100644 index 0000000000..9bc01cffa0 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/__tests__/index.test.ts @@ -0,0 +1,348 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { API_URL } from '../../config' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import * as Functions from '../../functions' + +jest.mock('../../functions', () => ({ + ...jest.requireActual('../../functions'), + getProfiles: jest.fn(), + removeProfileFromList: jest.fn(() => Promise.resolve({ success: true })) +})) + +const testDestination = createTestIntegration(Definition) + +const apiKey = 'fake-api-key' + +export const settings = { + api_key: apiKey +} +const listId = 'XYZABC' + +const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + }, + { + type: 'profile', + id: 'XYZABD' + } + ] +} + +describe('Remove List from Profile', () => { + it('should throw error if no external_id/email is provided', async () => { + const event = createTestEvent({ + type: 'track', + properties: {} + }) + + await expect(testDestination.testAction('removeProfileFromList', { event, settings })).rejects.toThrowError( + AggregateAjvError + ) + }) + + it('should remove profile from list if successful with email address only', async () => { + const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + } + ] + } + + const email = 'test@example.com' + nock(`${API_URL}/profiles`) + .get(`/?filter=equals(email,"${email}")`) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + + nock(`${API_URL}/lists/${listId}`) + .delete('/relationships/profiles/', requestBody) + .reply(200, { + data: [ + { + id: 'XYZABC' + } + ] + }) + + const event = createTestEvent({ + type: 'track', + userId: '123', + context: { + personas: { + external_audience_id: listId + }, + traits: { + email: 'test@example.com' + } + } + }) + + await expect( + testDestination.testAction('removeProfileFromList', { event, settings, useDefaultMappings: true }) + ).resolves.not.toThrowError() + }) + + it('should remove profile from list if successful with External Id only', async () => { + const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + } + ] + } + + const external_id = 'testing_123' + nock(`${API_URL}/profiles`) + .get(`/?filter=equals(external_id,"${external_id}")`) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + + nock(`${API_URL}/lists/${listId}`) + .delete('/relationships/profiles/', requestBody) + .reply(200, { + data: [ + { + id: 'XYZABC' + } + ] + }) + + const event = createTestEvent({ + type: 'track', + userId: '123', + context: { + personas: { + external_audience_id: listId + } + }, + properties: { + external_id: 'testing_123' + } + }) + const mapping = { + list_id: listId, + external_id: 'testing_123' + } + + await expect( + testDestination.testAction('removeProfileFromList', { event, mapping, settings }) + ).resolves.not.toThrowError() + }) +}) + +describe('Remove List from Profile Batch', () => { + beforeEach(() => { + nock.cleanAll() + jest.resetAllMocks() + }) + afterEach(() => { + jest.resetAllMocks() + }) + + it('should remove multiple profiles with valid emails', async () => { + const events = [ + createTestEvent({ + properties: { + email: 'user1@example.com' + } + }), + createTestEvent({ + properties: { + email: 'user2@example.com' + } + }) + ] + const mapping = { + list_id: listId, + email: { + '@path': '$.properties.email' + } + } + + nock(`${API_URL}`) + .get('/profiles/') + .query({ + filter: 'any(email,["user1@example.com","user2@example.com"])' + }) + .reply(200, { + data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] + }) + + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) + + await expect( + testDestination.testBatchAction('removeProfileFromList', { + settings, + events, + mapping + }) + ).resolves.not.toThrowError() + }) + + it('should remove multiple profiles with valid external IDs', async () => { + const events = [ + createTestEvent({ + properties: { + external_id: 'externalId1' + } + }), + createTestEvent({ + properties: { + external_id: 'externalId2' + } + }) + ] + + const mapping = { + list_id: listId, + external_id: { + '@path': '$.properties.external_id' + } + } + + nock(`${API_URL}`) + .get('/profiles/') + .query({ + filter: 'any(external_id,["externalId1","externalId2"])' + }) + .reply(200, { + data: [{ id: 'XYZABC' }, { id: 'XYZABD' }] + }) + + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) + + await expect( + testDestination.testBatchAction('removeProfileFromList', { + settings, + events, + mapping + }) + ).resolves.not.toThrowError() + }) + + it('should remove profiles with valid emails and external IDs', async () => { + const events = [ + createTestEvent({ + properties: { + email: 'user1@example.com' + } + }), + createTestEvent({ + properties: { + external_id: 'externalId2' + } + }) + ] + + const mapping = { + list_id: listId, + external_id: { + '@path': '$.properties.external_id' + }, + email: { + '@path': '$.properties.email' + } + } + + nock(`${API_URL}`) + .get('/profiles/') + .query({ + filter: 'any(email,["user1@example.com"])' + }) + .reply(200, { + data: [{ id: 'XYZABD' }] + }) + + nock(`${API_URL}`) + .get('/profiles/') + .query({ + filter: 'any(external_id,["externalId2"])' + }) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) + + await expect( + testDestination.testBatchAction('removeProfileFromList', { + settings, + events, + mapping + }) + ).resolves.not.toThrowError() + }) + + it('should filter out profiles without email or external ID', async () => { + const events = [ + createTestEvent({ + properties: { + fake: 'property' + } + }), + createTestEvent({ + properties: { + email: 'valid@example.com' + } + }) + ] + + const mapping = { + list_id: listId, + external_id: { + '@path': '$.properties.external_id' + }, + email: { + '@path': '$.properties.email' + } + } + + const requestBody = { + data: [ + { + type: 'profile', + id: 'XYZABC' + } + ] + } + + nock(`${API_URL}`) + .get('/profiles/') + .query({ + filter: 'any(email,["valid@example.com"])' + }) + .reply(200, { + data: [{ id: 'XYZABC' }] + }) + + nock(`${API_URL}/lists/${listId}`).delete('/relationships/profiles/', requestBody).reply(200) + + await expect( + testDestination.testBatchAction('removeProfileFromList', { + settings, + events, + mapping + }) + ).resolves.not.toThrowError() + }) + + it('should handle an empty payload', async () => { + await testDestination.testBatchAction('removeProfileFromList', { + settings, + events: [] + }) + + expect(Functions.getProfiles).not.toHaveBeenCalled() + expect(Functions.removeProfileFromList).not.toHaveBeenCalled() + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts new file mode 100644 index 0000000000..2f68827f06 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/generated-types.ts @@ -0,0 +1,20 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user's email to send to Klavio. + */ + email?: string + /** + * A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID and Email required. + */ + external_id?: string + /** + * 'Insert the ID of the default list that you'd like to subscribe users to when you call .identify().' + */ + list_id: string + /** + * When enabled, the action will use the klaviyo batch API. + */ + enable_batching?: boolean +} diff --git a/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts new file mode 100644 index 0000000000..98ab3510b4 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/removeProfileFromList/index.ts @@ -0,0 +1,40 @@ +import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import { Payload } from './generated-types' + +import { getProfiles, removeProfileFromList } from '../functions' +import { email, list_id, external_id, enable_batching } from '../properties' + +const action: ActionDefinition = { + title: 'Remove profile from list', + description: 'Remove profile from list', + defaultSubscription: 'event = "Audience Exited"', + fields: { + email: { ...email }, + external_id: { ...external_id }, + list_id: { ...list_id }, + enable_batching: { ...enable_batching } + }, + perform: async (request, { payload }) => { + const { email, list_id, external_id } = payload + if (!email && !external_id) { + throw new PayloadValidationError('Missing Email or External Id') + } + const profileIds = await getProfiles(request, email ? [email] : undefined, external_id ? [external_id] : undefined) + return await removeProfileFromList(request, profileIds, list_id) + }, + performBatch: async (request, { payload }) => { + // Filtering out profiles that do not contain either an email or external_id. + const filteredPayload = payload.filter((profile) => profile.email || profile.external_id) + const listId = filteredPayload[0]?.list_id + const emails = filteredPayload.map((profile) => profile.email).filter((email) => email) as string[] + const externalIds = filteredPayload + .map((profile) => profile.external_id) + .filter((external_id) => external_id) as string[] + + const profileIds = await getProfiles(request, emails, externalIds) + return await removeProfileFromList(request, profileIds, listId) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..fed3b35186 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Klaviyo's trackEvent destination action: all fields 1`] = ` +Object { + "data": Object { + "attributes": Object { + "metric": Object { + "data": Object { + "attributes": Object { + "name": "]DD4LgSzT#hw(U]@J$a", + }, + "type": "metric", + }, + }, + "profile": Object { + "data": Object { + "attributes": Object { + "email": "so@uzwumiz.wf", + "other_properties": Object { + "testType": "]DD4LgSzT#hw(U]@J$a", + }, + "phone_number": "]DD4LgSzT#hw(U]@J$a", + }, + "type": "profile", + }, + }, + "properties": Object { + "testType": "]DD4LgSzT#hw(U]@J$a", + }, + "time": "2021-02-01T00:00:00.000Z", + "value": 71505359625256.95, + }, + "type": "event", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..d47efbdc9c --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/index.test.ts @@ -0,0 +1,121 @@ +import nock from 'nock' +import { IntegrationError, createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { API_URL } from '../../config' + +const testDestination = createTestIntegration(Definition) + +const apiKey = 'fake-api-key' + +export const settings = { + api_key: apiKey +} + +describe('Track Event', () => { + it('should throw error if no email or phone_number is provided', async () => { + const event = createTestEvent({ + type: 'track' + }) + const mapping = { profile: {}, metric_name: 'fake-name', properties: {} } + + await expect(testDestination.testAction('trackEvent', { event, mapping, settings })).rejects.toThrowError( + IntegrationError + ) + }) + + it('should successfully track event if proper parameters are provided', async () => { + const requestBody = { + data: { + type: 'event', + attributes: { + properties: { key: 'value' }, + time: '2022-01-01T00:00:00.000Z', + value: 10, + metric: { + data: { + type: 'metric', + attributes: { + name: 'event_name' + } + } + }, + profile: { + data: { + type: 'profile', + attributes: { + email: 'test@example.com', + phone_number: '1234567890' + } + } + } + } + } + } + + nock(`${API_URL}`).post('/events/', requestBody).reply(200, {}) + + const event = createTestEvent({ + type: 'track', + timestamp: '2022-01-01T00:00:00.000Z' + }) + + const mapping = { + profile: { email: 'test@example.com', phone_number: '1234567890' }, + metric_name: 'event_name', + properties: { key: 'value' }, + value: 10 + } + + await expect( + testDestination.testAction('trackEvent', { event, mapping, settings, useDefaultMappings: true }) + ).resolves.not.toThrowError() + }) + + it('should throw an error if the API request fails', async () => { + const requestBody = { + data: { + type: 'event', + attributes: { + properties: { key: 'value' }, + time: '2022-01-01T00:00:00.000Z', + value: 10, + metric: { + data: { + type: 'metric', + attributes: { + name: 'event_name' + } + } + }, + profile: { + data: { + type: 'profile', + attributes: { + email: 'test@example.com', + phone_number: '1234567890' + } + } + } + } + } + } + + nock(`${API_URL}`).post('/events/', requestBody).reply(500, {}) + + const event = createTestEvent({ + type: 'track', + timestamp: '2022-01-01T00:00:00.000Z' + }) + + const mapping = { + profile: { email: 'test@example.com', phone_number: '1234567890' }, + metric_name: 'event_name', + properties: { key: 'value' }, + value: 10 + } + + await expect( + testDestination.testAction('trackEvent', { event, mapping, settings, useDefaultMappings: true }) + ).rejects.toThrowError('Internal Server Error') + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..1ba428e5f2 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/trackEvent/__tests__/snapshot.test.ts @@ -0,0 +1,42 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'trackEvent' +const destinationSlug = 'Klaviyo' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/trackEvent/generated-types.ts new file mode 100644 index 0000000000..1fab8aed49 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/trackEvent/generated-types.ts @@ -0,0 +1,43 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Properties of the profile that triggered this event. + */ + profile: { + email?: string + phone_number?: string + other_properties?: { + [k: string]: unknown + } + } + /** + * Name of the event. Must be less than 128 characters. + */ + metric_name: string + /** + * Properties of this event. + */ + properties: { + [k: string]: unknown + } + /** + * When this event occurred. By default, the time the request was received will be used. + * The time is truncated to the second. The time must be after the year 2000 and can only + * be up to 1 year in the future. + * + */ + time?: string | number + /** + * A numeric value to associate with this event. For example, the dollar amount of a purchase. + */ + value?: number + /** + * A unique identifier for an event. If the unique_id is repeated for the same + * profile and metric, only the first processed event will be recorded. If this is not + * present, this will use the time to the second. Using the default, this limits only one + * event per profile per second. + * + */ + unique_id?: string +} diff --git a/packages/destination-actions/src/destinations/klaviyo/trackEvent/index.ts b/packages/destination-actions/src/destinations/klaviyo/trackEvent/index.ts new file mode 100644 index 0000000000..4824f240b6 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/trackEvent/index.ts @@ -0,0 +1,121 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +import { PayloadValidationError } from '@segment/actions-core' +import { API_URL } from '../config' + +const action: ActionDefinition = { + title: 'Track Event', + description: 'Track user events and associate it with their profile.', + defaultSubscription: 'type = "track"', + fields: { + profile: { + label: 'Profile', + description: `Properties of the profile that triggered this event.`, + type: 'object', + properties: { + email: { + label: 'Email', + type: 'string' + }, + phone_number: { + label: 'Phone Number', + type: 'string' + }, + other_properties: { + label: 'Other Properties', + type: 'object' + } + }, + required: true + }, + metric_name: { + label: 'Metric Name', + description: 'Name of the event. Must be less than 128 characters.', + type: 'string', + default: { + '@path': '$.event' + }, + required: true + }, + properties: { + description: `Properties of this event.`, + label: 'Properties', + type: 'object', + default: { + '@path': '$.properties' + }, + required: true + }, + time: { + label: 'Time', + description: `When this event occurred. By default, the time the request was received will be used. + The time is truncated to the second. The time must be after the year 2000 and can only + be up to 1 year in the future. + `, + type: 'datetime', + default: { + '@path': '$.timestamp' + } + }, + value: { + label: 'Value', + description: 'A numeric value to associate with this event. For example, the dollar amount of a purchase.', + type: 'number' + }, + unique_id: { + label: 'Unique ID', + description: `A unique identifier for an event. If the unique_id is repeated for the same + profile and metric, only the first processed event will be recorded. If this is not + present, this will use the time to the second. Using the default, this limits only one + event per profile per second. + `, + type: 'string', + default: { + '@path': '$.messageId' + } + } + }, + perform: (request, { payload }) => { + const { email, phone_number } = payload.profile + + if (!email && !phone_number) { + throw new PayloadValidationError('One of Phone Number or Email is required.') + } + + const eventData = { + data: { + type: 'event', + attributes: { + properties: { ...payload.properties }, + time: payload.time, + value: payload.value, + metric: { + data: { + type: 'metric', + attributes: { + name: payload.metric_name + } + } + }, + profile: { + data: { + type: 'profile', + attributes: { + ...payload.profile + } + } + } + } + } + } + + return request(`${API_URL}/events/`, { + method: 'POST', + json: eventData + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/klaviyo/types.ts b/packages/destination-actions/src/destinations/klaviyo/types.ts new file mode 100644 index 0000000000..0aea0f6dc7 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/types.ts @@ -0,0 +1,138 @@ +import { HTTPError } from '@segment/actions-core' +export class KlaviyoAPIError extends HTTPError { + response: Response & { + data: { + errors: Array<{ + id: string + status: number + code: string + title: string + detail: string + source: { + pointer: string + } + meta: { + duplicate_profile_id: string + } + }> + } + content: string + } +} + +export interface ProfileData { + data: { + type: string + id?: string + attributes: { + email?: string + external_id?: string + phone_number?: string + [key: string]: string | Record | undefined + } + } +} + +export interface EventData { + data: { + type: string + attributes: { + properties?: object + time?: string | number + value?: number + metric: { + data: { + type: string + attributes: { + name?: string + } + } + } + profile: { + data: { + type: string + attributes: { + email?: string + phone_number?: string + other_properties?: object + } + } + } + } + } +} + +export interface listData { + data: listAttributes[] +} + +export interface listAttributes { + type: string + id?: string +} + +export interface ListIdResponse { + content: string +} +export interface GetListResultContent { + data: { + id: string + attributes: { + name: string + } + }[] +} + +export interface Location { + address1?: string | null + address2?: string | null + city?: string | null + region?: string | null + zip?: string | null + latitude?: string | null + longitude?: string | null + country?: string | null +} + +export interface ProfileAttributes { + email?: string + phone_number?: string + external_id?: string + first_name?: string + last_name?: string + organization?: string + title?: string + image?: string + location?: Location | null + properties?: Record + list_id?: string +} + +export interface ImportJobPayload { + type: string + attributes: { + profiles: { + data: { + type: string + attributes: ProfileAttributes + }[] + } + } + relationships?: { + lists: { + data: { + type: string + id: string + }[] + } + } +} + +export interface Profile { + type: string + id: string +} + +export interface GetProfileResponse { + data: Profile[] +} diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..2d458c5e74 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Klaviyo's upsertProfile destination action: all fields 1`] = ` +Object { + "data": Object { + "attributes": Object { + "email": "el@ocbidoj.gw", + "external_id": "56GUhWJiuibf", + "first_name": "56GUhWJiuibf", + "image": "56GUhWJiuibf", + "last_name": "56GUhWJiuibf", + "location": Object { + "address1": "56GUhWJiuibf", + "address2": "56GUhWJiuibf", + "city": "56GUhWJiuibf", + "country": "56GUhWJiuibf", + "latitude": "56GUhWJiuibf", + "longitude": "56GUhWJiuibf", + "region": "56GUhWJiuibf", + "zip": "56GUhWJiuibf", + }, + "organization": "56GUhWJiuibf", + "phone_number": "56GUhWJiuibf", + "properties": Object { + "testType": "56GUhWJiuibf", + }, + "title": "56GUhWJiuibf", + }, + "type": "profile", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts new file mode 100644 index 0000000000..31bba326b6 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/index.test.ts @@ -0,0 +1,370 @@ +import nock from 'nock' +import { IntegrationError, createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../../index' +import { API_URL } from '../../config' +import * as Functions from '../../functions' + +const testDestination = createTestIntegration(Definition) + +const apiKey = 'fake-api-key' + +export const settings = { + api_key: apiKey +} + +jest.mock('../../functions', () => ({ + ...jest.requireActual('../../functions'), + addProfileToList: jest.fn(() => Promise.resolve()) +})) + +describe('Upsert Profile', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + it('should throw error if no email, phone_number, or external_id is provided', async () => { + const event = createTestEvent({ + type: 'track', + properties: {} + }) + + await expect(testDestination.testAction('upsertProfile', { event, settings })).rejects.toThrowError( + IntegrationError + ) + }) + + it('should create a new profile if successful', async () => { + const requestBody = { + data: { + type: 'profile', + attributes: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + location: {}, + properties: {} + } + } + } + + nock(`${API_URL}`).post('/profiles/', requestBody).reply(200, {}) + + const event = createTestEvent({ + type: 'track', + userId: '123', + traits: { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe' + } + }) + + await expect( + testDestination.testAction('upsertProfile', { event, settings, useDefaultMappings: true }) + ).resolves.not.toThrowError() + }) + + it('should update an existing profile if duplicate is found', async () => { + const requestBody = { + data: { + type: 'profile', + attributes: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + location: {}, + properties: {} + } + } + } + + const errorResponse = JSON.stringify({ + errors: [ + { + meta: { + duplicate_profile_id: '123' + } + } + ] + }) + + nock(`${API_URL}`).post('/profiles/', requestBody).reply(409, errorResponse) + + const updateRequestBody = { + data: { + type: 'profile', + id: '123', + attributes: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + location: {}, + properties: {} + } + } + } + nock(`${API_URL}`).patch('/profiles/123', updateRequestBody).reply(200, {}) + + const event = createTestEvent({ + type: 'track', + traits: { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe' + } + }) + + await expect( + testDestination.testAction('upsertProfile', { event, settings, useDefaultMappings: true }) + ).resolves.not.toThrowError() + }) + + it('should throw an error if the API request fails', async () => { + const requestBody = { + data: { + type: 'profile', + attributes: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + location: {}, + properties: {} + } + } + } + + nock(`${API_URL}`).post('/profiles/', requestBody).reply(500, {}) + + const event = createTestEvent({ + type: 'track', + traits: { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe' + } + }) + + await expect( + testDestination.testAction('upsertProfile', { event, settings, useDefaultMappings: true }) + ).rejects.toThrowError('Internal Server Error') + }) + + it('should add a profile to a list if list_id is provided', async () => { + const listId = 'abc123' + const profileId = '123' + const mapping = { list_id: 'abc123' } + + const requestBody = { + data: { + type: 'profile', + attributes: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + location: {}, + properties: {} + } + } + } + + nock(`${API_URL}`) + .post('/profiles/', requestBody) + .reply( + 200, + JSON.stringify({ + data: { + id: profileId + } + }) + ) + + nock(`${API_URL}`).post(`/lists/${listId}/relationships/profiles/`).reply(200) + + const event = createTestEvent({ + type: 'track', + userId: '123', + traits: { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + list_id: listId + } + }) + + await expect( + testDestination.testAction('upsertProfile', { event, mapping, settings, useDefaultMappings: true }) + ).resolves.not.toThrowError() + + expect(Functions.addProfileToList).toHaveBeenCalledWith(expect.anything(), profileId, listId) + }) + + it('should add an existing profile to a list if list_id is provided', async () => { + const listId = 'abc123' + const profileId = '123' + const mapping = { list_id: 'abc123' } + + const requestBody = { + data: { + type: 'profile', + attributes: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + location: {}, + properties: {} + } + } + } + + const errorResponse = JSON.stringify({ + errors: [ + { + meta: { + duplicate_profile_id: profileId + } + } + ] + }) + + nock(`${API_URL}`).post('/profiles/', requestBody).reply(409, errorResponse) + + const updateRequestBody = { + data: { + type: 'profile', + id: profileId, + attributes: { + email: 'test@example.com', + first_name: 'John', + last_name: 'Doe', + location: {}, + properties: {} + } + } + } + nock(`${API_URL}`).patch(`/profiles/${profileId}`, updateRequestBody).reply(200, {}) + + nock(`${API_URL}`).post(`/lists/${listId}/relationships/profiles/`).reply(200) + + const event = createTestEvent({ + type: 'track', + userId: '123', + traits: { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + list_id: listId + } + }) + + await expect( + testDestination.testAction('upsertProfile', { event, mapping, settings, useDefaultMappings: true }) + ).resolves.not.toThrowError() + + expect(Functions.addProfileToList).toHaveBeenCalledWith(expect.anything(), profileId, listId) + }) +}) + +describe('Upsert Profile Batch', () => { + beforeEach(() => { + nock.cleanAll() + jest.resetAllMocks() + }) + + it('should discard profiles without email, phone_number, or external_id', async () => { + const events = [createTestEvent({ traits: { first_name: 'John', last_name: 'Doe' } })] + + const response = await testDestination.testBatchAction('upsertProfile', { + settings, + events, + useDefaultMappings: true + }) + + expect(response).toEqual([]) + }) + + it('should process profiles with and without list_ids separately', async () => { + const eventWithListId = createTestEvent({ + traits: { first_name: 'John', last_name: 'Doe', email: 'withlist@example.com', list_id: 'abc123' } + }) + const eventWithoutListId = createTestEvent({ + traits: { first_name: 'Jane', last_name: 'Smith', email: 'withoutlist@example.com' } + }) + + nock(API_URL).post('/profile-bulk-import-jobs/').reply(200, { success: true, withList: true }) + nock(API_URL).post('/profile-bulk-import-jobs/').reply(200, { success: true, withoutList: true }) + + const responseWithList = await testDestination.testBatchAction('upsertProfile', { + settings, + events: [eventWithListId], + mapping: { list_id: 'abc123' }, + useDefaultMappings: true + }) + + const responseWithoutList = await testDestination.testBatchAction('upsertProfile', { + settings, + events: [eventWithoutListId], + mapping: {}, + useDefaultMappings: true + }) + + expect(responseWithList[0]).toMatchObject({ + data: { success: true, withList: true } + }) + + expect(responseWithoutList[0]).toMatchObject({ + data: { success: true, withoutList: true } + }) + }) + + it('should process profiles with list_ids only', async () => { + const events = [createTestEvent({ traits: { email: 'withlist@example.com', list_id: 'abc123' } })] + + nock(API_URL).post('/profile-bulk-import-jobs/').reply(200, { success: true, withList: true }) + + const response = await testDestination.testBatchAction('upsertProfile', { + settings, + events, + mapping: { list_id: 'abc123' }, + useDefaultMappings: true + }) + + expect(response[0].data).toMatchObject({ + success: true, + withList: true + }) + expect(response).toHaveLength(1) + }) + + it('should process profiles without list_ids only', async () => { + const events = [createTestEvent({ traits: { email: 'withoutlist@example.com' } })] + + nock(API_URL).post('/profile-bulk-import-jobs/').reply(200, { success: true, withoutList: true }) + + const response = await testDestination.testBatchAction('upsertProfile', { + settings, + events, + mapping: {}, + useDefaultMappings: true + }) + + expect(response[0].data).toMatchObject({ + success: true, + withoutList: true + }) + expect(response).toHaveLength(1) + }) + + it('should handle errors when sending profiles to Klaviyo', async () => { + const events = [createTestEvent({ traits: { email: 'error@example.com' } })] + + nock(API_URL).post('/profile-bulk-import-jobs/').reply(500, { error: 'Server error' }) + + await expect( + testDestination.testBatchAction('upsertProfile', { + settings, + events, + useDefaultMappings: true + }) + ).rejects.toThrow() + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..99ab6d2182 --- /dev/null +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/__tests__/snapshot.test.ts @@ -0,0 +1,45 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'upsertProfile' +const destinationSlug = 'Klaviyo' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/) + .persist() + .post(/.*/) + .reply(200, { data: { id: 'fake-id' } }) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts index dc26ac3f3e..c44441f23a 100644 --- a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/generated-types.ts @@ -2,7 +2,66 @@ export interface Payload { /** - * Placeholder + * Individual's email address. One of External ID, Phone Number and Email required. */ - placeholder?: string + email?: string + /** + * When enabled, the action will use the klaviyo batch API. + */ + enable_batching?: boolean + /** + * Individual's phone number in E.164 format. If SMS is not enabled and if you use Phone Number as identifier, then you have to provide one of Email or External ID. + */ + phone_number?: string + /** + * A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID, Phone Number and Email required. + */ + external_id?: string + /** + * Individual's first name. + */ + first_name?: string + /** + * Individual's last name. + */ + last_name?: string + /** + * Name of the company or organization within the company for whom the individual works. + */ + organization?: string + /** + * Individual's job title. + */ + title?: string + /** + * URL pointing to the location of a profile image. + */ + image?: string + /** + * Individual's address. + */ + location?: { + address1?: string | null + address2?: string | null + city?: string | null + region?: string | null + zip?: string | null + latitude?: string | null + longitude?: string | null + country?: string | null + } + /** + * An object containing key/value pairs for any custom properties assigned to this profile. + */ + properties?: { + [k: string]: unknown + } + /** + * The Klaviyo list to add the profile to. + */ + list_id?: string + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number } diff --git a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts index bb1cb0717a..ebcc5f106e 100644 --- a/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts +++ b/packages/destination-actions/src/destinations/klaviyo/upsertProfile/index.ts @@ -1,19 +1,225 @@ -import type { ActionDefinition } from '@segment/actions-core' +import type { ActionDefinition, DynamicFieldResponse } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' +import { API_URL } from '../config' +import { PayloadValidationError } from '@segment/actions-core' +import { KlaviyoAPIError, ProfileData } from '../types' +import { addProfileToList, createImportJobPayload, getListIdDynamicData, sendImportJobRequest } from '../functions' +import { batch_size } from '../properties' + const action: ActionDefinition = { title: 'Upsert Profile', description: 'Upsert user profile.', + defaultSubscription: 'type = "identify"', fields: { - placeholder: { - label: 'Placeholder', - description: 'Placeholder', + email: { + label: 'Email', + description: `Individual's email address. One of External ID, Phone Number and Email required.`, + type: 'string', + format: 'email', + default: { '@path': '$.traits.email' } + }, + enable_batching: { + type: 'boolean', + label: 'Batch Data to Klaviyo', + description: 'When enabled, the action will use the klaviyo batch API.' + }, + phone_number: { + label: 'Phone Number', + description: `Individual's phone number in E.164 format. If SMS is not enabled and if you use Phone Number as identifier, then you have to provide one of Email or External ID.`, + type: 'string', + default: { '@path': '$.context.traits.phone' } + }, + external_id: { + label: 'External ID', + description: `A unique identifier used by customers to associate Klaviyo profiles with profiles in an external system. One of External ID, Phone Number and Email required.`, type: 'string' + }, + first_name: { + label: 'First Name', + description: `Individual's first name.`, + type: 'string', + default: { '@path': '$.traits.firstName' } + }, + last_name: { + label: 'Last Name', + description: `Individual's last name.`, + type: 'string', + default: { '@path': '$.traits.lastName' } + }, + organization: { + label: 'Organization', + description: `Name of the company or organization within the company for whom the individual works.`, + type: 'string', + default: { '@path': '$.traits.company.name' } + }, + title: { + label: 'Title', + description: `Individual's job title.`, + type: 'string', + default: { '@path': '$.traits.title' } + }, + image: { + label: 'Image', + description: `URL pointing to the location of a profile image.`, + type: 'string', + default: { '@path': '$.traits.avatar' } + }, + location: { + label: 'Location', + description: `Individual's address.`, + type: 'object', + properties: { + address1: { + label: 'Address 1', + type: 'string', + allowNull: true + }, + address2: { + label: 'Address 2', + type: 'string', + allowNull: true + }, + city: { + label: 'City', + type: 'string', + allowNull: true + }, + region: { + label: 'Region', + type: 'string', + allowNull: true + }, + zip: { + label: 'ZIP', + type: 'string', + allowNull: true + }, + latitude: { + label: 'Latitude', + type: 'string', + allowNull: true + }, + longitude: { + label: 'Longitide', + type: 'string', + allowNull: true + }, + country: { + label: 'Country', + type: 'string', + allowNull: true + } + }, + default: { + city: { '@path': '$.traits.address.city' }, + region: { '@path': '$.traits.address.state' }, + zip: { '@path': '$.traits.address.postal_code' }, + address1: { '@path': '$.traits.address.street' }, + country: { '@path': '$.traits.address.country' } + } + }, + properties: { + description: 'An object containing key/value pairs for any custom properties assigned to this profile.', + label: 'Properties', + type: 'object', + default: { + '@path': '$.properties' + } + }, + list_id: { + label: 'List', + description: `The Klaviyo list to add the profile to.`, + type: 'string', + dynamic: true + }, + batch_size: { ...batch_size } + }, + dynamicFields: { + list_id: async (request): Promise => { + return getListIdDynamicData(request) + } + }, + perform: async (request, { payload }) => { + const { email, external_id, phone_number, list_id, enable_batching, batch_size, ...otherAttributes } = payload + + if (!email && !phone_number && !external_id) { + throw new PayloadValidationError('One of External ID, Phone Number and Email is required.') + } + + const profileData: ProfileData = { + data: { + type: 'profile', + attributes: { + email, + external_id, + phone_number, + ...otherAttributes + } + } + } + + try { + const profile = await request(`${API_URL}/profiles/`, { + method: 'POST', + json: profileData + }) + if (list_id) { + const content = JSON.parse(profile?.content) + const id = content.data.id + await addProfileToList(request, id, list_id) + } + return profile + } catch (error) { + const { response } = error as KlaviyoAPIError + + if (response?.status === 409) { + const content = JSON.parse(response?.content) + const id = content?.errors[0]?.meta?.duplicate_profile_id + + if (id) { + profileData.data.id = id + + const profile = await request(`${API_URL}/profiles/${id}`, { + method: 'PATCH', + json: profileData + }) + + if (list_id) { + await addProfileToList(request, id, list_id) + } + return profile + } + } + + throw error } }, - perform: () => { - return undefined + + performBatch: async (request, { payload }) => { + payload = payload.filter((profile) => profile.email || profile.external_id || profile.phone_number) + const profilesWithList = payload.filter((profile) => profile.list_id) + const profilesWithoutList = payload.filter((profile) => !profile.list_id) + + let importResponseWithList + let importResponseWithoutList + + if (profilesWithList.length > 0) { + const listId = profilesWithList[0].list_id + const importJobPayload = createImportJobPayload(profilesWithList, listId) + importResponseWithList = await sendImportJobRequest(request, importJobPayload) + } + + if (profilesWithoutList.length > 0) { + const importJobPayload = createImportJobPayload(profilesWithoutList) + importResponseWithoutList = await sendImportJobRequest(request, importJobPayload) + } + + return { + withList: importResponseWithList, + withoutList: importResponseWithoutList + } } } diff --git a/packages/destination-actions/src/destinations/launchdarkly-audiences/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/launchdarkly-audiences/__tests__/__snapshots__/snapshot.test.ts.snap index e492d76c9f..e0b1a2ed35 100644 --- a/packages/destination-actions/src/destinations/launchdarkly-audiences/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/launchdarkly-audiences/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,7 +4,7 @@ exports[`Testing snapshot for actions-launchdarkly-audiences destination: syncAu Object { "batch": Array [ Object { - "cohortId": "test_audience", + "cohortId": "uKRBKG", "cohortName": "Test audience", "userId": "uKRBKG", "value": true, diff --git a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/__snapshots__/snapshot.test.ts.snap index 7208f654bb..00e85df1c4 100644 --- a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/__snapshots__/snapshot.test.ts.snap @@ -4,7 +4,7 @@ exports[`Testing snapshot for LaunchDarklyAudiences's syncAudience destination a Object { "batch": Array [ Object { - "cohortId": "test_audience", + "cohortId": "ld_segment_audience_id", "cohortName": "Test audience", "userId": "&bUSudpNPsUWT", "value": true, @@ -38,7 +38,7 @@ exports[`Testing snapshot for LaunchDarklyAudiences's syncAudience destination a Object { "batch": Array [ Object { - "cohortId": "test_audience", + "cohortId": "ld_segment_audience_id", "cohortName": "Test audience", "userId": "&bUSudpNPsUWT", "value": false, diff --git a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/index.test.ts index 3f8dec26dd..434cf766e1 100644 --- a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/index.test.ts @@ -10,7 +10,8 @@ const goodTrackEvent = createTestEvent({ context: { personas: { computation_class: 'audience', - computation_key: 'ld_segment_test' + computation_key: 'ld_segment_test', + computation_id: 'ld_segment_audience_id' }, traits: { email: 'test@email.com' @@ -27,7 +28,8 @@ const goodIdentifyEvent = createTestEvent({ context: { personas: { computation_class: 'audience', - computation_key: 'ld_segment_test' + computation_key: 'ld_segment_test', + computation_id: 'ld_segment_audience_id' } }, traits: { @@ -40,7 +42,8 @@ const goodIdentifyEvent = createTestEvent({ const badEvent = createTestEvent({ context: { personas: { - computation_key: 'ld_segment_test' + computation_key: 'ld_segment_test', + computation_id: 'ld_segment_audience_id' }, traits: { email: 'test@email.com' diff --git a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/snapshot.test.ts index fde8ba14bc..b8e6e36adf 100644 --- a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/__tests__/snapshot.test.ts @@ -13,11 +13,13 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) eventData['segment_audience_key'] = 'test_audience' + eventData['segment_audience_id'] = 'ld_segment_audience_id' eventData['segment_computation_action'] = 'audience' eventData['traits_or_props'] = { [eventData['segment_audience_key']]: true } setStaticDataForSnapshot(eventData, 'context_kind', 'customContextKind') setStaticDataForSnapshot(eventData, 'context_key', 'user_id_only') + setStaticDataForSnapshot(eventData, 'context_id', 'ld_segment_audience_id') setStaticDataForSnapshot(settingsData, 'clientId', 'environment-id') setStaticDataForSnapshot(settingsData, 'apiKey', 'api-key') @@ -50,6 +52,7 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) eventData['segment_audience_key'] = 'test_audience' + eventData['segment_audience_id'] = 'ld_segment_audience_id' eventData['segment_computation_action'] = 'audience' eventData['traits_or_props'] = { [eventData['segment_audience_key']]: false } diff --git a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/custom-audience-operations.ts b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/custom-audience-operations.ts index 67d260274f..c70a6e7c39 100644 --- a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/custom-audience-operations.ts +++ b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/custom-audience-operations.ts @@ -34,9 +34,14 @@ const snakeCaseToSentenceCase = (key: string) => { * @param audienceId audience ID * @param include include or exclude the context from LaunchDarkly's segment */ -const createContextForBatch = (contextKey: string, audienceId: string, audienceAction: AudienceAction) => ({ +const createContextForBatch = ( + contextKey: string, + audienceKey: string, + audienceId: string, + audienceAction: AudienceAction +) => ({ userId: contextKey, - cohortName: snakeCaseToSentenceCase(audienceId), + cohortName: snakeCaseToSentenceCase(audienceKey), cohortId: audienceId, value: audienceAction === CONSTANTS.ADD ? true : false }) @@ -78,7 +83,7 @@ const parseCustomAudienceBatches = (payload: Payload[], settings: Settings): Aud const audienceMap = new Map() for (const p of payload) { - const audienceId = p.segment_audience_key + const audienceId = p.segment_audience_id const contextKey = getContextKey(p) let audienceBatch: AudienceBatch = { @@ -97,7 +102,9 @@ const parseCustomAudienceBatches = (payload: Payload[], settings: Settings): Aud audienceMap.set(audienceId, audienceBatch) } - audienceBatch.batch.push(createContextForBatch(contextKey, audienceId, p.audience_action as AudienceAction)) + audienceBatch.batch.push( + createContextForBatch(contextKey, p.segment_audience_key, audienceId, p.audience_action as AudienceAction) + ) } return Array.from(audienceMap.values()) diff --git a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/generated-types.ts index 05e81e9c04..2cc64c90d7 100644 --- a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/generated-types.ts @@ -1,6 +1,10 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { + /** + * Segment Audience ID to which user identifier should be added or removed + */ + segment_audience_id: string /** * Segment Audience key to which user identifier should be added or removed */ diff --git a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/index.ts b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/index.ts index edfc87cc95..ba8e88d780 100644 --- a/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/launchdarkly-audiences/syncAudience/index.ts @@ -10,10 +10,21 @@ const action: ActionDefinition = { description: 'Sync Engage Audiences to LaunchDarkly segments', defaultSubscription: 'type = "identify" or type = "track"', fields: { + segment_audience_id: { + label: 'Audience ID', + description: 'Segment Audience ID to which user identifier should be added or removed', + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_id' + } + }, segment_audience_key: { label: 'Audience Key', description: 'Segment Audience key to which user identifier should be added or removed', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_key' @@ -23,7 +34,8 @@ const action: ActionDefinition = { label: 'Segment Computation Action', description: "Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'.", - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_class' @@ -41,21 +53,24 @@ const action: ActionDefinition = { segment_user_id: { label: 'Segment User ID', description: 'The Segment userId value.', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@path': '$.userId' } }, segment_anonymous_id: { label: 'Segment Anonymous ID', description: 'The Segment anonymousId value.', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@path': '$.anonymousId' } }, user_email: { label: 'Email address', description: "The user's email address", - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: false, default: { '@if': { @@ -84,6 +99,7 @@ const action: ActionDefinition = { description: 'A computed object for track and identify events. This field should not need to be edited.', type: 'object', required: true, + unsafe_hidden: true, default: { '@if': { exists: { '@path': '$.properties' }, @@ -103,7 +119,8 @@ const action: ActionDefinition = { audience_action: { label: 'Audience Action', description: 'Indicates if the user will be added or removed from the Audience', - type: 'hidden', + type: 'string', + unsafe_hidden: true, choices: [ { label: CONSTANTS.ADD as AudienceAction, value: CONSTANTS.ADD as AudienceAction }, { label: CONSTANTS.REMOVE as AudienceAction, value: CONSTANTS.REMOVE as AudienceAction } diff --git a/packages/destination-actions/src/destinations/launchdarkly/__tests__/index.test.ts b/packages/destination-actions/src/destinations/launchdarkly/__tests__/index.test.ts index 4512724a21..9466b171f9 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/__tests__/index.test.ts @@ -22,5 +22,13 @@ describe('LaunchDarkly', () => { } await expect(testDestination.testAuthentication(authData)).rejects.toThrowError() }) + it('should succeed if a custom host name is provided', async () => { + nock(`https://clientsdk.launchdarkly.com`).head(`/sdk/goals/invalid`).reply(404, {}) + const authData = { + client_id: 'anything', + events_host_name: 'events2.launchdarkly.com' + } + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) }) }) diff --git a/packages/destination-actions/src/destinations/launchdarkly/aliasUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/launchdarkly/aliasUser/__tests__/index.test.ts index 526d5563c3..c6d349a28a 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/aliasUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/aliasUser/__tests__/index.test.ts @@ -39,6 +39,37 @@ describe('LaunchDarkly.aliasUser', () => { ]) }) + it('should send an alias event to a custom host for identify events when specified in the settings', async () => { + const customEventsHost = 'events2.launchdarkly.com' + const settingsWithCustomHost: Settings = { ...testSettings, events_host_name: customEventsHost } + nock(`https://${customEventsHost}`).post(`/events/bulk/${testSettings.client_id}`).reply(202) + + const event = createTestEvent({ + type: 'identify', + userId: 'user1234', + anonymousId: '701a9c00-aabe-4074-80b7-0fd6cab41c08', + timestamp: '2022-03-30T17:24:58Z' + }) + + const responses = await testDestination.testAction(actionSlug, { + event, + settings: settingsWithCustomHost, + useDefaultMappings: true + }) + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(202) + expect(responses[0].options.json).toMatchObject([ + { + kind: 'alias', + key: 'user1234', + previousKey: '701a9c00-aabe-4074-80b7-0fd6cab41c08', + contextKind: 'user', + previousContextKind: 'anonymousUser', + creationDate: 1648661098000 + } + ]) + }) + it('should send an identify event to LaunchDarkly for alias events with default mapping', async () => { nock('https://events.launchdarkly.com').post(`/events/bulk/${testSettings.client_id}`).reply(202) diff --git a/packages/destination-actions/src/destinations/launchdarkly/aliasUser/index.ts b/packages/destination-actions/src/destinations/launchdarkly/aliasUser/index.ts index 4cb1b374be..7bf9ac4b40 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/aliasUser/index.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/aliasUser/index.ts @@ -62,7 +62,7 @@ const action: ActionDefinition = { perform: (request, { payload, settings }) => { const event = convertPayloadToLDEvent(payload) - return request(getEventsUrl(settings.client_id), { + return request(getEventsUrl(settings), { method: 'post', json: [event] }) diff --git a/packages/destination-actions/src/destinations/launchdarkly/generated-types.ts b/packages/destination-actions/src/destinations/launchdarkly/generated-types.ts index 6e1bbbe08e..9b11acc991 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/generated-types.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/generated-types.ts @@ -5,4 +5,8 @@ export interface Settings { * Find and copy the client-side ID in the LaunchDarkly account settings page. */ client_id: string + /** + * Your LaunchDarkly events host name. If not specified, the default value of events.launchdarkly.com will be used. Most customers will not need to change this setting. + */ + events_host_name?: string } diff --git a/packages/destination-actions/src/destinations/launchdarkly/index.ts b/packages/destination-actions/src/destinations/launchdarkly/index.ts index e09ee74df6..01d59b140c 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/index.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/index.ts @@ -3,6 +3,7 @@ import type { Settings } from './generated-types' import aliasUser from './aliasUser' import trackEvent from './trackEvent' +import { DEFAULT_EVENTS_HOST_NAME } from './utils' const presets: Preset[] = [ { @@ -36,9 +37,23 @@ const destination: DestinationDefinition = { description: 'Find and copy the client-side ID in the LaunchDarkly account settings page.', type: 'string', required: true + }, + events_host_name: { + label: 'LaunchDarkly events host name', + description: `Your LaunchDarkly events host name. If not specified, the default value of ${DEFAULT_EVENTS_HOST_NAME} will be used. Most customers will not need to change this setting.`, + type: 'string', + default: DEFAULT_EVENTS_HOST_NAME, + required: false, + format: 'hostname' } }, testAuthentication: (request, { settings }) => { + // The endpoint we are using to validate the clientID is only compatible with the default host name so we only + // validate it if the default host name is provided. + const hostname = settings.events_host_name || DEFAULT_EVENTS_HOST_NAME + if (hostname !== DEFAULT_EVENTS_HOST_NAME) { + return true + } // The sdk/goals/{clientID} endpoint returns a 200 if the client ID is valid and a 404 otherwise. return request(`https://clientsdk.launchdarkly.com/sdk/goals/${settings.client_id}`, { method: 'head' }) } @@ -47,7 +62,7 @@ const destination: DestinationDefinition = { extendRequest: () => { return { headers: { - 'User-Agent': 'SegmentDestination/2.1.0', + 'User-Agent': 'SegmentDestination/2.2.0', 'Content-Type': 'application/json', 'X-LaunchDarkly-Event-Schema': '4' } diff --git a/packages/destination-actions/src/destinations/launchdarkly/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/launchdarkly/trackEvent/__tests__/index.test.ts index efed24fe8c..b7bc1c13dc 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/trackEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/trackEvent/__tests__/index.test.ts @@ -43,6 +43,41 @@ describe('LaunchDarkly.trackEvent', () => { ]) }) + it('should send custom events to a custom host when specified in the settings', async () => { + const customEventsHost = 'events2.launchdarkly.com' + const settingsWithCustomHost = { ...testSettings, events_host_name: customEventsHost } + nock(`https://${customEventsHost}`).post(`/events/bulk/${testSettings.client_id}`).reply(202) + + const event = createTestEvent({ + type: 'track', + event: 'Test Event', + userId: 'user1234', + anonymousId: '72d7bed1-4f42-4f2f-8955-72677340546b', + timestamp: '2022-03-30T17:24:58Z', + properties: { + revenue: 123.456 + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event, + settings: settingsWithCustomHost, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(202) + expect(responses[0].options.json).toMatchObject([ + { + key: 'Test Event', + contextKeys: { user: 'user1234', unauthenticatedUser: '72d7bed1-4f42-4f2f-8955-72677340546b' }, + kind: 'custom', + metricValue: 123.456, + creationDate: 1648661098000 + } + ]) + }) + it('should use custom context kinds if provided', async () => { nock('https://events.launchdarkly.com').post(`/events/bulk/${testSettings.client_id}`).reply(202) diff --git a/packages/destination-actions/src/destinations/launchdarkly/trackEvent/index.ts b/packages/destination-actions/src/destinations/launchdarkly/trackEvent/index.ts index 255618b19e..53201dadcf 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/trackEvent/index.ts @@ -116,7 +116,7 @@ const action: ActionDefinition = { }, perform: (request, { payload, settings }) => { const event = convertPayloadToLDEvent(payload) - return request(getEventsUrl(settings.client_id), { + return request(getEventsUrl(settings), { method: 'post', json: [event] }) diff --git a/packages/destination-actions/src/destinations/launchdarkly/utils.ts b/packages/destination-actions/src/destinations/launchdarkly/utils.ts index 68aad68c6c..6afd035921 100644 --- a/packages/destination-actions/src/destinations/launchdarkly/utils.ts +++ b/packages/destination-actions/src/destinations/launchdarkly/utils.ts @@ -1,8 +1,11 @@ import dayjs from '../../lib/dayjs' -const BULK_EVENTS_BASE_URL = 'https://events.launchdarkly.com/events/bulk' +import { Settings } from './generated-types' +export const DEFAULT_EVENTS_HOST_NAME = 'events.launchdarkly.com' +const BULK_EVENTS_PATH = 'events/bulk' -export const getEventsUrl = (clientID: string) => { - return `${BULK_EVENTS_BASE_URL}/${clientID}` +export const getEventsUrl = (settings: Settings) => { + const { client_id, events_host_name } = settings + return `https://${events_host_name || DEFAULT_EVENTS_HOST_NAME}/${BULK_EVENTS_PATH}/${client_id}` } export const parseTimestamp = (ts?: string | number): number => { diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap index d46798ee22..da1bcfcfa5 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/linkedin-audiences/__tests__/__snapshots__/snapshot.test.ts.snap @@ -13,7 +13,7 @@ Headers { "Bearer undefined", ], "linkedin-version": Array [ - "202307", + "202311", ], "user-agent": Array [ "Segment (Actions)", diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts b/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts index 4231561981..a12b2870f2 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/constants.ts @@ -1,3 +1,3 @@ -export const LINKEDIN_API_VERSION = '202307' +export const LINKEDIN_API_VERSION = '202311' export const BASE_URL = 'https://api.linkedin.com/rest' export const LINKEDIN_SOURCE_PLATFORM = 'SEGMENT' diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap index c6e95a4017..37d64c740e 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/__tests__/__snapshots__/snapshot.test.ts.snap @@ -13,7 +13,7 @@ Headers { "Bearer undefined", ], "linkedin-version": Array [ - "202307", + "202311", ], "user-agent": Array [ "Segment (Actions)", diff --git a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts index 9d1f856743..6917684cc2 100644 --- a/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts +++ b/packages/destination-actions/src/destinations/linkedin-audiences/updateAudience/index.ts @@ -1,4 +1,4 @@ -import type { ActionDefinition } from '@segment/actions-core' +import type { ActionDefinition, StatsContext } from '@segment/actions-core' import { RequestClient, RetryableError, IntegrationError } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' @@ -28,7 +28,8 @@ const action: ActionDefinition = { email: { label: 'User Email', description: "The user's email address to send to LinkedIn.", - type: 'hidden', // This field is hidden from customers because the desired value always appears at path '$.context.traits.email' in Personas events. + type: 'string', + unsafe_hidden: true, // This field is hidden from customers because the desired value always appears at path '$.context.traits.email' in Personas events. default: { '@path': '$.context.traits.email' } @@ -36,7 +37,8 @@ const action: ActionDefinition = { google_advertising_id: { label: 'User Google Advertising ID', description: "The user's Google Advertising ID to send to LinkedIn.", - type: 'hidden', // This field is hidden from customers because the desired value always appears at path '$.context.device.advertisingId' in Personas events. + type: 'string', + unsafe_hidden: true, // This field is hidden from customers because the desired value always appears at path '$.context.device.advertisingId' in Personas events. default: { '@path': '$.context.device.advertisingId' } @@ -45,7 +47,8 @@ const action: ActionDefinition = { label: 'LinkedIn Source Segment ID', description: "A Segment-specific key associated with the LinkedIn DMP Segment. This is the lookup key Segment uses to fetch the DMP Segment from LinkedIn's API.", - type: 'hidden', // This field is hidden from customers because the desired value always appears at '$.properties.audience_key' in Personas events. + type: 'string', + unsafe_hidden: true, // This field is hidden from customers because the desired value always appears at '$.properties.audience_key' in Personas events. default: { '@path': '$.properties.audience_key' } @@ -60,7 +63,8 @@ const action: ActionDefinition = { event_name: { label: 'Event Name', description: 'The name of the current Segment event.', - type: 'hidden', // This field is hidden from customers because the desired value always appears at path '$.event' in Personas events. + type: 'string', + unsafe_hidden: true, // This field is hidden from customers because the desired value always appears at path '$.event' in Personas events. default: { '@path': '$.event' } @@ -77,20 +81,25 @@ const action: ActionDefinition = { default: 'AUTO' } }, - perform: async (request, { settings, payload }) => { - return processPayload(request, settings, [payload]) + perform: async (request, { settings, payload, statsContext }) => { + return processPayload(request, settings, [payload], statsContext) }, - performBatch: async (request, { settings, payload }) => { - return processPayload(request, settings, payload) + performBatch: async (request, { settings, payload, statsContext }) => { + return processPayload(request, settings, payload, statsContext) } } -async function processPayload(request: RequestClient, settings: Settings, payloads: Payload[]) { +async function processPayload( + request: RequestClient, + settings: Settings, + payloads: Payload[], + statsContext: StatsContext | undefined +) { validate(settings, payloads) const linkedinApiClient: LinkedInAudiences = new LinkedInAudiences(request) - const dmpSegmentId = await getDmpSegmentId(linkedinApiClient, settings, payloads[0]) + const dmpSegmentId = await getDmpSegmentId(linkedinApiClient, settings, payloads[0], statsContext) const elements = extractUsers(settings, payloads) // We should never hit this condition because at least an email or a @@ -102,7 +111,10 @@ async function processPayload(request: RequestClient, settings: Settings, payloa if (elements.length < 1) { return } - + statsContext?.statsClient?.incr('oauth_app_api_call', 1, [ + ...statsContext?.tags, + `endpoint:add-or-remove-users-from-dmpSegment` + ]) const res = await linkedinApiClient.batchUpdate(dmpSegmentId, elements) // At this point, if LinkedIn's API returns a 404 error, it's because the audience @@ -135,18 +147,29 @@ function validate(settings: Settings, payloads: Payload[]): void { } } -async function getDmpSegmentId(linkedinApiClient: LinkedInAudiences, settings: Settings, payload: Payload) { +async function getDmpSegmentId( + linkedinApiClient: LinkedInAudiences, + settings: Settings, + payload: Payload, + statsContext: StatsContext | undefined +) { + statsContext?.statsClient?.incr('oauth_app_api_call', 1, [...statsContext?.tags, `endpoint:get-dmpSegment`]) const res = await linkedinApiClient.getDmpSegment(settings, payload) const body = await res.json() if (body.elements?.length > 0) { return body.elements[0].id } - - return createDmpSegment(linkedinApiClient, settings, payload) + return createDmpSegment(linkedinApiClient, settings, payload, statsContext) } -async function createDmpSegment(linkedinApiClient: LinkedInAudiences, settings: Settings, payload: Payload) { +async function createDmpSegment( + linkedinApiClient: LinkedInAudiences, + settings: Settings, + payload: Payload, + statsContext: StatsContext | undefined +) { + statsContext?.statsClient?.incr('oauth_app_api_call', 1, [...statsContext?.tags, `endpoint:create-dmpSegment`]) const res = await linkedinApiClient.createDmpSegment(settings, payload) const headers = res.headers.toJSON() return headers['x-linkedin-id'] diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/__tests__/index.test.ts b/packages/destination-actions/src/destinations/linkedin-conversions/__tests__/index.test.ts new file mode 100644 index 0000000000..2427142934 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/__tests__/index.test.ts @@ -0,0 +1,43 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { BASE_URL } from '../constants' + +const testDestination = createTestIntegration(Definition) + +const validSettings = { + oauth: { + access_token: '123', + refresh_token: '123' + } +} + +describe('Linkedin Conversions Api', () => { + describe('testAuthentication', () => { + it('should not throw an error if all the appropriate credentials are available', async () => { + const mockProfileResponse = { + id: '123' + } + + // Validate that the user exists in LinkedIn. + nock(`${BASE_URL}/me`).get(/.*/).reply(200, mockProfileResponse) + + await expect(testDestination.testAuthentication(validSettings)).resolves.not.toThrowError() + }) + + it('should throw an error if the user has not completed the oauth flow', async () => { + const invalidOauth = {} + await expect(testDestination.testAuthentication(invalidOauth)).rejects.toThrowError( + 'Credentials are invalid: Please authenticate via Oauth before enabling the destination.' + ) + }) + + it('should throw an error if the oauth token is invalid', async () => { + nock(`${BASE_URL}/me`).get(/.*/).reply(401) + + await expect(testDestination.testAuthentication(validSettings)).rejects.toThrowError( + 'Credentials are invalid: Invalid LinkedIn Oauth access token. Please reauthenticate to retrieve a valid access token before enabling the destination.' + ) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/api/api.test.ts b/packages/destination-actions/src/destinations/linkedin-conversions/api/api.test.ts new file mode 100644 index 0000000000..114d384fdc --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/api/api.test.ts @@ -0,0 +1,396 @@ +import nock from 'nock' +import createRequestClient from '../../../../../core/src/create-request-client' +import { LinkedInConversions } from '../api' +import { BASE_URL } from '../constants' +import { HookBundle } from '../streamConversion/generated-types' + +const requestClient = createRequestClient() + +describe('LinkedIn Conversions', () => { + describe('conversionRule methods', () => { + const linkedIn: LinkedInConversions = new LinkedInConversions(requestClient) + const adAccountId = 'urn:li:sponsoredAccount:123456' + const hookInputs: HookBundle['onMappingSave']['inputs'] = { + adAccountId, + name: 'A different name that should trigger an update', + conversionType: 'PURCHASE', + attribution_type: 'LAST_TOUCH_BY_CAMPAIGN', + post_click_attribution_window_size: 30, + view_through_attribution_window_size: 7 + } + + const hookOutputs: HookBundle['onMappingSave']['outputs'] = { + id: '56789', + name: 'The original name', + conversionType: 'LEAD', + attribution_type: 'LAST_TOUCH_BY_CONVERSION', + post_click_attribution_window_size: 30, + view_through_attribution_window_size: 7 + } + + it('should update a conversion rule', async () => { + nock(`${BASE_URL}`) + .post(`/conversions/${hookOutputs.id}`, { + patch: { + $set: { + name: hookInputs.name, + type: hookInputs.conversionType, + attributionType: hookInputs.attribution_type + } + } + }) + .query({ + account: adAccountId + }) + .reply(204) + + const updateResult = await linkedIn.updateConversionRule(hookInputs, hookOutputs) + + expect(updateResult).toEqual({ + successMessage: `Conversion rule ${hookOutputs.id} updated successfully!`, + savedData: { + id: hookOutputs.id, + name: hookInputs.name, + conversionType: hookInputs.conversionType, + attribution_type: hookInputs.attribution_type, + post_click_attribution_window_size: hookOutputs.post_click_attribution_window_size, + view_through_attribution_window_size: hookOutputs.view_through_attribution_window_size + } + }) + }) + + it('should create a conversion rule', async () => { + const mockReturnedId = '12345' + + nock(`${BASE_URL}`) + .post(`/conversions`, { + name: hookInputs.name, + account: adAccountId, + conversionMethod: 'CONVERSIONS_API', + postClickAttributionWindowSize: hookInputs.post_click_attribution_window_size, + viewThroughAttributionWindowSize: hookInputs.view_through_attribution_window_size, + attributionType: hookInputs.attribution_type, + type: hookInputs.conversionType + }) + .reply(201, { + id: mockReturnedId, + name: hookInputs.name, + type: hookInputs.conversionType, + attributionType: hookInputs.attribution_type, + postClickAttributionWindowSize: hookInputs.post_click_attribution_window_size, + viewThroughAttributionWindowSize: hookInputs.view_through_attribution_window_size + }) + const createResult = await linkedIn.createConversionRule(hookInputs) + + expect(createResult).toEqual({ + successMessage: `Conversion rule ${mockReturnedId} created successfully!`, + savedData: { + id: mockReturnedId, + name: hookInputs.name, + conversionType: hookInputs.conversionType, + attribution_type: hookInputs.attribution_type, + post_click_attribution_window_size: hookInputs.post_click_attribution_window_size, + view_through_attribution_window_size: hookInputs.view_through_attribution_window_size + } + }) + }) + + it('should use the existing conversionRuleId if passed in and not update anything', async () => { + const existingRule = { + id: '5678', + name: 'Exists already', + type: 'PURCHASE', + attributionType: 'LAST_TOUCH_BY_CAMPAIGN', + postClickAttributionWindowSize: 1, + viewThroughAttributionWindowSize: 1 + } + + nock(`${BASE_URL}`) + .get(`/conversions/${existingRule.id}`) + .query({ account: adAccountId }) + .reply(200, existingRule) + + const updateResult = await linkedIn.updateConversionRule( + { ...hookInputs, conversionRuleId: existingRule.id }, + hookOutputs + ) + + expect(updateResult).toEqual({ + successMessage: `Using existing Conversion Rule: ${existingRule.id} `, + savedData: { + id: existingRule.id, + name: existingRule.name, + conversionType: existingRule.type, + attribution_type: existingRule.attributionType, + post_click_attribution_window_size: existingRule.postClickAttributionWindowSize, + view_through_attribution_window_size: existingRule.viewThroughAttributionWindowSize + } + }) + }) + + it('should pass back an error and the existing savedData if the update request fails', async () => { + nock(`${BASE_URL}`) + .post(`/conversions/${hookOutputs.id}`, { + patch: { + $set: { + name: hookInputs.name, + type: hookInputs.conversionType, + attributionType: hookInputs.attribution_type + } + } + }) + .query({ + account: adAccountId + }) + .reply(500) + + const updateResult = await linkedIn.updateConversionRule(hookInputs, hookOutputs) + + expect(updateResult).toEqual({ + error: { + message: `Failed to update conversion rule: Internal Server Error`, + code: 'CONVERSION_RULE_UPDATE_FAILURE' + }, + savedData: { + id: hookOutputs.id, + name: hookOutputs.name, + conversionType: hookOutputs.conversionType, + attribution_type: hookOutputs.attribution_type, + post_click_attribution_window_size: hookOutputs.post_click_attribution_window_size, + view_through_attribution_window_size: hookOutputs.view_through_attribution_window_size + } + }) + }) + }) + describe('dynamicFields', () => { + const linkedIn: LinkedInConversions = new LinkedInConversions(requestClient) + + it('should fetch a list of ad accounts, with their names', async () => { + nock(`${BASE_URL}`) + .get(`/adAccounts`) + .query({ q: 'search' }) + .reply(200, { + elements: [ + { + test: false, + notifiedOnCreativeRejection: true, + notifiedOnNewFeaturesEnabled: true, + notifiedOnEndOfCampaign: true, + notifiedOnCampaignOptimization: true, + type: 'BUSINESS', + version: { + versionTag: '6' + }, + reference: 'urn:li:organization:1122334', + notifiedOnCreativeApproval: false, + changeAuditStamps: { + created: { + actor: 'urn:li:unknown:0', + time: 1498178296000 + }, + lastModified: { + actor: 'urn:li:unknown:0', + time: 1696277984515 + } + }, + name: 'Test Ad Account', + currency: 'USD', + id: 101100090, + status: 'ACTIVE' + }, + { + test: false, + notifiedOnCreativeRejection: false, + notifiedOnNewFeaturesEnabled: false, + notifiedOnEndOfCampaign: false, + notifiedOnCampaignOptimization: false, + type: 'BUSINESS', + version: { + versionTag: '4' + }, + reference: 'urn:li:organization:1122334', + notifiedOnCreativeApproval: false, + changeAuditStamps: { + created: { + actor: 'urn:li:unknown:0', + time: 1687394995000 + }, + lastModified: { + actor: 'urn:li:unknown:0', + time: 1694040316291 + } + }, + name: 'Krusty Krab Ads', + currency: 'USD', + id: 998877665, + status: 'ACTIVE' + } + ], + paging: { + count: 1000, + links: [], + start: 0, + total: 2 + } + }) + + const getAdAccountsRes = await linkedIn.getAdAccounts() + expect(getAdAccountsRes).toEqual({ + choices: [ + { + label: 'Test Ad Account', + value: 'urn:li:sponsoredAccount:101100090' + }, + { + label: 'Krusty Krab Ads', + value: 'urn:li:sponsoredAccount:998877665' + } + ] + }) + }) + + it('should fetch a list of conversion rules', async () => { + const payload = { + adAccountId: '123456' + } + nock(`${BASE_URL}`) + .get(`/conversions`) + .query({ q: 'account', account: payload.adAccountId, start: 0, count: 100 }) + .reply(200, { + elements: [ + { + postClickAttributionWindowSize: 30, + viewThroughAttributionWindowSize: 7, + created: 1563230311551, + type: 'LEAD', + enabled: true, + name: 'Conversion API Segment 2', + lastModified: 1563230311551, + id: 104012, + attributionType: 'LAST_TOUCH_BY_CAMPAIGN', + conversionMethod: 'CONVERSIONS_API', + account: 'urn:li:sponsoredAccount:51234560' + }, + { + postClickAttributionWindowSize: 30, + viewThroughAttributionWindowSize: 7, + created: 1563230255308, + type: 'PURCHASE', + enabled: true, + name: 'Conversion API Segment 3', + lastModified: 1563230265652, + id: 104004, + attributionType: 'LAST_TOUCH_BY_CAMPAIGN', + conversionMethod: 'CONVERSIONS_API', + account: 'urn:li:sponsoredAccount:51234560' + } + ] + }) + + const getConversionRulesListRes = await linkedIn.getConversionRulesList(payload.adAccountId) + expect(getConversionRulesListRes).toEqual({ + choices: [ + { + label: 'Conversion API Segment 2', + value: 104012 + }, + { + label: 'Conversion API Segment 3', + value: 104004 + } + ] + }) + }) + + it('should fetch a list of campaigns', async () => { + const payload = { + adAccountId: '123456' + } + nock(`${BASE_URL}`) + .get(`/adAccounts/${payload.adAccountId}/adCampaigns?q=search&search=(status:(values:List(ACTIVE,DRAFT)))`) + .reply(200, { + paging: { + start: 0, + count: 10, + links: [], + total: 1 + }, + elements: [ + { + test: false, + storyDeliveryEnabled: false, + format: 'TEXT_AD', + targetingCriteria: { + include: { + and: [ + { + or: { + 'urn:li:adTargetingFacet:locations': ['urn:li:geo:90000084'] + } + }, + { + or: { + 'urn:li:adTargetingFacet:interfaceLocales': ['urn:li:locale:en_US'] + } + } + ] + } + }, + servingStatuses: ['ACCOUNT_SERVING_HOLD'], + locale: { + country: 'US', + language: 'en' + }, + type: 'TEXT_AD', + version: { + versionTag: '11' + }, + objectiveType: 'WEBSITE_TRAFFIC', + associatedEntity: 'urn:li:organization:2425698', + optimizationTargetType: 'NONE', + runSchedule: { + start: 1498178362345 + }, + changeAuditStamps: { + created: { + actor: 'urn:li:unknown:0', + time: 1498178304000 + }, + lastModified: { + actor: 'urn:li:unknown:0', + time: 1698494362000 + } + }, + campaignGroup: 'urn:li:sponsoredCampaignGroup:600360846', + dailyBudget: { + currencyCode: 'USD', + amount: '25' + }, + costType: 'CPC', + creativeSelection: 'OPTIMIZED', + unitCost: { + currencyCode: 'USD', + amount: '8.19' + }, + name: 'Test', + offsiteDeliveryEnabled: false, + id: 125868226, + audienceExpansionEnabled: true, + account: 'urn:li:sponsoredAccount:507525021', + status: 'ACTIVE' + } + ] + }) + + const getCampaignsListRes = await linkedIn.getCampaignsList(payload.adAccountId) + expect(getCampaignsListRes).toEqual({ + choices: [ + { + label: 'Test', + value: 125868226 + } + ] + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts new file mode 100644 index 0000000000..55d45ab193 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/api/index.ts @@ -0,0 +1,542 @@ +import { RequestClient, ModifiedResponse, DynamicFieldResponse, ActionHookResponse } from '@segment/actions-core' +import { BASE_URL, DEFAULT_POST_CLICK_LOOKBACK_WINDOW, DEFAULT_VIEW_THROUGH_LOOKBACK_WINDOW } from '../constants' +import type { + ProfileAPIResponse, + GetAdAccountsAPIResponse, + AccountsErrorInfo, + GetConversionListAPIResponse, + Conversions, + GetCampaignsListAPIResponse, + Campaigns, + ConversionRuleCreationResponse, + GetConversionRuleResponse, + ConversionRuleUpdateResponse +} from '../types' +import type { Payload, HookBundle } from '../streamConversion/generated-types' +import { createHash } from 'crypto' + +interface ConversionRuleUpdateValues { + name?: string + type?: string + attributionType?: string + postClickAttributionWindowSize?: number + viewThroughAttributionWindowSize?: number +} + +interface UserID { + idType: 'SHA256_EMAIL' | 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID' | 'AXCIOM_ID' | 'ORACLE_MOAT_ID' + idValue: string +} + +export class LinkedInConversions { + request: RequestClient + conversionRuleId?: string + + constructor(request: RequestClient) { + this.request = request + } + + setConversionRuleId(conversionRuleId: string): void { + this.conversionRuleId = conversionRuleId + } + + async getProfile(): Promise> { + return this.request(`${BASE_URL}/me`, { + method: 'GET' + }) + } + + getConversionRule = async ( + adAccount: string, + conversionRuleId: string + ): Promise> => { + try { + const { data } = await this.request(`${BASE_URL}/conversions/${conversionRuleId}`, { + method: 'get', + searchParams: { + account: adAccount + } + }) + + return { + successMessage: `Using existing Conversion Rule: ${conversionRuleId} `, + savedData: { + id: conversionRuleId, + name: data.name || `No name returned for rule: ${conversionRuleId}`, + conversionType: data.type || `No type returned for rule: ${conversionRuleId}`, + attribution_type: data.attributionType || `No attribution type returned for rule: ${conversionRuleId}`, + post_click_attribution_window_size: data.postClickAttributionWindowSize || DEFAULT_POST_CLICK_LOOKBACK_WINDOW, + view_through_attribution_window_size: + data.viewThroughAttributionWindowSize || DEFAULT_VIEW_THROUGH_LOOKBACK_WINDOW + } + } + } catch (e) { + return { + error: { + message: `Failed to verify conversion rule: ${(e as { message: string })?.message ?? JSON.stringify(e)}`, + code: 'CONVERSION_RULE_VERIFICATION_FAILURE' + } + } + } + } + + createConversionRule = async ( + hookInputs: HookBundle['onMappingSave']['inputs'] + ): Promise> => { + if (!hookInputs?.adAccountId) { + return { + error: { + message: `Failed to create conversion rule: No Ad Account selected.`, + code: 'CONVERSION_RULE_CREATION_FAILURE' + } + } + } + + if (hookInputs?.conversionRuleId) { + return this.getConversionRule(hookInputs.adAccountId, hookInputs?.conversionRuleId) + } + + try { + const { data } = await this.request(`${BASE_URL}/conversions`, { + method: 'post', + json: { + name: hookInputs?.name, + account: hookInputs.adAccountId, + conversionMethod: 'CONVERSIONS_API', + postClickAttributionWindowSize: + hookInputs?.post_click_attribution_window_size || DEFAULT_POST_CLICK_LOOKBACK_WINDOW, + viewThroughAttributionWindowSize: + hookInputs?.view_through_attribution_window_size || DEFAULT_VIEW_THROUGH_LOOKBACK_WINDOW, + attributionType: hookInputs?.attribution_type, + type: hookInputs?.conversionType + } + }) + + return { + successMessage: `Conversion rule ${data.id} created successfully!`, + savedData: { + id: data.id, + name: data.name, + conversionType: data.type, + attribution_type: data.attributionType || 'UNKNOWN', + post_click_attribution_window_size: data.postClickAttributionWindowSize, + view_through_attribution_window_size: data.viewThroughAttributionWindowSize + } + } + } catch (e) { + return { + error: { + message: `Failed to create conversion rule: ${(e as { message: string })?.message ?? JSON.stringify(e)}`, + code: 'CONVERSION_RULE_CREATION_FAILURE' + } + } + } + } + + updateConversionRule = async ( + hookInputs: HookBundle['onMappingSave']['inputs'], + hookOutputs: HookBundle['onMappingSave']['outputs'] + ): Promise> => { + if (!hookOutputs) { + return { + error: { + message: `Failed to update conversion rule: No existing rule to update.`, + code: 'CONVERSION_RULE_UPDATE_FAILURE' + } + } + } + + if (!hookInputs?.adAccountId) { + return { + error: { + message: `Failed to update conversion rule: No Ad Account selected.`, + code: 'CONVERSION_RULE_UPDATE_FAILURE' + } + } + } + + if (hookInputs?.conversionRuleId) { + return this.getConversionRule(hookInputs.adAccountId, hookInputs?.conversionRuleId) + } + + const valuesChanged = this.conversionRuleValuesUpdated(hookInputs, hookOutputs) + if (!valuesChanged) { + if (!hookOutputs?.id || !hookOutputs?.name || !hookOutputs?.conversionType || !hookOutputs?.attribution_type) { + return { + error: { + message: `Failed to update conversion rule: Conversion rule values are not valid.`, + code: 'CONVERSION_RULE_UPDATE_FAILURE' + } + } + } + + return { + successMessage: `No updates detected, using rule: ${hookOutputs.id}.`, + savedData: { + id: hookOutputs.id, + name: hookOutputs.name, + conversionType: hookOutputs.conversionType, + attribution_type: hookOutputs.attribution_type, + post_click_attribution_window_size: hookOutputs.post_click_attribution_window_size, + view_through_attribution_window_size: hookOutputs.view_through_attribution_window_size + } + } + } + + try { + await this.request(`${BASE_URL}/conversions/${hookOutputs.id}`, { + method: 'post', + searchParams: { + account: hookInputs.adAccountId + }, + headers: { + 'X-RestLi-Method': 'PARTIAL_UPDATE', + 'Content-Type': 'application/json' + }, + json: { + patch: { + $set: valuesChanged + } + } + }) + + return { + successMessage: `Conversion rule ${hookOutputs.id} updated successfully!`, + savedData: { + id: hookOutputs.id, + name: valuesChanged?.name || hookOutputs.name, + conversionType: valuesChanged?.type || hookOutputs.conversionType, + attribution_type: valuesChanged?.attributionType || hookOutputs.attribution_type, + post_click_attribution_window_size: + valuesChanged?.postClickAttributionWindowSize || hookOutputs.post_click_attribution_window_size, + view_through_attribution_window_size: + valuesChanged?.viewThroughAttributionWindowSize || hookOutputs.view_through_attribution_window_size + } + } + } catch (e) { + return { + savedData: { + id: hookOutputs.id, + name: hookOutputs.name, + conversionType: hookOutputs.conversionType, + attribution_type: hookOutputs.attribution_type, + post_click_attribution_window_size: hookOutputs.post_click_attribution_window_size, + view_through_attribution_window_size: hookOutputs.view_through_attribution_window_size + }, + error: { + message: `Failed to update conversion rule: ${(e as { message: string })?.message ?? JSON.stringify(e)}`, + code: 'CONVERSION_RULE_UPDATE_FAILURE' + } + } + } + } + + getAdAccounts = async (): Promise => { + try { + const allAdAccountsResponse = await this.request(`${BASE_URL}/adAccounts`, { + method: 'GET', + searchParams: { + q: 'search' + } + }) + + const choices = allAdAccountsResponse.data.elements.map((item) => { + return { + label: item.name, + value: `urn:li:sponsoredAccount:${item.id}` + } + }) + + return { + choices + } + } catch (err) { + return { + choices: [], + error: { + message: + (err as AccountsErrorInfo).response?.data?.message ?? 'An error occurred while fetching ad accounts.', + code: (err as AccountsErrorInfo).response?.data?.code?.toString() ?? 'FETCH_AD_ACCOUNTS_ERROR' + } + } + } + } + + getConversionRulesList = async (adAccountId?: string): Promise => { + if (!adAccountId || !adAccountId.length) { + return { + choices: [], + error: { + message: 'Please select Ad Account first to get list of Conversion Rules.', + code: 'FIELD_NOT_SELECTED' + } + } + } + + try { + const response: Array = [] + const result = await this.request(`${BASE_URL}/conversions`, { + method: 'GET', + skipResponseCloning: true, + searchParams: { + q: 'account', + account: adAccountId, + start: 0, + count: 100 + } + }) + + result.data.elements.forEach((item) => { + if (item.enabled && item.conversionMethod === 'CONVERSIONS_API') { + response.push(item) + } + }) + + const choices = response?.map((item) => { + return { + label: item.name, + value: item.id + } + }) + + return { + choices + } + } catch (err) { + return { + choices: [], + error: { + message: + (err as AccountsErrorInfo).response?.data?.message ?? 'An error occurred while fetching conversion rules.', + code: (err as AccountsErrorInfo).response?.data?.code?.toString() ?? 'FETCH_CONVERSIONS_ERROR' + } + } + } + } + + private parseIdFromUrn = (urn?: string): string | undefined => { + if (!urn) { + return + } + + const parts = urn.split(':') + const id = parts.pop() + if (!id) { + return + } + + return id + } + + getCampaignsList = async (adAccountUrn?: string): Promise => { + const adAccountId = this.parseIdFromUrn(adAccountUrn) + + if (!adAccountId) { + return { + choices: [], + error: { + message: 'Please select Ad Account first to get list of Conversion Rules.', + code: 'FIELD_NOT_SELECTED' + } + } + } + + try { + const response: Array = [] + const result = await this.request( + `${BASE_URL}/adAccounts/${adAccountId}/adCampaigns?q=search&search=(status:(values:List(ACTIVE,DRAFT)))`, + { + method: 'GET' + } + ) + + result.data.elements.forEach((item) => { + response.push(item) + }) + + const choices = response?.map((item) => { + return { + label: item.name, + value: item.id + } + }) + + return { + choices + } + } catch (err) { + return { + choices: [], + error: { + message: + (err as AccountsErrorInfo).response?.data?.message ?? 'An error occurred while fetching conversion rules.', + code: (err as AccountsErrorInfo).response?.data?.code?.toString() ?? 'FETCH_CONVERSIONS_ERROR' + } + } + } + } + + private hashValue = (val: string): string => { + const hash = createHash('sha256') + hash.update(val) + return hash.digest('hex') + } + + private buildUserIdsArray = (payload: Payload): UserID[] => { + const userIds: UserID[] = [] + + if (payload.email) { + const hashedEmail = this.hashValue(payload.email) + userIds.push({ + idType: 'SHA256_EMAIL', + idValue: hashedEmail + }) + } + + if (payload.linkedInUUID) { + userIds.push({ + idType: 'LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID', + idValue: payload.linkedInUUID + }) + } + + if (payload.acxiomID) { + userIds.push({ + idType: 'AXCIOM_ID', + idValue: payload.acxiomID + }) + } + + if (payload.oracleID) { + userIds.push({ + idType: 'ORACLE_MOAT_ID', + idValue: payload.oracleID + }) + } + + return userIds + } + + async streamConversionEvent(payload: Payload, conversionTime: number): Promise { + const userIds = this.buildUserIdsArray(payload) + return this.request(`${BASE_URL}/conversionEvents`, { + method: 'POST', + json: { + conversion: `urn:lla:llaPartnerConversion:${this.conversionRuleId}`, + conversionHappenedAt: conversionTime, + conversionValue: payload.conversionValue, + eventId: payload.eventId, + user: { + userIds, + userInfo: payload.userInfo + } + } + }) + } + + async bulkAssociateCampaignToConversion(campaignIds?: string[]): Promise { + // Associating campaigns is not required to create or update a conversion rule, or to stream a conversion event + if (!campaignIds || campaignIds.length === 0) { + return + } + + if (campaignIds.length === 1) { + return this.associateCampignToConversion(campaignIds[0]) + } + + /** + * campaign[0]: "(campaign:urn%3Ali%3AsponsoredCampaign%3A,conversion:urn%3Alla%3AllaPartnerConversion%3A)" + * ... + * campaign[n]: "(campaign:urn%3Ali%3AsponsoredCampaign%3A,conversion:urn%3Alla%3AllaPartnerConversion%3A)" + */ + const campaignConversions = new Map( + campaignIds.map((campaignId) => { + return [ + campaignId, + `(campaign:${encodeURIComponent(`urn:li:sponsoredCampaign:${campaignId}`)},conversion:${encodeURIComponent( + `urn:lla:llaPartnerConversion:${this.conversionRuleId})` + )}` + ] + }) + ) + + /** + * { + * campaignConversions.get(campaignIds[0]): { + * campaign: `urn:li:sponsoredCampaign:${campaignIds[0]}`, + * conversion: `urn:lla:llaPartnerConversion:${this.conversionRuleId}` + * }, + * ... + * campaignConversions.get(campaignIds[n]): { + * campaign: `urn:li:sponsoredCampaign:${campaignIds[n]}`, + * conversion: `urn:lla:llaPartnerConversion:${this.conversionRuleId}` + * } + */ + const entities = Object.fromEntries( + Array.from(campaignConversions, ([id, value]) => [ + value, + { + campaign: `urn:li:sponsoredCampaign:${id}`, + conversion: `urn:lla:llaPartnerConversion:${this.conversionRuleId}` + } + ]) + ) + + const listString = Array.from(campaignConversions, ([_, value]) => value).join(',') + + return this.request(`${BASE_URL}/campaignConversions?ids=List(${listString})`, { + method: 'PUT', + json: { + entities + } + }) + } + + async associateCampignToConversion(campaignId: string): Promise { + return this.request( + `${BASE_URL}/campaignConversions/(campaign:urn%3Ali%3AsponsoredCampaign%3A${campaignId},conversion:urn%3Alla%3AllaPartnerConversion%3A${this.conversionRuleId})`, + { + method: 'PUT', + body: JSON.stringify({ + campaign: `urn:li:sponsoredCampaign:${campaignId}`, + conversion: `urn:lla:llaPartnerConversion:${this.conversionRuleId}` + }) + } + ) + } + + private conversionRuleValuesUpdated = ( + hookInputs: HookBundle['onMappingSave']['inputs'], + hookOutputs: Partial + ): ConversionRuleUpdateValues => { + const valuesChanged: ConversionRuleUpdateValues = {} + + if (hookInputs?.name && hookInputs?.name !== hookOutputs?.name) { + valuesChanged.name = hookInputs?.name + } + + if (hookInputs?.conversionType && hookInputs?.conversionType !== hookOutputs?.conversionType) { + valuesChanged.type = hookInputs?.conversionType + } + + if (hookInputs?.attribution_type && hookInputs?.attribution_type !== hookOutputs?.attribution_type) { + valuesChanged.attributionType = hookInputs?.attribution_type + } + + if ( + hookInputs?.post_click_attribution_window_size && + hookInputs?.post_click_attribution_window_size !== hookOutputs?.post_click_attribution_window_size + ) { + valuesChanged.postClickAttributionWindowSize = hookInputs?.post_click_attribution_window_size + } + + if ( + hookInputs?.view_through_attribution_window_size && + hookInputs?.view_through_attribution_window_size !== hookOutputs?.view_through_attribution_window_size + ) { + valuesChanged.viewThroughAttributionWindowSize = hookInputs?.view_through_attribution_window_size + } + + return valuesChanged + } +} diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/constants.ts b/packages/destination-actions/src/destinations/linkedin-conversions/constants.ts new file mode 100644 index 0000000000..f8f52b89ce --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/constants.ts @@ -0,0 +1,70 @@ +import { DependsOnConditions } from '@segment/actions-core/destination-kit/types' + +export const LINKEDIN_API_VERSION = '202401' +export const BASE_URL = 'https://api.linkedin.com/rest' +export const LINKEDIN_SOURCE_PLATFORM = 'SEGMENT' + +interface Choice { + value: string | number + label: string +} + +export const CONVERSION_TYPE_OPTIONS: Choice[] = [ + { label: 'Add to Cart', value: 'ADD_TO_CART' }, + { label: 'Download', value: 'DOWNLOAD' }, + { label: 'Install', value: 'INSTALL' }, + { label: 'Key Page View', value: 'KEY_PAGE_VIEW' }, + { label: 'Lead', value: 'LEAD' }, + { label: 'Purchase', value: 'PURCHASE' }, + { label: 'Sign Up', value: 'SIGN_UP' }, + { label: 'Other', value: 'OTHER' }, + { label: 'Talent Lead', value: 'TALENT_LEAD' }, + { label: 'Job Apply', value: 'JOB_APPLY' }, + { label: 'Save', value: 'SAVE' }, + { label: 'Start Checkout', value: 'START_CHECKOUT' }, + { label: 'Schedule', value: 'SCHEDULE' }, + { label: 'View Content', value: 'VIEW_CONTENT' }, + { label: 'View Video', value: 'VIEW_VIDEO' }, + { label: 'Add Billing Info', value: 'ADD_BILLING_INFO' }, + { label: 'Book Appointment', value: 'BOOK_APPOINTMENT' }, + { label: 'Request Quote', value: 'REQUEST_QUOTE' }, + { label: 'Search', value: 'SEARCH' }, + { label: 'Subscribe', value: 'SUBSCRIBE' }, + { label: 'Ad Click', value: 'AD_CLICK' }, + { label: 'Ad View', value: 'AD_VIEW' }, + { label: 'Complete Signup', value: 'COMPLETE_SIGNUP' }, + { label: 'Submit Application', value: 'SUBMIT_APPLICATION' }, + { label: 'Phone Call', value: 'PHONE_CALL' }, + { label: 'Invite', value: 'INVITE' }, + { label: 'Login', value: 'LOGIN' }, + { label: 'Share', value: 'SHARE' }, + { label: 'Donate', value: 'DONATE' }, + { label: 'Add to List', value: 'ADD_TO_LIST' }, + { label: 'Rate', value: 'RATE' }, + { label: 'Start Trial', value: 'START_TRIAL' }, + { label: 'Outbound Click', value: 'OUTBOUND_CLICK' }, + { label: 'Contact', value: 'CONTACT' }, + { label: 'Marketing Qualified Lead', value: 'MARKETING_QUALIFIED_LEAD' }, + { label: 'Sales Qualified Lead', value: 'SALES_QUALIFIED_LEAD' } +] + +export const SUPPORTED_LOOKBACK_WINDOW_CHOICES: Choice[] = [ + { label: '1 day', value: 1 }, + { label: '7 days', value: 7 }, + { label: '30 days', value: 30 }, + { label: '90 days', value: 90 } +] + +export const DEFAULT_POST_CLICK_LOOKBACK_WINDOW = 30 +export const DEFAULT_VIEW_THROUGH_LOOKBACK_WINDOW = 7 + +export const DEPENDS_ON_CONVERSION_RULE_ID: DependsOnConditions = { + match: 'all', + conditions: [ + { + fieldKey: 'conversionRuleId', + operator: 'is_not', + value: undefined + } + ] +} diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/generated-types.ts b/packages/destination-actions/src/destinations/linkedin-conversions/generated-types.ts new file mode 100644 index 0000000000..4ab2786ec6 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/generated-types.ts @@ -0,0 +1,3 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings {} diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/index.ts new file mode 100644 index 0000000000..628b2ddb9b --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/index.ts @@ -0,0 +1,92 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { InvalidAuthenticationError, IntegrationError, ErrorCodes } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { LinkedInConversions } from './api' +import type { LinkedInTestAuthenticationError, RefreshTokenResponse, LinkedInRefreshTokenError } from './types' +import { LINKEDIN_API_VERSION } from './constants' +import https from 'https' +import streamConversion from './streamConversion' + +const destination: DestinationDefinition = { + name: 'LinkedIn Conversions API', + slug: 'actions-linkedin-conversions', + mode: 'cloud', + + authentication: { + scheme: 'oauth2', + fields: {}, + testAuthentication: async (request, { auth }) => { + if (!auth?.accessToken) { + throw new InvalidAuthenticationError('Please authenticate via Oauth before enabling the destination.') + } + + const linkedinApiClient: LinkedInConversions = new LinkedInConversions(request) + + try { + // GET the current user's id from LinkedIn's profile API: https://learn.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api?context=linkedin%2Fcompliance%2Fcontext&view=li-lms-unversioned&preserve-view=true#request + // We request `r_basicprofile` scope when a user oauths into LinkedIn, so we retrieve the "Basic Profile Fields": https://learn.microsoft.com/en-us/linkedin/shared/references/v2/profile/basic-profile + return await linkedinApiClient.getProfile() + } catch (e: any) { + const error = e as LinkedInTestAuthenticationError + if (error.message === 'Unauthorized') { + throw new Error( + 'Invalid LinkedIn Oauth access token. Please reauthenticate to retrieve a valid access token before enabling the destination.' + ) + } + throw e + } + }, + refreshAccessToken: async (request, { auth }) => { + let res + + try { + res = await request('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: auth.refreshToken, + client_id: auth.clientId, + client_secret: auth.clientSecret, + grant_type: 'refresh_token' + }) + }) + } catch (e: any) { + const error = e as LinkedInRefreshTokenError + if (error.response?.data?.error === 'invalid_grant') { + throw new IntegrationError( + `Invalid Authentication: Your refresh token is invalid or expired. Please re-authenticate to fetch a new refresh token.`, + ErrorCodes.REFRESH_TOKEN_EXPIRED, + 401 + ) + } + + throw new IntegrationError( + `Failed to fetch a new access token. Reason: ${error.response?.data?.error}`, + ErrorCodes.OAUTH_REFRESH_FAILED, + 401 + ) + } + + return { accessToken: res?.data?.access_token } + } + }, + extendRequest({ auth }) { + // Repeat calls to the same LinkedIn API endpoint were failing due to a `socket hang up`. + // This seems to fix it: https://stackoverflow.com/questions/62500011/reuse-tcp-connection-with-node-fetch-in-node-js + // Copied from LinkedIn Audiences extendRequest, which also ran into this issue. + const agent = new https.Agent({ keepAlive: true }) + + return { + headers: { + authorization: `Bearer ${auth?.accessToken}`, + 'LinkedIn-Version': LINKEDIN_API_VERSION, + 'X-Restli-Protocol-Version': `2.0.0` + }, + agent + } + }, + actions: { + streamConversion + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..cfc5b44a34 --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for LinkedinConversions's streamConversion destination action: all fields 1`] = ` +Object { + "conversion": "urn:lla:llaPartnerConversion:1234", + "conversionHappenedAt": null, + "conversionValue": Object { + "amount": "100", + "currencyCode": "USD", + }, + "user": Object { + "userIds": Array [ + Object { + "idType": "SHA256_EMAIL", + "idValue": "e290e11fef012fc831059c96e9186cda491dd0b259641815f23463cb4c54b7e1", + }, + ], + "userInfo": Object { + "companyName": "microsoft", + "countryCode": "US", + "firstName": "mike", + "lastName": "smith", + "title": "software engineer", + }, + }, +} +`; + +exports[`Testing snapshot for LinkedinConversions's streamConversion destination action: required fields 1`] = ` +Object { + "conversion": "urn:lla:llaPartnerConversion:1234", + "conversionHappenedAt": null, + "user": Object { + "userIds": Array [ + Object { + "type": "SHA256_EMAIL", + "value": "e290e11fef012fc831059c96e9186cda491dd0b259641815f23463cb4c54b7e1", + }, + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts new file mode 100644 index 0000000000..9f402d3f1e --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/index.test.ts @@ -0,0 +1,323 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { DynamicFieldResponse } from '@segment/actions-core' +import { BASE_URL } from '../../constants' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const currentTimestamp = Date.now() + +const event = createTestEvent({ + event: 'Example Event', + type: 'track', + timestamp: currentTimestamp.toString(), + context: { + traits: { + email: 'testing@testing.com', + first_name: 'mike', + last_name: 'smith', + title: 'software engineer', + companyName: 'microsoft', + countryCode: 'US', + value: 100 + } + } +}) + +const settings = {} +const payload = { + campaignId: ['56789'], + adAccountId: '12345', + conversionId: 789123 +} + +describe('LinkedinConversions.streamConversion', () => { + it('should successfully send the event with strictly required fields', async () => { + nock(`${BASE_URL}/conversionEvents`) + .post('', { + conversion: 'urn:lla:llaPartnerConversion:789123', + conversionHappenedAt: currentTimestamp, + user: { + userIds: [ + { + idType: 'SHA256_EMAIL', + idValue: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777' + } + ] + } + }) + .reply(201) + + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + email: { '@path': '$.context.traits.email' }, + conversionHappenedAt: { + '@path': '$.timestamp' + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + } + } + }) + ).resolves.not.toThrowError() + }) + + it('should successfully send the event with all fields', async () => { + nock(`${BASE_URL}/conversionEvents`) + .post('', { + conversion: 'urn:lla:llaPartnerConversion:789123', + conversionHappenedAt: currentTimestamp, + conversionValue: { + currencyCode: 'USD', + amount: '100' + }, + user: { + userIds: [ + { + idType: 'SHA256_EMAIL', + idValue: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777' + } + ], + userInfo: { + firstName: 'mike', + lastName: 'smith', + title: 'software engineer', + companyName: 'microsoft', + countryCode: 'US' + } + } + }) + .reply(201) + + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + email: { '@path': '$.context.traits.email' }, + conversionHappenedAt: { + '@path': '$.timestamp' + }, + conversionValue: { + currencyCode: 'USD', + amount: { '@path': '$.context.traits.value' } + }, + userInfo: { + firstName: { '@path': '$.context.traits.first_name' }, + lastName: { '@path': '$.context.traits.last_name' }, + title: { '@path': '$.context.traits.title' }, + companyName: { '@path': '$.context.traits.companyName' }, + countryCode: { '@path': '$.context.traits.countryCode' } + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + } + } + }) + ).resolves.not.toThrowError() + }) + + it('should throw an error if timestamp is not within the past 90 days', async () => { + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + user: { + '@path': '$.context.traits.user' + }, + conversionHappenedAt: '50000000000' + } + }) + ).rejects.toThrowError('Timestamp should be within the past 90 days.') + }) + + it('should throw an error no user ID fields were defined.', async () => { + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + conversionHappenedAt: { + '@path': '$.timestamp' + } + } + }) + ).rejects.toThrowError('One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.') + }) + + it('should throw an error if the userInfo object is defined without both a first or last name', async () => { + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + userInfo: { + companyName: { '@path': '$.context.traits.companyName' } + }, + email: { '@path': '$.context.traits.email' }, + conversionHappenedAt: { + '@path': '$.timestamp' + } + } + }) + ).rejects.toThrowError( + "User Info is missing the required field 'firstName'. User Info is missing the required field 'lastName'." + ) + }) + + it('should throw an error if the userInfo object is defined without a first name', async () => { + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + userInfo: { + lastName: { '@path': '$.context.traits.lastName' } + }, + email: { '@path': '$.context.traits.email' }, + conversionHappenedAt: { + '@path': '$.timestamp' + } + } + }) + ).rejects.toThrowError("User Info is missing the required field 'firstName'.") + }) + + it('should throw an error if the userInfo object is defined without a last name', async () => { + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + userInfo: { + firstName: { '@path': '$.context.traits.firstName' } + }, + email: { '@path': '$.context.traits.email' }, + conversionHappenedAt: { + '@path': '$.timestamp' + } + } + }) + ).rejects.toThrowError("User Info is missing the required field 'lastName'.") + }) +}) + +describe('LinkedinConversions.dynamicField', () => { + it('conversionId: should give error if adAccountId is not provided', async () => { + const settings = {} + + const payload = { + adAccountId: '' + } + + const dynamicFn = + testDestination.actions.streamConversion.definition.hooks?.onMappingSave?.inputFields?.conversionRuleId.dynamic + const responses = (await testDestination.testDynamicField( + 'streamConversion', + 'conversionId', + { + settings, + payload + }, + dynamicFn + )) as DynamicFieldResponse + + expect(responses).toMatchObject({ + choices: [], + error: { + message: 'Please select Ad Account first to get list of Conversion Rules.', + code: 'FIELD_NOT_SELECTED' + } + }) + }) + + it('campaignId: should give error if adAccountId is not provided', async () => { + const settings = {} + + const payload = { + adAccountId: '' + } + + const dynamicFn = + testDestination.actions.streamConversion.definition.hooks?.onMappingSave?.inputFields?.campaignId.dynamic + const responses = (await testDestination.testDynamicField( + 'streamConversion', + 'campaignId', + { + settings, + payload + }, + dynamicFn + )) as DynamicFieldResponse + + expect(responses).toMatchObject({ + choices: [], + error: { + message: 'Please select Ad Account first to get list of Conversion Rules.', + code: 'FIELD_NOT_SELECTED' + } + }) + }) +}) + +describe('LinkedinConversions.timestamp', () => { + it('should convert a human readable date to a unix timestamp', async () => { + event.timestamp = currentTimestamp.toString() + + nock(`${BASE_URL}/conversionEvents`).post(/.*/).reply(201) + + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + email: { '@path': '$.context.traits.email' }, + conversionHappenedAt: { + '@path': '$.timestamp' + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + } + } + }) + ).resolves.not.toThrowError() + }) + + it('should convert a string unix timestamp to a number', async () => { + event.timestamp = currentTimestamp.toString() + + nock(`${BASE_URL}/conversionEvents`).post(/.*/).reply(201) + + await expect( + testDestination.testAction('streamConversion', { + event, + settings, + mapping: { + email: { '@path': '$.context.traits.email' }, + conversionHappenedAt: { + '@path': '$.timestamp' + }, + onMappingSave: { + inputs: {}, + outputs: { + id: payload.conversionId + } + } + } + }) + ).resolves.not.toThrowError() + }) +}) diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..9cf39698ac --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/__tests__/snapshot.test.ts @@ -0,0 +1,112 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'streamConversion' +const destinationSlug = 'LinkedinConversions' +const seedName = `${destinationSlug}#${actionSlug}` +const action = destination.actions[actionSlug] +const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + eventData.email = 'nick@twilio.com' + eventData.timestamp = 'NaN' + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { + email: { '@path': '$.properties.email' }, + conversionHappenedAt: { '@path': '$.properties.timestamp' }, + onMappingSave: { + inputs: {}, + outputs: { + id: '1234' + } + } + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it.only('all fields', async () => { + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + eventData.email = 'nick@twilio.com' + eventData.timestamp = 'NaN' + eventData.first_name = 'mike' + eventData.last_name = 'smith' + eventData.title = 'software engineer' + eventData.companyName = 'microsoft' + eventData.countryCode = 'US' + eventData.value = 100 + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { + email: { '@path': '$.properties.email' }, + conversionHappenedAt: { '@path': '$.properties.timestamp' }, + conversionValue: { + currencyCode: 'USD', + amount: { '@path': '$.properties.value' } + }, + userInfo: { + firstName: { '@path': '$.properties.first_name' }, + lastName: { '@path': '$.properties.last_name' }, + title: { '@path': '$.properties.title' }, + companyName: { '@path': '$.properties.companyName' }, + countryCode: { '@path': '$.properties.countryCode' } + }, + onMappingSave: { + inputs: {}, + outputs: { + id: '1234' + } + } + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts new file mode 100644 index 0000000000..8037403a6b --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/generated-types.ts @@ -0,0 +1,117 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Epoch timestamp in milliseconds at which the conversion event happened. If your source records conversion timestamps in second, insert 000 at the end to transform it to milliseconds. + */ + conversionHappenedAt: string + /** + * The monetary value for this conversion. Example: {“currencyCode”: “USD”, “amount”: “50.0”}. + */ + conversionValue?: { + /** + * ISO format + */ + currencyCode: string + /** + * Value of the conversion in decimal string. Can be dynamically set up or have a fixed value. + */ + amount: string + } + /** + * The unique id for each event. This field is optional and is used for deduplication. + */ + eventId?: string + /** + * Email address of the contact associated with the conversion event. Segment will hash this value before sending it to LinkedIn. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + */ + email?: string + /** + * First party cookie or Click Id. Enhanced conversion tracking must be enabled to use this ID type. See [LinkedIn documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-01&tabs=http#idtype) for more details. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + */ + linkedInUUID?: string + /** + * User identifier for matching with LiveRamp identity graph. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + */ + acxiomID?: string + /** + * User identifier for matching with Oracle MOAT Identity. Also known as ORACLE_MOAT_ID in LinkedIn documentation. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required. + */ + oracleID?: string + /** + * Object containing additional fields for user matching. If this object is defined, both firstName and lastName are required. + */ + userInfo?: { + firstName: string + lastName: string + companyName?: string + title?: string + countryCode?: string + } +} +// Generated bundle for hooks. DO NOT MODIFY IT BY HAND. + +export interface HookBundle { + onMappingSave: { + inputs?: { + /** + * The ad account to use for the conversion event. + */ + adAccountId: string + /** + * Select one or more advertising campaigns from your ad account to associate with the configured conversion rule. Segment will only add the selected campaigns to the conversion rule. Deselecting a campaign will not disassociate it from the conversion rule. + */ + campaignId?: string[] + /** + * The ID of an existing conversion rule to stream events to. If defined, we will not create a new conversion rule. + */ + conversionRuleId?: string + /** + * The name of the conversion rule. + */ + name?: string + /** + * The type of conversion rule. + */ + conversionType?: string + /** + * The attribution type for the conversion rule. + */ + attribution_type?: string + /** + * Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 30. + */ + post_click_attribution_window_size?: number + /** + * Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7. + */ + view_through_attribution_window_size?: number + } + outputs?: { + /** + * The ID of the conversion rule. + */ + id: string + /** + * The name of the conversion rule. + */ + name: string + /** + * The type of conversion rule. + */ + conversionType: string + /** + * The attribution type for the conversion rule. + */ + attribution_type: string + /** + * Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad. + */ + post_click_attribution_window_size: number + /** + * Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7. + */ + view_through_attribution_window_size: number + } + } +} diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts new file mode 100644 index 0000000000..df90cb939e --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/streamConversion/index.ts @@ -0,0 +1,358 @@ +import type { ActionDefinition, ActionHookResponse } from '@segment/actions-core' +import { ErrorCodes, IntegrationError, PayloadValidationError, InvalidAuthenticationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import { LinkedInConversions } from '../api' +import { CONVERSION_TYPE_OPTIONS, SUPPORTED_LOOKBACK_WINDOW_CHOICES, DEPENDS_ON_CONVERSION_RULE_ID } from '../constants' +import type { Payload, HookBundle } from './generated-types' +import { LinkedInError } from '../types' + +const action: ActionDefinition = { + title: 'Stream Conversion Event', + description: 'Directly streams conversion events to a specific conversion rule.', + defaultSubscription: 'type = "track"', + hooks: { + onMappingSave: { + label: 'Create a Conversion Rule', + description: + 'When saving this mapping, we will create a conversion rule in LinkedIn using the fields you provided.\n To configure: either provide an existing conversion rule ID or fill in the fields below to create a new conversion rule.', + inputFields: { + adAccountId: { + label: 'Ad Account', + description: 'The ad account to use for the conversion event.', + type: 'string', + required: true, + dynamic: async (request) => { + const linkedIn = new LinkedInConversions(request) + return linkedIn.getAdAccounts() + } + }, + campaignId: { + label: 'Add Campaigns to Conversion', + description: + 'Select one or more advertising campaigns from your ad account to associate with the configured conversion rule. Segment will only add the selected campaigns to the conversion rule. Deselecting a campaign will not disassociate it from the conversion rule.', + type: 'string', + multiple: true, + required: false, + dynamic: async (request, { hookInputs }) => { + const linkedIn = new LinkedInConversions(request) + return linkedIn.getCampaignsList(hookInputs?.adAccountId) + } + }, + /** + * The configuration fields for a LinkedIn CAPI conversion rule. + * Detailed information on these parameters can be found at + * https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversion-tracking?view=li-lms-2023-07&tabs=https#create-a-conversion + */ + conversionRuleId: { + type: 'string', + label: 'Existing Conversion Rule ID', + description: + 'The ID of an existing conversion rule to stream events to. If defined, we will not create a new conversion rule.', + required: false, + dynamic: async (request, { hookInputs }) => { + const linkedIn = new LinkedInConversions(request) + return linkedIn.getConversionRulesList(hookInputs?.adAccountId) + } + }, + name: { + type: 'string', + label: 'Name', + description: 'The name of the conversion rule.', + depends_on: DEPENDS_ON_CONVERSION_RULE_ID + }, + conversionType: { + type: 'string', + label: 'Conversion Type', + description: 'The type of conversion rule.', + choices: CONVERSION_TYPE_OPTIONS, + depends_on: DEPENDS_ON_CONVERSION_RULE_ID + }, + attribution_type: { + label: 'Attribution Type', + description: 'The attribution type for the conversion rule.', + type: 'string', + choices: [ + { label: 'Each Campaign', value: 'LAST_TOUCH_BY_CAMPAIGN' }, + { label: 'Single Campaign', value: 'LAST_TOUCH_BY_CONVERSION' } + ], + depends_on: DEPENDS_ON_CONVERSION_RULE_ID + }, + post_click_attribution_window_size: { + label: 'Post-Click Attribution Window Size', + description: + 'Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 30.', + type: 'number', + default: 30, + choices: SUPPORTED_LOOKBACK_WINDOW_CHOICES, + depends_on: DEPENDS_ON_CONVERSION_RULE_ID + }, + view_through_attribution_window_size: { + label: 'View-Through Attribution Window Size', + description: + 'Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7.', + type: 'number', + default: 7, + choices: SUPPORTED_LOOKBACK_WINDOW_CHOICES, + depends_on: DEPENDS_ON_CONVERSION_RULE_ID + } + }, + outputTypes: { + id: { + type: 'string', + label: 'ID', + description: 'The ID of the conversion rule.', + required: true + }, + name: { + type: 'string', + label: 'Name', + description: 'The name of the conversion rule.', + required: true + }, + conversionType: { + type: 'string', + label: 'Conversion Type', + description: 'The type of conversion rule.', + required: true + }, + attribution_type: { + label: 'Attribution Type', + description: 'The attribution type for the conversion rule.', + type: 'string', + required: true + }, + post_click_attribution_window_size: { + label: 'Post-Click Attribution Window Size', + description: + 'Conversion window timeframe (in days) of a member clicking on a LinkedIn Ad (a post-click conversion) within which conversions will be attributed to a LinkedIn ad.', + type: 'number', + required: true + }, + view_through_attribution_window_size: { + label: 'View-Through Attribution Window Size', + description: + 'Conversion window timeframe (in days) of a member seeing a LinkedIn Ad (a view-through conversion) within which conversions will be attributed to a LinkedIn ad. Allowed values are 1, 7, 30 or 90. Default is 7.', + type: 'number', + required: true + } + }, + performHook: async (request, { hookInputs, hookOutputs }) => { + const linkedIn = new LinkedInConversions(request) + + let hookReturn: ActionHookResponse + if (hookOutputs?.onMappingSave?.outputs) { + linkedIn.setConversionRuleId(hookOutputs.onMappingSave.outputs.id) + + hookReturn = await linkedIn.updateConversionRule( + hookInputs, + hookOutputs.onMappingSave.outputs as HookBundle['onMappingSave']['outputs'] + ) + } else { + hookReturn = await linkedIn.createConversionRule(hookInputs) + } + + if (hookReturn.error || !hookReturn.savedData) { + return hookReturn + } + linkedIn.setConversionRuleId(hookReturn.savedData.id) + + try { + await linkedIn.bulkAssociateCampaignToConversion(hookInputs?.campaignId) + } catch (error) { + return { + error: { + message: `Failed to associate campaigns to conversion rule, please try again: ${JSON.stringify(error)}`, + code: 'ASSOCIATE_CAMPAIGN_TO_CONVERSION_ERROR' + } + } + } + + return hookReturn + } + } + }, + fields: { + conversionHappenedAt: { + label: 'Timestamp', + description: + 'Epoch timestamp in milliseconds at which the conversion event happened. If your source records conversion timestamps in second, insert 000 at the end to transform it to milliseconds.', + type: 'string', + required: true, + default: { + '@path': '$.timestamp' + } + }, + conversionValue: { + label: 'Conversion Value', + description: 'The monetary value for this conversion. Example: {“currencyCode”: “USD”, “amount”: “50.0”}.', + type: 'object', + required: false, + defaultObjectUI: 'keyvalue:only', + properties: { + currencyCode: { + label: 'Currency Code', + type: 'string', + required: true, + description: 'ISO format' + }, + amount: { + label: 'Amount', + type: 'string', + required: true, + description: 'Value of the conversion in decimal string. Can be dynamically set up or have a fixed value.' + } + } + }, + eventId: { + label: 'Event ID', + description: 'The unique id for each event. This field is optional and is used for deduplication.', + type: 'string', + required: false, + default: { + '@path': '$.messageId' + } + }, + email: { + label: 'Email', + description: + 'Email address of the contact associated with the conversion event. Segment will hash this value before sending it to LinkedIn. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.', + type: 'string', + required: false + }, + linkedInUUID: { + label: 'LinkedIn First Party Ads Tracking UUID', + description: + 'First party cookie or Click Id. Enhanced conversion tracking must be enabled to use this ID type. See [LinkedIn documentation](https://learn.microsoft.com/en-us/linkedin/marketing/integrations/ads-reporting/conversions-api?view=li-lms-2024-01&tabs=http#idtype) for more details. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.', + type: 'string', + required: false + }, + acxiomID: { + label: 'Acxiom ID', + description: + 'User identifier for matching with LiveRamp identity graph. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.', + type: 'string', + required: false + }, + oracleID: { + label: 'Oracle ID', + description: + 'User identifier for matching with Oracle MOAT Identity. Also known as ORACLE_MOAT_ID in LinkedIn documentation. One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.', + type: 'string', + required: false + }, + userInfo: { + label: 'User Info', + description: + 'Object containing additional fields for user matching. If this object is defined, both firstName and lastName are required.', + type: 'object', + defaultObjectUI: 'keyvalue', + required: false, + properties: { + firstName: { + label: 'First Name', + type: 'string', + required: true + }, + lastName: { + label: 'Last Name', + type: 'string', + required: true + }, + companyName: { + label: 'Company Name', + type: 'string', + required: false + }, + title: { + label: 'Title', + type: 'string', + required: false + }, + countryCode: { + label: 'Country Code', + type: 'string', + required: false + } + } + } + }, + perform: async (request, { payload, hookOutputs }) => { + const conversionTime = isNotEpochTimestampInMilliseconds(payload.conversionHappenedAt) + ? convertToEpochMillis(payload.conversionHappenedAt) + : Number(payload.conversionHappenedAt) + validate(payload, conversionTime) + + let conversionRuleId = '' + if (hookOutputs?.onMappingSave?.outputs?.id) { + conversionRuleId = hookOutputs?.onMappingSave.outputs?.id + } + + if (!conversionRuleId) { + throw new PayloadValidationError('Conversion Rule ID is required.') + } + + const linkedinApiClient: LinkedInConversions = new LinkedInConversions(request) + linkedinApiClient.setConversionRuleId(conversionRuleId) + + try { + return linkedinApiClient.streamConversionEvent(payload, conversionTime) + } catch (error) { + throw handleRequestError(error) + } + } +} + +function handleRequestError(error: unknown) { + const asLinkedInError = error as LinkedInError + + if (!asLinkedInError) { + return new IntegrationError('Unknown error', 'UNKNOWN_ERROR', 500) + } + + const status = asLinkedInError.response.data.status + + if (status === 401) { + return new InvalidAuthenticationError(asLinkedInError.response.data.message, ErrorCodes.INVALID_AUTHENTICATION) + } + + if (status === 501) { + return new IntegrationError(asLinkedInError.response.data.message, 'INTEGRATION_ERROR', 501) + } + + if (status === 408 || status === 423 || status === 429 || status >= 500) { + return new IntegrationError(asLinkedInError.response.data.message, 'RETRYABLE_ERROR', status) + } + + return new IntegrationError(asLinkedInError.response.data.message, 'INTEGRATION_ERROR', status) +} + +function validate(payload: Payload, conversionTime: number) { + // Check if the timestamp is within the past 90 days + const ninetyDaysAgo = Date.now() - 90 * 24 * 60 * 60 * 1000 + if (conversionTime < ninetyDaysAgo) { + throw new PayloadValidationError('Timestamp should be within the past 90 days.') + } + + if (!payload.email && !payload.linkedInUUID && !payload.acxiomID && !payload.oracleID) { + throw new PayloadValidationError('One of email or LinkedIn UUID or Axciom ID or Oracle ID is required.') + } +} + +function isNotEpochTimestampInMilliseconds(timestamp: string) { + if (typeof timestamp === 'string' && !isNaN(Number(timestamp))) { + const convertedTimestamp = Number(timestamp) + const startDate = new Date('1970-01-01T00:00:00Z').getTime() + const endDate = new Date('2100-01-01T00:00:00Z').getTime() + if (Number.isSafeInteger(convertedTimestamp) && convertedTimestamp >= startDate && convertedTimestamp <= endDate) { + return false + } + } + return true +} + +function convertToEpochMillis(timestamp: string) { + const date = new Date(timestamp) + return date.getTime() +} + +export default action diff --git a/packages/destination-actions/src/destinations/linkedin-conversions/types.ts b/packages/destination-actions/src/destinations/linkedin-conversions/types.ts new file mode 100644 index 0000000000..f87b8e884f --- /dev/null +++ b/packages/destination-actions/src/destinations/linkedin-conversions/types.ts @@ -0,0 +1,123 @@ +import { HTTPError } from '@segment/actions-core' + +export interface RefreshTokenResponse { + access_token: string + scope: string + expires_in: number + token_type: string +} + +export interface ProfileAPIResponse { + id: string +} + +export interface LinkedInError { + response: { + data: { + message: string + code: string + status: number + } + } +} + +export class LinkedInTestAuthenticationError extends HTTPError { + response: Response & { + data: { + message: string + } + } +} + +export class LinkedInRefreshTokenError extends HTTPError { + response: Response & { + data: { + error: string + error_description: string + } + } +} + +export interface GetAdAccountsAPIResponse { + paging: { + count: number + links: Array + start: number + total: number + } + elements: [Account] +} + +export interface Account { + name: string + id: string +} + +export interface AccountsErrorInfo { + response: { + data: { + message?: string + code?: string + } + } +} + +export interface GetConversionListAPIResponse { + paging: { + count: number + links: Array + start: number + total: number + } + elements: [Conversions] +} + +export interface Conversions { + name: string + id: string + enabled: boolean + conversionMethod: string +} + +export interface GetCampaignsListAPIResponse { + paging: { + count: number + links: Array + start: number + total: number + } + elements: [Campaigns] +} + +export interface Campaigns { + name: string + id: string +} + +export interface ConversionRuleCreationResponse { + id: string + name: string + type: string + attributionType: string + postClickAttributionWindowSize: number + viewThroughAttributionWindowSize: number +} + +/** This request returns 204 no content */ +export interface ConversionRuleUpdateResponse {} + +/** + * The shape of the response from LinkedIn when fetching a conversion rule by id. + * Not all properties in this type are used, but they are included if needed in the future. + */ +export interface GetConversionRuleResponse { + conversionMethod?: string + type?: string + enabled?: boolean + name?: string + id?: string + attributionType?: string + account?: string + postClickAttributionWindowSize?: number + viewThroughAttributionWindowSize?: number +} diff --git a/packages/destination-actions/src/destinations/livelike-cloud/trackEvent/index.ts b/packages/destination-actions/src/destinations/livelike-cloud/trackEvent/index.ts index ddba9f054d..34634b8809 100644 --- a/packages/destination-actions/src/destinations/livelike-cloud/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/livelike-cloud/trackEvent/index.ts @@ -67,7 +67,8 @@ const action: ActionDefinition = { label: 'Segment Anonymous ID', description: 'Segment Anonymous ID.', required: false, - type: 'hidden', + type: 'string', + unsafe_hidden: true, default: { '@path': '$.anonymousId' } diff --git a/packages/destination-actions/src/destinations/liveramp-audiences/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/liveramp-audiences/__tests__/__snapshots__/snapshot.test.ts.snap index b0b90d1c8c..ba18b90da5 100644 --- a/packages/destination-actions/src/destinations/liveramp-audiences/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/liveramp-audiences/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,73 +1,73 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Testing snapshot for LiverampAudiences's audienceEnteredS3 destination action: all fields 1`] = ` -"audience_key,testType,testType -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\"" +"audience_key,testType +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\"" `; exports[`Testing snapshot for LiverampAudiences's audienceEnteredS3 destination action: missing minimum payload size 1`] = `[PayloadValidationError: received payload count below LiveRamp's ingestion limits. expected: >=25 actual: 1]`; exports[`Testing snapshot for LiverampAudiences's audienceEnteredS3 destination action: required fields 1`] = ` -"audience_key,testType,testType -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" -\\"8I!3YmPiv2%lv7\\",\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\"" +"audience_key,testType +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\" +\\"8I!3YmPiv2%lv7\\",\\"047e87c5aeaa6844b0125de813f7683f636401c49b092afd324d2c32d21ccaff\\"" `; exports[`Testing snapshot for LiverampAudiences's audienceEnteredS3 destination action: required fields 2`] = ` Headers { Symbol(map): Object { "authorization": Array [ - "AWS4-HMAC-SHA256 Credential=12345/19700101/us-west/s3/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, Signature=4a5564353d61d819ae9759a627f4a4e9e0fd4acde988984d3220c391f0abde5e", + "AWS4-HMAC-SHA256 Credential=12345/19700101/us-west/s3/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-content-sha256;x-amz-date, Signature=4d3fa5773e208c949b4c6278e1eb76fdc88ddb4c9ebece9ffacf3e40e1ff2a51", ], "content-length": Array [ - "2555", + "2121", ], "content-type": Array [ "application/x-www-form-urlencoded; charset=utf-8", @@ -79,7 +79,7 @@ Headers { "Segment (Actions)", ], "x-amz-content-sha256": Array [ - "7fced28a29b028b75193be3b3824c4df2fcc96ba80b74a741afc069f54bd6156", + "a8ed2a4ab5e742f5669c17ebc3f5cb0f79dc29fd08c23aa41a1e27a8c339b957", ], "x-amz-date": Array [ "19700101T000012Z", @@ -114,15 +114,6 @@ Array [ 121, 112, 101, - 44, - 116, - 101, - 115, - 116, - 84, - 121, - 112, - 101, 10, 34, 105, @@ -139,20 +130,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -234,20 +211,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -329,20 +292,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -424,20 +373,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -519,20 +454,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -614,20 +535,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -709,20 +616,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -804,20 +697,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -899,20 +778,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -994,20 +859,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1089,20 +940,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1184,20 +1021,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1279,20 +1102,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1374,20 +1183,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1469,20 +1264,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1564,20 +1345,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1659,20 +1426,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1754,20 +1507,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1849,20 +1588,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -1944,20 +1669,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2039,20 +1750,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2134,20 +1831,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2210,24 +1893,10 @@ Array [ 53, 101, 100, - 56, - 51, - 34, - 10, - 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, + 56, + 51, 34, - 44, + 10, 34, 105, 50, @@ -2324,20 +1993,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2419,20 +2074,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2527,15 +2168,6 @@ Array [ 121, 112, 101, - 44, - 116, - 101, - 115, - 116, - 84, - 121, - 112, - 101, 10, 34, 105, @@ -2552,20 +2184,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2647,20 +2265,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2742,20 +2346,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2837,20 +2427,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -2932,20 +2508,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3027,20 +2589,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3122,20 +2670,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3217,20 +2751,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3312,20 +2832,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3407,20 +2913,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3502,20 +2994,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3597,20 +3075,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3692,20 +3156,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3787,20 +3237,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -3860,27 +3296,13 @@ Array [ 55, 102, 52, - 53, - 101, - 100, - 56, - 51, - 34, - 10, - 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, + 53, + 101, + 100, + 56, + 51, 34, - 44, + 10, 34, 105, 50, @@ -3977,20 +3399,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4072,20 +3480,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4167,20 +3561,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4262,20 +3642,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4357,20 +3723,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4452,20 +3804,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4547,20 +3885,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4642,20 +3966,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4737,20 +4047,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4832,20 +4128,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -4947,15 +4229,6 @@ Array [ 121, 112, 101, - 44, - 116, - 101, - 115, - 116, - 84, - 121, - 112, - 101, 10, 34, 105, @@ -4972,20 +4245,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5067,20 +4326,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5162,20 +4407,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5257,20 +4488,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5352,20 +4569,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5447,20 +4650,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5523,24 +4712,10 @@ Array [ 53, 101, 100, - 56, - 51, - 34, - 10, - 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, + 56, + 51, 34, - 44, + 10, 34, 105, 50, @@ -5637,20 +4812,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5732,20 +4893,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5827,20 +4974,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -5922,20 +5055,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6017,20 +5136,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6112,20 +5217,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6207,20 +5298,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6302,20 +5379,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6397,20 +5460,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6492,20 +5541,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6587,20 +5622,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6682,20 +5703,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6777,20 +5784,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6872,20 +5865,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -6967,20 +5946,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -7062,20 +6027,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -7157,20 +6108,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, @@ -7252,20 +6189,6 @@ Array [ 34, 44, 34, - 105, - 50, - 57, - 57, - 56, - 41, - 66, - 97, - 77, - 111, - 122, - 34, - 44, - 34, 50, 102, 54, diff --git a/packages/destination-actions/src/destinations/liveramp-audiences/__tests__/operations.test.ts b/packages/destination-actions/src/destinations/liveramp-audiences/__tests__/operations.test.ts new file mode 100644 index 0000000000..59b601f98c --- /dev/null +++ b/packages/destination-actions/src/destinations/liveramp-audiences/__tests__/operations.test.ts @@ -0,0 +1,90 @@ +import { generateFile } from '../operations' +import type { Payload } from '../audienceEnteredSftp/generated-types' + +describe(`Test operations helper functions:`, () => { + it('should generate CSV with hashed and unhashed identifier data', async () => { + const payloads: Payload[] = [ + // Entry with hashed identifier data + { + audience_key: 'aud001', + delimiter: ',', + identifier_data: { + email: '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' + }, + filename: 'test_file_name.csv', + enable_batching: true + }, + // Entry with unhashed identifier data + { + audience_key: 'aud002', + delimiter: ',', + unhashed_identifier_data: { + email: 'test@example.com' + }, + filename: 'test_file_name.csv', + enable_batching: true + }, + // Entry with both hashed and unhashed identifier data + { + audience_key: 'aud003', + delimiter: ',', + identifier_data: { + email: '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' + }, + unhashed_identifier_data: { + email: 'test@example.com' + }, + filename: 'test_file_name.csv', + enable_batching: true + } + ] + + const result = generateFile(payloads) + + const expectedFileContents = `audience_key,email\n"aud001","973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"\n"aud002","973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"\n"aud003","973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"` + + expect(result).toMatchObject({ + filename: 'test_file_name.csv', + fileContents: Buffer.from(expectedFileContents) + }) + }) + + it('should generate CSV even if rows have missing data', async () => { + const payloads: Payload[] = [ + { + audience_key: 'aud001', + delimiter: ',', + filename: 'test_file_name.csv', + enable_batching: true + }, + { + audience_key: 'aud002', + delimiter: ',', + unhashed_identifier_data: { + email: 'test@example.com' + }, + filename: 'test_file_name.csv', + enable_batching: true + }, + { + audience_key: 'aud003', + delimiter: ',', + unhashed_identifier_data: { + email: 'test@example.com', + example_identifier: 'example-id' + }, + filename: 'test_file_name.csv', + enable_batching: true + } + ] + + const result = generateFile(payloads) + + const expectedFileContents = `audience_key,email,example_identifier\n"aud001"\n"aud002","973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b"\n"aud003","973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b","66a0acf498240ea61ce3ce698c5a30eb6824242b39695f8689d7c32499c79748"` + + expect(result).toMatchObject({ + filename: 'test_file_name.csv', + fileContents: Buffer.from(expectedFileContents) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredS3/index.ts b/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredS3/index.ts index 65042a2dd8..4d23f52698 100644 --- a/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredS3/index.ts +++ b/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredS3/index.ts @@ -67,7 +67,7 @@ const action: ActionDefinition = { description: `Name of the CSV file to upload for LiveRamp ingestion.`, type: 'string', required: true, - default: { '@template': '{{properties.audience_key}}_PII_{{timestamp}}.csv' } + default: { '@template': '{{properties.audience_key}}.csv' } }, enable_batching: { type: 'boolean', diff --git a/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredSftp/index.ts b/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredSftp/index.ts index 6c4dc9cdad..83dd6b7296 100644 --- a/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredSftp/index.ts +++ b/packages/destination-actions/src/destinations/liveramp-audiences/audienceEnteredSftp/index.ts @@ -65,7 +65,7 @@ const action: ActionDefinition = { description: `Name of the CSV file to upload for LiveRamp ingestion.`, type: 'string', required: true, - default: { '@template': '{{properties.audience_key}}_PII_{{timestamp}}.csv' } + default: { '@template': '{{properties.audience_key}}_PII.csv' } }, enable_batching: { type: 'boolean', diff --git a/packages/destination-actions/src/destinations/liveramp-audiences/operations.ts b/packages/destination-actions/src/destinations/liveramp-audiences/operations.ts index de59b96dcf..ab519399cf 100644 --- a/packages/destination-actions/src/destinations/liveramp-audiences/operations.ts +++ b/packages/destination-actions/src/destinations/liveramp-audiences/operations.ts @@ -32,45 +32,44 @@ Generates the LiveRamp ingestion file. Expected format: liveramp_audience_key[1],identifier_data[0..n] */ function generateFile(payloads: s3Payload[] | sftpPayload[]) { - const headers: string[] = ['audience_key'] + // Using a Set to keep track of headers + const headers = new Set() + headers.add('audience_key') - // Prepare header row - if (payloads[0].identifier_data) { - for (const identifier of Object.getOwnPropertyNames(payloads[0].identifier_data)) { - headers.push(identifier) - } - } - - if (payloads[0].unhashed_identifier_data) { - for (const identifier of Object.getOwnPropertyNames(payloads[0].unhashed_identifier_data)) { - headers.push(identifier) - } - } - - let rows = Buffer.from(headers.join(payloads[0].delimiter) + '\n') + // Declare rows as an empty Buffer + let rows = Buffer.from('') // Prepare data rows for (let i = 0; i < payloads.length; i++) { const payload = payloads[i] const row: string[] = [enquoteIdentifier(payload.audience_key)] - if (payload.identifier_data) { - for (const key in payload.identifier_data) { - if (Object.prototype.hasOwnProperty.call(payload.identifier_data, key)) { - row.push(enquoteIdentifier(String(payload.identifier_data[key]))) - } - } - } + // Process unhashed_identifier_data first if (payload.unhashed_identifier_data) { for (const key in payload.unhashed_identifier_data) { if (Object.prototype.hasOwnProperty.call(payload.unhashed_identifier_data, key)) { + headers.add(key) row.push(`"${hash(normalize(key, String(payload.unhashed_identifier_data[key])))}"`) } } } + + // Process identifier_data, skipping keys that have already been processed + if (payload.identifier_data) { + for (const key in payload.identifier_data) { + if (Object.prototype.hasOwnProperty.call(payload.identifier_data, key) && !headers.has(key)) { + headers.add(key) + row.push(enquoteIdentifier(String(payload.identifier_data[key]))) + } + } + } + rows = Buffer.concat([rows, Buffer.from(row.join(payload.delimiter) + (i + 1 === payloads.length ? '' : '\n'))]) } + // Add headers to the beginning of the file contents + rows = Buffer.concat([Buffer.from(Array.from(headers).join(payloads[0].delimiter) + '\n'), rows]) + const filename = payloads[0].filename return { filename, fileContents: rows } } diff --git a/packages/destination-actions/src/destinations/loops/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/loops/__tests__/__snapshots__/snapshot.test.ts.snap index ed065434fb..27df5a17ad 100644 --- a/packages/destination-actions/src/destinations/loops/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/loops/__tests__/__snapshots__/snapshot.test.ts.snap @@ -16,7 +16,6 @@ Object { exports[`Testing snapshot for actions-loops destination: createOrUpdateContact action - required fields 1`] = ` Object { - "email": "bivjag@ennaato.st", "userId": "3dQ7ER]1HKW", } `; diff --git a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/__snapshots__/snapshot.test.ts.snap index caa2e0f849..971dd5c476 100644 --- a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/__snapshots__/snapshot.test.ts.snap @@ -16,7 +16,6 @@ Object { exports[`Testing snapshot for Loops's createOrUpdateContact destination action: required fields 1`] = ` Object { - "email": "macepa@tariviz.sm", "userId": "IAIhDY9yOUxjQs(FSFfe", } `; diff --git a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/index.test.ts index 419e5b6188..0a69d9412b 100644 --- a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/__tests__/index.test.ts @@ -2,7 +2,11 @@ import nock from 'nock' import { createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -const testDestination = createTestIntegration(Destination) +let testDestination = createTestIntegration(Destination) +beforeEach(() => { + nock.cleanAll() + testDestination = createTestIntegration(Destination) +}) const LOOPS_API_KEY = 'some random secret' @@ -14,7 +18,6 @@ describe('Loops.createOrUpdateContact', () => { }) } catch (err) { expect(err.message).toContain("missing the required field 'userId'.") - expect(err.message).toContain("missing the required field 'email'.") } }) @@ -55,12 +58,33 @@ describe('Loops.createOrUpdateContact', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) + expect(responses[0].data).toStrictEqual({ + success: true, + id: 'someId' + }) + }) + + it('should not work without email', async () => { + const testPayload = { + firstName: 'Ellen', + userId: 'some-id-1' + } + nock('https://app.loops.so/api/v1').put('/contacts/update', testPayload).reply(400, { + success: false, + message: 'userId not found and cannot create a new contact without an email.' + }) + await expect( + testDestination.testAction('createOrUpdateContact', { + mapping: testPayload, + settings: { apiKey: LOOPS_API_KEY } + }) + ).rejects.toThrow('Bad Request') }) it('should work without optional fields', async () => { const testPayload = { email: 'test@example.com', - userId: 'some-id-1' + userId: 'some-id-2' } nock('https://app.loops.so/api/v1').put('/contacts/update', testPayload).reply(200, { success: true, @@ -74,5 +98,9 @@ describe('Loops.createOrUpdateContact', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) + expect(responses[0].data).toStrictEqual({ + success: true, + id: 'someId' + }) }) }) diff --git a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/generated-types.ts b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/generated-types.ts index be39d06ee5..6365c7c2f9 100644 --- a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/generated-types.ts +++ b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/generated-types.ts @@ -12,9 +12,9 @@ export interface Payload { [k: string]: unknown } /** - * Email address for the contact. + * Email address for the contact. This is required when creating new contacts. */ - email: string + email?: string /** * The contact's given name. */ diff --git a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/index.ts b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/index.ts index 3565da201b..6097de88bd 100644 --- a/packages/destination-actions/src/destinations/loops/createOrUpdateContact/index.ts +++ b/packages/destination-actions/src/destinations/loops/createOrUpdateContact/index.ts @@ -23,10 +23,10 @@ const action: ActionDefinition = { }, email: { label: 'Contact Email', - description: 'Email address for the contact.', + description: 'Email address for the contact. This is required when creating new contacts.', type: 'string', format: 'email', - required: true, + required: false, default: { '@if': { exists: { '@path': '$.traits.email' }, diff --git a/packages/destination-actions/src/destinations/mantle/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/mantle/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..4a1eb2654e --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-mantle destination: identify action - all fields 1`] = ` +Object { + "customFields": Object { + "testType": "f$gKO(@kXal", + }, + "email": "cothe@kogufo.as", + "myshopifyDomain": "f$gKO(@kXal", + "name": "f$gKO(@kXal", + "platform": "shopify", + "platformId": "f$gKO(@kXal", +} +`; + +exports[`Testing snapshot for actions-mantle destination: identify action - required fields 1`] = ` +Object { + "myshopifyDomain": "f$gKO(@kXal", + "platform": "shopify", + "platformId": "f$gKO(@kXal", +} +`; + +exports[`Testing snapshot for actions-mantle destination: pushEvent action - all fields 1`] = ` +Object { + "customerId": "1ebNi5n[1Ax", + "event_id": "1ebNi5n[1Ax", + "event_name": "1ebNi5n[1Ax", + "properties": Object { + "testType": "1ebNi5n[1Ax", + }, + "timestamp": "2021-02-01T00:00:00.000Z", +} +`; + +exports[`Testing snapshot for actions-mantle destination: pushEvent action - required fields 1`] = ` +Object { + "customerId": "1ebNi5n[1Ax", + "event_name": "1ebNi5n[1Ax", + "properties": Object {}, +} +`; diff --git a/packages/destination-actions/src/destinations/mantle/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mantle/__tests__/index.test.ts new file mode 100644 index 0000000000..748c1ff8f4 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/__tests__/index.test.ts @@ -0,0 +1,32 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +import { API_URL } from '../config' + +const testDestination = createTestIntegration(Definition) + +describe('Mantle', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock(API_URL).get('/app').reply(200, {}) + + const authData = { + appId: 'fake-app-id', + apiKey: 'fake-api-key' + } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + it('should fail on invalid authentication inputs', async () => { + nock(API_URL).get('/app').reply(401, {}) + + const authData = { + appId: 'fake-app-id', + apiKey: '' + } + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/mantle/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/mantle/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..2a3a2ca060 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-mantle' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/mantle/config.ts b/packages/destination-actions/src/destinations/mantle/config.ts new file mode 100644 index 0000000000..6584373aea --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/config.ts @@ -0,0 +1 @@ +export const API_URL = 'https://appapi.heymantle.com/v1' diff --git a/packages/destination-actions/src/destinations/mantle/generated-types.ts b/packages/destination-actions/src/destinations/mantle/generated-types.ts new file mode 100644 index 0000000000..ea175e27c3 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The unique identifier for the app in Mantle. Get this from the API Keys section for your app in Mantle. + */ + appId: string + /** + * The API key for the app in Mantle. Get this from the API Keys section for your app in Mantle. + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/mantle/identify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/mantle/identify/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..a60f16a9c2 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/identify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MantleRevOps's identify destination action: all fields 1`] = ` +Object { + "customFields": Object { + "testType": "P%L*#Z]I@", + }, + "email": "faacogad@wazjanom.nr", + "myshopifyDomain": "P%L*#Z]I@", + "name": "P%L*#Z]I@", + "platform": "shopify", + "platformId": "P%L*#Z]I@", +} +`; + +exports[`Testing snapshot for MantleRevOps's identify destination action: required fields 1`] = ` +Object { + "myshopifyDomain": "P%L*#Z]I@", + "platform": "shopify", + "platformId": "P%L*#Z]I@", +} +`; diff --git a/packages/destination-actions/src/destinations/mantle/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mantle/identify/__tests__/index.test.ts new file mode 100644 index 0000000000..00a245447d --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/identify/__tests__/index.test.ts @@ -0,0 +1,55 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +import { API_URL } from '../../config' + +const testDestination = createTestIntegration(Destination) + +describe('MantleRevOps.identify', () => { + it('should identify a customer in mantle', async () => { + nock(API_URL).post('/identify').reply(200, {}) + + const responses = await testDestination.testAction('identify', { + event: createTestEvent({ + type: 'identify', + userId: 'test-user-id', + traits: { + email: 'test@example.com', + name: 'test-name', + platformId: 'test-platform-id', + myshopifyDomain: 'test-shopify-domain' + } + }), + settings: { + appId: 'fake-app-id', + apiKey: 'fake-api-key' + }, + mapping: { + email: { + '@path': '$.traits.email' + }, + name: { + '@path': '$.traits.name' + }, + platformId: { + '@path': '$.traits.platformId' + }, + myshopifyDomain: { + '@path': '$.traits.myshopifyDomain' + }, + useDefaultMappings: true + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + platform: 'shopify', + platformId: 'test-platform-id', + myshopifyDomain: 'test-shopify-domain', + email: 'test@example.com', + name: 'test-name' + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/mantle/identify/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/mantle/identify/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..4914f20c50 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/identify/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'identify' +const destinationSlug = 'MantleRevOps' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/mantle/identify/generated-types.ts b/packages/destination-actions/src/destinations/mantle/identify/generated-types.ts new file mode 100644 index 0000000000..c1a2ee2c82 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/identify/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The unique identifier for the Shopify shop. This is used to associate the customer with a Shopify shop in Mantle + */ + platformId: string + /** + * The unique .myshopify.com domain of the Shopify shop. This is used to associate the customer with a Shopify shop in Mantle + */ + myshopifyDomain: string + /** + * The name of the customer / shop + */ + name?: string + /** + * The email of the customer + */ + email?: string + /** + * The custom fields of the customer / shop + */ + customFields?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/mantle/identify/index.ts b/packages/destination-actions/src/destinations/mantle/identify/index.ts new file mode 100644 index 0000000000..38bcfd502d --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/identify/index.ts @@ -0,0 +1,67 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +import { API_URL } from '../config' + +const action: ActionDefinition = { + title: 'Identify', + description: + 'Identify (create or update) a customer for your app in Mantle, including any additional information about the customer', + defaultSubscription: 'type = "identify"', + fields: { + platformId: { + label: 'Shopify Shop ID', + description: + 'The unique identifier for the Shopify shop. This is used to associate the customer with a Shopify shop in Mantle', + type: 'string', + required: true + }, + myshopifyDomain: { + label: 'Shopify Shop Domain', + description: + 'The unique .myshopify.com domain of the Shopify shop. This is used to associate the customer with a Shopify shop in Mantle', + type: 'string', + required: true + }, + name: { + label: 'Name', + description: 'The name of the customer / shop', + type: 'string', + required: false, + default: { '@path': '$.traits.name' } + }, + email: { + label: 'Email', + description: 'The email of the customer', + type: 'string', + required: false, + default: { '@path': '$.traits.email' } + }, + customFields: { + label: 'Custom Fields', + description: 'The custom fields of the customer / shop', + type: 'object', + required: false, + defaultObjectUI: 'keyvalue', + default: { + '@path': '$.traits.custom_fields' + } + } + }, + perform: (request, data) => { + return request(`${API_URL}/identify`, { + method: 'post', + json: { + platform: 'shopify', + platformId: data.payload.platformId, + myshopifyDomain: data.payload.myshopifyDomain, + name: data.payload.name, + email: data.payload.email, + ...(data.payload.customFields ? { customFields: data.payload.customFields } : {}) + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/mantle/index.ts b/packages/destination-actions/src/destinations/mantle/index.ts new file mode 100644 index 0000000000..a912feb531 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/index.ts @@ -0,0 +1,54 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import pushEvent from './pushEvent' +import identify from './identify' +import { API_URL } from './config' + +const destination: DestinationDefinition = { + name: 'Mantle (Actions)', + description: + 'Track important revenue metrics for your Shopify apps. Manage plans and pricing. Improve customer relationships. Focus on growing your business.', + slug: 'actions-mantle', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + appId: { + label: 'App ID', + description: + 'The unique identifier for the app in Mantle. Get this from the API Keys section for your app in Mantle.', + type: 'string', + required: true + }, + apiKey: { + label: 'API Key', + description: 'The API key for the app in Mantle. Get this from the API Keys section for your app in Mantle.', + type: 'string', + required: true + } + }, + testAuthentication: (request) => { + return request(`${API_URL}/app`, { + method: 'GET' + }) + } + }, + + extendRequest: ({ settings }) => { + return { + headers: { + 'x-mantle-app-id': settings.appId, + 'x-mantle-app-api-key': settings.apiKey + } + } + }, + + actions: { + pushEvent, + identify + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..d88a8d2f82 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MantleRevOps's pushEvent destination action: all fields 1`] = ` +Object { + "customerId": "vgsl(VF]C@p@66ob", + "event_id": "vgsl(VF]C@p@66ob", + "event_name": "vgsl(VF]C@p@66ob", + "properties": Object { + "testType": "vgsl(VF]C@p@66ob", + }, + "timestamp": "2021-02-01T00:00:00.000Z", +} +`; + +exports[`Testing snapshot for MantleRevOps's pushEvent destination action: required fields 1`] = ` +Object { + "customerId": "vgsl(VF]C@p@66ob", + "event_name": "vgsl(VF]C@p@66ob", + "properties": Object {}, +} +`; diff --git a/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..e139315bc7 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/index.test.ts @@ -0,0 +1,63 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +import { API_URL } from '../../config' + +const testDestination = createTestIntegration(Destination) + +describe('MantleRevOps.pushEvent', () => { + it('should push usage event to mantle', async () => { + nock(API_URL).post('/usage_events').reply(200, {}) + + const event = createTestEvent({ + type: 'track', + userId: 'test-user-id', + event: 'test-event', + timestamp: '2022-03-07T17:02:44.000Z', + properties: { + test: 'test', + customerId: 'test-customer-id', + eventId: 'test-event-id' + } + }) + const responses = await testDestination.testAction('pushEvent', { + event, + settings: { + appId: 'fake-app-id', + apiKey: 'fake-api-key' + }, + mapping: { + eventName: { + '@path': '$.event' + }, + eventId: { + '@path': '$.properties.eventId' + }, + customerId: { + '@path': '$.properties.customerId' + }, + properties: { + '@path': '$.properties' + }, + timestamp: { + '@path': '$.timestamp' + }, + useDefaultMappings: true + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + event_name: 'test-event', + customerId: 'test-customer-id', + properties: { + test: 'test', + customerId: 'test-customer-id', + eventId: 'test-event-id' + }, + timestamp: '2022-03-07T17:02:44.000Z' + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..94a2af8701 --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/pushEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'pushEvent' +const destinationSlug = 'MantleRevOps' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/mantle/pushEvent/generated-types.ts b/packages/destination-actions/src/destinations/mantle/pushEvent/generated-types.ts new file mode 100644 index 0000000000..dda87d8d0d --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/pushEvent/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The name of the event you want to push to Mantle + */ + eventName: string + /** + * The unique identifier for the event. This is used to deduplicate events in Mantle + */ + eventId?: string + /** + * The unique identifier for the customer. This is used to associate the event with a customer in Mantle. It can be the internal customer ID, API token, Shopify shop ID, or Shopify shop domain + */ + customerId: string + /** + * The properties of the event. This is the extra data you want to attach to the event + */ + properties?: { + [k: string]: unknown + } + /** + * The timestamp of the event, defaults to the current time + */ + timestamp?: string | number +} diff --git a/packages/destination-actions/src/destinations/mantle/pushEvent/index.ts b/packages/destination-actions/src/destinations/mantle/pushEvent/index.ts new file mode 100644 index 0000000000..c54d56299b --- /dev/null +++ b/packages/destination-actions/src/destinations/mantle/pushEvent/index.ts @@ -0,0 +1,78 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +import { API_URL } from '../config' + +const action: ActionDefinition = { + title: 'Push Event', + description: 'Push event to your app in Mantle', + defaultSubscription: 'type = "track"', + fields: { + eventName: { + label: 'Event Name', + description: 'The name of the event you want to push to Mantle', + type: 'string', + required: true, + default: { '@path': '$.event' } + }, + eventId: { + label: 'Event ID', + description: 'The unique identifier for the event. This is used to deduplicate events in Mantle', + type: 'string', + required: false, + default: { '@path': '$.messageId' } + }, + customerId: { + label: 'Customer ID', + description: + 'The unique identifier for the customer. This is used to associate the event with a customer in Mantle. It can be the internal customer ID, API token, Shopify shop ID, or Shopify shop domain', + type: 'string', + required: true + }, + properties: { + label: 'Event Properties', + description: 'The properties of the event. This is the extra data you want to attach to the event', + type: 'object', + required: false, + default: { '@path': '$.properties' } + }, + timestamp: { + label: 'Event timestamp', + description: 'The timestamp of the event, defaults to the current time', + type: 'datetime', + required: false, + default: { '@path': '$.timestamp' } + } + }, + perform: (request, data) => { + const payload = { + event_name: data.payload.eventName, + ...(data.payload.eventId ? { event_id: data.payload.eventId } : {}), + customerId: data.payload.customerId, + properties: data.payload.properties || {}, + ...(data.payload.timestamp ? { timestamp: data.payload.timestamp } : {}) + } + return request(`${API_URL}/usage_events`, { + method: 'post', + json: payload + }) + }, + performBatch: (request, data) => { + const events = data.payload.map((payload) => ({ + event_name: payload.eventName, + ...(payload.eventId ? { event_id: payload.eventId } : {}), + customerId: payload.customerId, + properties: payload.properties || {}, + ...(payload.timestamp ? { timestamp: payload.timestamp } : {}) + })) + return request(`${API_URL}/usage_events`, { + method: 'post', + json: { + events + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/__tests__/index.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/__tests__/index.test.ts new file mode 100644 index 0000000000..5dff5b7b4f --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/__tests__/index.test.ts @@ -0,0 +1,146 @@ +import nock from 'nock' +import { createTestIntegration, IntegrationError } from '@segment/actions-core' +import Destination from '../index' + +const audienceName = 'The Best Test Audience' +const folderName = 'Test Folder' +const clientId = 'test_client_id' +const clientSecret = 'test_client_secret' +const apiEndpoint = 'https://123-ABC-456.mktorest.com' +const testDestination = createTestIntegration(Destination) + +const createAudienceInput = { + settings: { + folder_name: folderName, + client_id: clientId, + client_secret: clientSecret, + api_endpoint: apiEndpoint + }, + audienceName: '' +} + +const getAudienceInput = { + settings: { + folder_name: folderName, + client_id: clientId, + client_secret: clientSecret, + api_endpoint: apiEndpoint + }, + audienceName: audienceName, + externalId: '782' +} + +describe('Marketo Static Lists', () => { + describe('createAudience', () => { + it('should fail if no audience name is set', async () => { + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + + it('creates an audience', async () => { + nock( + `${apiEndpoint}/identity/oauth/token?grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}` + ) + .post(/.*/) + .reply(200, { + access_token: 'access_token' + }) + + nock(`${apiEndpoint}/rest/asset/v1/folder/byName.json?name=${encodeURIComponent(folderName)}`) + .get(/.*/) + .reply(200, { + success: true, + result: [ + { + name: folderName, + id: 12 + } + ] + }) + + nock(`${apiEndpoint}/rest/asset/v1/staticLists.json?folder=12&name=${encodeURIComponent(audienceName)}`) + .post(/.*/) + .reply(200, { + success: true, + result: [ + { + name: audienceName, + id: 782 + } + ] + }) + + createAudienceInput.audienceName = audienceName + + const r = await testDestination.createAudience(createAudienceInput) + expect(r).toEqual({ + externalId: '782' + }) + }) + + it('errors out when audience with same name already exists', async () => { + nock( + `${apiEndpoint}/identity/oauth/token?grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}` + ) + .post(/.*/) + .reply(200, { + access_token: 'access_token' + }) + + nock(`${apiEndpoint}/rest/asset/v1/folder/byName.json?name=${encodeURIComponent(folderName)}`) + .get(/.*/) + .reply(200, { + success: true, + result: [ + { + name: folderName, + id: 12 + } + ] + }) + + nock(`${apiEndpoint} /rest/asset/v1/staticLists.json`) + .post(/.*/) + .reply(200, { + success: false, + errors: [ + { + code: '709', + message: 'Static List with the same name already exists' + } + ] + }) + + createAudienceInput.audienceName = audienceName + + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + }) + + describe('getAudience', () => { + it('should succeed when with valid list id', async () => { + nock( + `${apiEndpoint}/identity/oauth/token?grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}` + ) + .post(/.*/) + .reply(200, { + access_token: 'access_token' + }) + nock(`${apiEndpoint}/rest/asset/v1/staticList/782.json`) + .get(/.*/) + .reply(200, { + success: true, + result: [ + { + name: folderName, + id: 782 + } + ] + }) + + const r = await testDestination.getAudience(getAudienceInput) + expect(r).toEqual({ + externalId: '782' + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/addToList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/__tests__/index.test.ts new file mode 100644 index 0000000000..0e490ac964 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/__tests__/index.test.ts @@ -0,0 +1,69 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { BULK_IMPORT_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const EXTERNAL_AUDIENCE_ID = '12345' +const API_ENDPOINT = 'https://marketo.com' +const settings = { + client_id: '1234', + client_secret: '1234', + api_endpoint: 'https://marketo.com', + folder_name: 'Test Audience' +} + +const event = createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: {}, + context: { + traits: { + email: 'testing@testing.com' + }, + personas: { + external_audience_id: EXTERNAL_AUDIENCE_ID + } + } +}) + +describe('MarketoStaticLists.addToList', () => { + it('should succeed if response from Marketo is successful', async () => { + const bulkImport = API_ENDPOINT + BULK_IMPORT_ENDPOINT.replace('externalId', EXTERNAL_AUDIENCE_ID) + nock(bulkImport).post(/.*/).reply(200, { success: true }) + + const r = await testDestination.testAction('addToList', { + event, + settings: settings, + useDefaultMappings: true + }) + + expect(r[0].status).toEqual(200) + expect(r[0].options.body).toMatchInlineSnapshot(` + "----SEGMENT-DATA-- + Content-Disposition: form-data; name=\\"file\\"; filename=\\"leads.csv\\" + Content-Type: text/csv + + email + testing@testing.com + ----SEGMENT-DATA---- + " + `) + }) + + it('should fail if Marketo returns error', async () => { + const bulkImport = API_ENDPOINT + BULK_IMPORT_ENDPOINT.replace('externalId', 'invalidID') + nock(bulkImport) + .post(/.*/) + .reply(200, { success: false, errors: [{ code: 1013, message: 'Static list not found' }] }) + + await expect( + testDestination.testAction('addToList', { + event, + settings: settings, + useDefaultMappings: true + }) + ).rejects.toThrow('Static list not found') + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/addToList/generated-types.ts b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/generated-types.ts new file mode 100644 index 0000000000..e46ddfb691 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/generated-types.ts @@ -0,0 +1,46 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the Static List that users will be synced to. + */ + external_id: string + /** + * The lead field to use for deduplication and filtering. This field must be apart of the field(s) you are sending to Marketo. + */ + lookup_field: string + /** + * The fields that contain data about the lead, such as Email, Last Name, etc. On the left-hand side, input the field name exactly how it appears in Marketo. On the right-hand side, map the Segment field that contains the corresponding value. + */ + data: { + /** + * The user's email address to send to Marketo. + */ + email?: string + /** + * The user's first name. + */ + firstName?: string + /** + * The user's last name. + */ + lastName?: string + /** + * The user's phone number. + */ + phone?: string + [k: string]: unknown + } + /** + * Enable batching of requests. + */ + enable_batching: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size: number + /** + * The name of the current Segment event. + */ + event_name: string +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/addToList/index.ts b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/index.ts new file mode 100644 index 0000000000..f27d4ec34a --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/addToList/index.ts @@ -0,0 +1,29 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { external_id, lookup_field, data, enable_batching, batch_size, event_name } from '../properties' +import { addToList } from '../functions' + +const action: ActionDefinition = { + title: 'Add to List', + description: 'Add users from an Engage Audience to a list in Marketo.', + defaultSubscription: 'event = "Audience Entered"', + fields: { + external_id: { ...external_id }, + lookup_field: { ...lookup_field }, + data: { ...data }, + enable_batching: { ...enable_batching }, + batch_size: { ...batch_size }, + event_name: { ...event_name } + }, + perform: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('addToAudience', 1, statsContext?.tags) + return addToList(request, settings, [payload], statsContext) + }, + performBatch: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('addToAudience.batch', 1, statsContext?.tags) + return addToList(request, settings, payload, statsContext) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/constants.ts b/packages/destination-actions/src/destinations/marketo-static-lists/constants.ts new file mode 100644 index 0000000000..62705a1c1d --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/constants.ts @@ -0,0 +1,81 @@ +import { RequestClient } from '@segment/actions-core' +import { Settings } from './generated-types' + +const API_VERSION = 'v1' +const OAUTH_ENDPOINT = 'identity/oauth/token' +export const GET_FOLDER_ENDPOINT = `/rest/asset/${API_VERSION}/folder/byName.json?name=folderName` +export const CREATE_LIST_ENDPOINT = `/rest/asset/${API_VERSION}/staticLists.json?folder=folderId&name=listName` +export const GET_LIST_ENDPOINT = `/rest/asset/${API_VERSION}/staticList/listId.json` +export const BULK_IMPORT_ENDPOINT = `/bulk/${API_VERSION}/leads.json?format=csv&listId=externalId&lookupField=fieldToLookup` +export const GET_LEADS_ENDPOINT = `/rest/${API_VERSION}/leads.json?filterType=field&filterValues=emailsToFilter` +export const REMOVE_USERS_ENDPOINT = `/rest/${API_VERSION}/lists/listId/leads.json?id=idsToDelete` + +export const CSV_LIMIT = 10000000 // 10MB +export interface RefreshTokenResponse { + access_token: string +} + +export interface MarketoResponse { + requestId: string + success: boolean + errors: [ + { + code: string + message: string + } + ] +} + +export interface MarketoListResponse extends MarketoResponse { + result: [ + { + name: string + id: number + } + ] +} + +export interface MarketoBulkImportResponse extends MarketoResponse { + result: [ + { + batchId: number + importId: string + status: string + } + ] +} + +export interface MarketoGetLeadsResponse extends MarketoResponse { + result: [MarketoLeads] +} + +export interface MarketoDeleteLeadsResponse extends MarketoResponse { + result: [ + { + id: number + status: string + } + ] +} + +export interface MarketoLeads { + id: number + firstName: string + lastName: string + email: string + updatedAt: string + createdAt: string +} + +export async function getAccessToken(request: RequestClient, settings: Settings) { + const res = await request(`${settings.api_endpoint}/${OAUTH_ENDPOINT}`, { + method: 'POST', + body: new URLSearchParams({ + client_id: settings.client_id, + client_secret: settings.client_secret, + grant_type: 'client_credentials' + }) + }) + + return res.data.access_token +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/functions.ts b/packages/destination-actions/src/destinations/marketo-static-lists/functions.ts new file mode 100644 index 0000000000..783b020b15 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/functions.ts @@ -0,0 +1,154 @@ +import { IntegrationError, RetryableError, RequestClient, StatsContext } from '@segment/actions-core' +import { Settings } from './generated-types' +import { Payload as AddToListPayload } from './addToList/generated-types' +import { Payload as RemoveFromListPayload } from './removeFromList/generated-types' +import { + CSV_LIMIT, + BULK_IMPORT_ENDPOINT, + MarketoBulkImportResponse, + GET_LEADS_ENDPOINT, + MarketoGetLeadsResponse, + MarketoLeads, + MarketoDeleteLeadsResponse, + REMOVE_USERS_ENDPOINT, + MarketoResponse +} from './constants' + +// Keep only the scheme and host from the endpoint +// Marketo UI shows endpoint with trailing "/rest", which we don't want +export function formatEndpoint(endpoint: string) { + return endpoint.replace('/rest', '') +} + +export async function addToList( + request: RequestClient, + settings: Settings, + payloads: AddToListPayload[], + statsContext?: StatsContext +) { + const api_endpoint = formatEndpoint(settings.api_endpoint) + + const csvData = formatData(payloads) + const csvSize = Buffer.byteLength(csvData, 'utf8') + if (csvSize > CSV_LIMIT) { + statsContext?.statsClient?.incr('addToAudience.error', 1, statsContext?.tags) + throw new IntegrationError(`CSV data size exceeds limit of ${CSV_LIMIT} bytes`, 'INVALID_REQUEST_DATA', 400) + } + + const url = + api_endpoint + + BULK_IMPORT_ENDPOINT.replace('externalId', payloads[0].external_id).replace( + 'fieldToLookup', + payloads[0].lookup_field + ) + + const response = await request(url, { + method: 'POST', + headers: { + 'Content-Type': 'multipart/form-data; boundary=--SEGMENT-DATA--' + }, + body: createFormData(csvData) + }) + + if (!response.data.success) { + statsContext?.statsClient?.incr('addToAudience.error', 1, statsContext?.tags) + parseErrorResponse(response.data) + } + statsContext?.statsClient?.incr('addToAudience.success', 1, statsContext?.tags) + return response.data +} + +export async function removeFromList( + request: RequestClient, + settings: Settings, + payloads: RemoveFromListPayload[], + statsContext?: StatsContext +) { + const api_endpoint = formatEndpoint(settings.api_endpoint) + const usersToRemove = extractFilterData(payloads) + + const getLeadsUrl = + api_endpoint + + GET_LEADS_ENDPOINT.replace('field', payloads[0].lookup_field).replace( + 'emailsToFilter', + encodeURIComponent(usersToRemove) + ) + + // Get lead ids from Marketo + const getLeadsResponse = await request(getLeadsUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + + if (!getLeadsResponse.data.success) { + statsContext?.statsClient?.incr('removeFromAudience.error', 1, statsContext?.tags) + parseErrorResponse(getLeadsResponse.data) + } + + const leadIds = extractLeadIds(getLeadsResponse.data.result) + + const deleteLeadsUrl = + api_endpoint + REMOVE_USERS_ENDPOINT.replace('listId', payloads[0].external_id).replace('idsToDelete', leadIds) + + // DELETE lead ids from list in Marketo + const deleteLeadsResponse = await request(deleteLeadsUrl, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }) + + if (!deleteLeadsResponse.data.success) { + statsContext?.statsClient?.incr('removeFromAudience.error', 1, statsContext?.tags) + parseErrorResponse(deleteLeadsResponse.data) + } + statsContext?.statsClient?.incr('removeFromAudience.success', 1, statsContext?.tags) + return deleteLeadsResponse.data +} + +function createFormData(csvData: string) { + const boundary = '--SEGMENT-DATA--' + const formData = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="leads.csv"\r\nContent-Type: text/csv\r\n\r\n${csvData}\r\n--${boundary}--\r\n` + return formData +} + +function formatData(payloads: AddToListPayload[]) { + if (payloads.length === 0) { + return '' + } + + const allKeys = [...new Set(payloads.flatMap((payload) => Object.keys(payload.data)))] + const header = allKeys.join(',') + const csvData = payloads + .map((payload) => allKeys.map((key) => payload.data[key as keyof typeof payload.data] || '').join(',')) + .join('\n') + + return `${header}\n${csvData}` +} + +function extractFilterData(payloads: RemoveFromListPayload[]) { + const data = payloads + .filter((payload) => payload.field_value !== undefined) + .map((payload) => payload.field_value) + .join(',') + return data +} + +function extractLeadIds(leads: MarketoLeads[]) { + const ids = leads.map((lead) => `${lead.id}`).join(',') + return ids +} + +function parseErrorResponse(response: MarketoResponse) { + if (response.errors[0].code === '601' || response.errors[0].code === '602') { + throw new IntegrationError(response.errors[0].message, 'INVALID_OAUTH_TOKEN', 401) + } + if (response.errors[0].code === '1019') { + throw new RetryableError( + 'Error while attempting to upload users to the list in Marketo. This batch will be retried.' + ) + } + throw new IntegrationError(response.errors[0].message, 'INVALID_RESPONSE', 400) +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/generated-types.ts b/packages/destination-actions/src/destinations/marketo-static-lists/generated-types.ts new file mode 100644 index 0000000000..4416c7bc88 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/generated-types.ts @@ -0,0 +1,20 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Marketo REST API Client ID. + */ + client_id: string + /** + * Your Marketo REST API Client Secret. + */ + client_secret: string + /** + * Your Marketo REST API Endpoint in this format: https://.mktorest.com. + */ + api_endpoint: string + /** + * Name of the folder in which to create static lists. + */ + folder_name: string +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/index.ts b/packages/destination-actions/src/destinations/marketo-static-lists/index.ts new file mode 100644 index 0000000000..d693355c6d --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/index.ts @@ -0,0 +1,163 @@ +import type { AudienceDestinationDefinition } from '@segment/actions-core' +import { IntegrationError } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import addToList from './addToList' +import removeFromList from './removeFromList' +import { + MarketoListResponse, + getAccessToken, + GET_FOLDER_ENDPOINT, + GET_LIST_ENDPOINT, + CREATE_LIST_ENDPOINT +} from './constants' +import { formatEndpoint } from './functions' + +const destination: AudienceDestinationDefinition = { + name: 'Marketo Static Lists (Actions)', + slug: 'actions-marketo-static-lists', + mode: 'cloud', + + authentication: { + scheme: 'oauth2', + fields: { + client_id: { + label: 'Client ID', + description: 'Your Marketo REST API Client ID.', + type: 'string', + required: true + }, + client_secret: { + label: 'Client Secret', + description: 'Your Marketo REST API Client Secret.', + type: 'password', + required: true + }, + api_endpoint: { + label: 'API Endpoint', + description: 'Your Marketo REST API Endpoint in this format: https://.mktorest.com.', + type: 'string', + required: true + }, + folder_name: { + label: 'Folder Name', + description: 'Name of the folder in which to create static lists.', + type: 'string', + required: true + } + }, + refreshAccessToken: async (request, { settings }) => { + return { accessToken: await getAccessToken(request, settings) } + } + }, + extendRequest({ auth }) { + return { + headers: { + authorization: `Bearer ${auth?.accessToken}` + } + } + }, + audienceFields: {}, + audienceConfig: { + mode: { + type: 'synced', // Indicates that the audience is synced on some schedule; update as necessary + full_audience_sync: false // If true, we send the entire audience. If false, we just send the delta. + }, + async createAudience(request, createAudienceInput) { + const audienceName = createAudienceInput.audienceName + const folder = createAudienceInput.settings.folder_name + const endpoint = formatEndpoint(createAudienceInput.settings.api_endpoint) + const statsClient = createAudienceInput?.statsContext?.statsClient + const statsTags = createAudienceInput?.statsContext?.tags + + if (!audienceName) { + throw new IntegrationError('Missing audience name value', 'MISSING_REQUIRED_FIELD', 400) + } + + // Get access token + const accessToken = await getAccessToken(request, createAudienceInput.settings) + + const getFolderUrl = endpoint + GET_FOLDER_ENDPOINT.replace('folderName', encodeURIComponent(folder)) + + // Get folder ID by name + const getFolderResponse = await request(getFolderUrl, { + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}` + } + }) + + // Since the API will return 200 we need to parse the response to see if it failed. + if (!getFolderResponse.data.success && getFolderResponse.data.errors) { + statsClient?.incr('createAudience.error', 1, statsTags) + throw new IntegrationError(`${getFolderResponse.data.errors[0].message}`, 'INVALID_RESPONSE', 400) + } + + if (!getFolderResponse.data.result) { + statsClient?.incr('createAudience.error', 1, statsTags) + throw new IntegrationError(`A folder with the name ${folder} not found`, 'INVALID_REQUEST_DATA', 400) + } + + const folderId = getFolderResponse.data.result[0].id.toString() + + const createListUrl = + endpoint + + CREATE_LIST_ENDPOINT.replace('folderId', folderId).replace('listName', encodeURIComponent(audienceName)) + + // Create list in given folder + const createListResponse = await request(createListUrl, { + method: 'POST', + headers: { + authorization: `Bearer ${accessToken}` + } + }) + + if (!createListResponse.data.success && createListResponse.data.errors) { + statsClient?.incr('createAudience.error', 1, statsTags) + throw new IntegrationError(`${createListResponse.data.errors[0].message}`, 'INVALID_RESPONSE', 400) + } + + const externalId = createListResponse.data.result[0].id.toString() + statsClient?.incr('createAudience.success', 1, statsTags) + + return { + externalId: externalId + } + }, + async getAudience(request, getAudienceInput) { + const endpoint = formatEndpoint(getAudienceInput.settings.api_endpoint) + const listId = getAudienceInput.externalId + const statsClient = getAudienceInput?.statsContext?.statsClient + const statsTags = getAudienceInput?.statsContext?.tags + + // Get access token + const accessToken = await getAccessToken(request, getAudienceInput.settings) + + const getListUrl = endpoint + GET_LIST_ENDPOINT.replace('listId', listId) + + const getListResponse = await request(getListUrl, { + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}` + } + }) + + if (!getListResponse.data.success && getListResponse.data.errors) { + statsClient?.incr('getAudience.error', 1, statsTags) + throw new IntegrationError(`${getListResponse.data.errors[0].message}`, 'INVALID_RESPONSE', 400) + } + + statsClient?.incr('getAudience.success', 1, statsTags) + + return { + externalId: listId + } + } + }, + actions: { + addToList, + removeFromList + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/properties.ts b/packages/destination-actions/src/destinations/marketo-static-lists/properties.ts new file mode 100644 index 0000000000..958c51841a --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/properties.ts @@ -0,0 +1,136 @@ +import { InputField } from '@segment/actions-core/destination-kit/types' + +export const external_id: InputField = { + label: 'External ID', + description: 'The ID of the Static List that users will be synced to.', + type: 'string', + default: { + '@path': '$.context.personas.external_audience_id' + }, + unsafe_hidden: true, + required: true +} + +export const field_value: InputField = { + label: 'Field Value', + description: 'The value cooresponding to the lookup field.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + required: true +} + +export const lookup_field: InputField = { + label: 'Lookup Field', + description: `The lead field to use for deduplication and filtering. This field must be apart of the field(s) you are sending to Marketo.`, + type: 'string', + choices: [ + { label: 'Email', value: 'email' }, + { label: 'Id', value: 'id' }, + { label: 'Cookies', value: 'cookies' }, + { label: 'Twitter ID', value: 'twitterId' }, + { label: 'Facebook ID', value: 'facebookId' }, + { label: 'LinkedIn ID', value: 'linkedinId' }, + { label: 'Salesforce Account ID', value: 'sfdcAccountId' }, + { label: 'Salesforce Contact ID', value: 'sfdcContactId' }, + { label: 'Salesforce Lead ID', value: 'sfdcLeadId' }, + { label: 'Salesforce Opportunity ID', value: 'sfdcOpptyId' } + ], + default: 'email', + required: true +} + +export const data: InputField = { + label: 'Lead Info Fields', + description: + 'The fields that contain data about the lead, such as Email, Last Name, etc. On the left-hand side, input the field name exactly how it appears in Marketo. On the right-hand side, map the Segment field that contains the corresponding value.', + type: 'object', + required: true, + properties: { + email: { + label: 'Email', + description: `The user's email address to send to Marketo.`, + type: 'string' + }, + firstName: { + label: 'First Name', + description: `The user's first name.`, + type: 'string' + }, + lastName: { + label: 'Last Name', + description: `The user's last name.`, + type: 'string' + }, + phone: { + label: 'Phone Number', + description: `The user's phone number.`, + type: 'string' + } + }, + default: { + email: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + }, + firstName: { + '@if': { + exists: { '@path': '$.context.traits.firstName' }, + then: { '@path': '$.context.traits.firstName' }, + else: { '@path': '$.properties.firstName' } + } + }, + lastName: { + '@if': { + exists: { '@path': '$.context.traits.lastName' }, + then: { '@path': '$.context.traits.lastName' }, + else: { '@path': '$.properties.lastName' } + } + }, + phone: { + '@if': { + exists: { '@path': '$.context.traits.phoneNumber' }, + then: { '@path': '$.context.traits.phoneNumber' }, + else: { '@path': '$.properties.phoneNumber' } + } + } + }, + additionalProperties: true +} + +export const enable_batching: InputField = { + label: 'Enable Batching', + description: 'Enable batching of requests.', + type: 'boolean', + default: true, + unsafe_hidden: true, + required: true +} + +export const batch_size: InputField = { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + default: 300000, + unsafe_hidden: true, + required: true +} + +export const event_name: InputField = { + label: 'Event Name', + description: 'The name of the current Segment event.', + type: 'string', + default: { + '@path': '$.event' + }, + readOnly: true, + required: true +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/__tests__/index.test.ts b/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/__tests__/index.test.ts new file mode 100644 index 0000000000..26e6dcd22f --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/__tests__/index.test.ts @@ -0,0 +1,88 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { GET_LEADS_ENDPOINT, REMOVE_USERS_ENDPOINT } from '../../constants' + +const testDestination = createTestIntegration(Destination) + +const EXTERNAL_AUDIENCE_ID = '12345' +const API_ENDPOINT = 'https://marketo.com' +const settings = { + client_id: '1234', + client_secret: '1234', + api_endpoint: 'https://marketo.com', + folder_name: 'Test Audience' +} + +const event = createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: {}, + context: { + traits: { + email: 'testing@testing.com' + }, + personas: { + external_audience_id: EXTERNAL_AUDIENCE_ID + } + } +}) + +describe('MarketoStaticLists.removeFromList', () => { + it('should succeed if response from Marketo is successful', async () => { + const getLeads = + API_ENDPOINT + GET_LEADS_ENDPOINT.replace('emailsToFilter', encodeURIComponent('testing@testing.com')) + nock(getLeads) + .get(/.*/) + .reply(200, { success: true, result: [{ id: 12 }] }) + + const deleteLeads = API_ENDPOINT + REMOVE_USERS_ENDPOINT.replace('listId', '12345').replace('idsToDelete', '12') + nock(deleteLeads).delete(/.*/).reply(200, { success: true }) + + const r = await testDestination.testAction('removeFromList', { + event, + settings: settings, + useDefaultMappings: true + }) + + expect(r[0].status).toEqual(200) + expect(r[1].status).toEqual(200) + }) + + it('should fail if Marketo returns error for get leads', async () => { + const getLeads = + API_ENDPOINT + GET_LEADS_ENDPOINT.replace('emailsToFilter', encodeURIComponent('testing@testing.com')) + nock(getLeads) + .get(/.*/) + .reply(200, { success: false, errors: [{ code: 1013, message: 'User not found' }] }) + + await expect( + testDestination.testAction('removeFromList', { + event, + settings: settings, + useDefaultMappings: true + }) + ).rejects.toThrow('User not found') + }) + + it('should fail if Marketo returns error for delete leads', async () => { + const getLeads = + API_ENDPOINT + GET_LEADS_ENDPOINT.replace('emailsToFilter', encodeURIComponent('testing@testing.com')) + nock(getLeads) + .get(/.*/) + .reply(200, { success: true, result: [{ id: 12 }] }) + + const deleteLeads = API_ENDPOINT + REMOVE_USERS_ENDPOINT.replace('listId', '12345').replace('idsToDelete', '12') + nock(deleteLeads) + .delete(/.*/) + .reply(200, { success: false, errors: [{ code: 1013, message: 'User not in list' }] }) + + await expect( + testDestination.testAction('removeFromList', { + event, + settings: settings, + useDefaultMappings: true + }) + ).rejects.toThrow('User not in list') + }) +}) diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/generated-types.ts b/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/generated-types.ts new file mode 100644 index 0000000000..533b7ecd82 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/generated-types.ts @@ -0,0 +1,28 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the Static List that users will be synced to. + */ + external_id: string + /** + * The lead field to use for deduplication and filtering. This field must be apart of the field(s) you are sending to Marketo. + */ + lookup_field: string + /** + * The value cooresponding to the lookup field. + */ + field_value: string + /** + * Enable batching of requests. + */ + enable_batching: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size: number + /** + * The name of the current Segment event. + */ + event_name: string +} diff --git a/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/index.ts b/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/index.ts new file mode 100644 index 0000000000..3e06b12193 --- /dev/null +++ b/packages/destination-actions/src/destinations/marketo-static-lists/removeFromList/index.ts @@ -0,0 +1,29 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { external_id, lookup_field, field_value, enable_batching, batch_size, event_name } from '../properties' +import { removeFromList } from '../functions' + +const action: ActionDefinition = { + title: 'Remove From List', + description: 'Remove users from a list in Marketo.', + defaultSubscription: 'event = "Audience Exited"', + fields: { + external_id: { ...external_id }, + lookup_field: { ...lookup_field }, + field_value: { ...field_value }, + enable_batching: { ...enable_batching }, + batch_size: { ...batch_size }, + event_name: { ...event_name } + }, + perform: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('removeFromAudience', 1, statsContext?.tags) + return removeFromList(request, settings, [payload], statsContext) + }, + performBatch: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('removeFromAudience.batch', 1, statsContext?.tags) + return removeFromList(request, settings, payload, statsContext) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts index dfb8aee100..9300b7fb61 100644 --- a/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/alias/__tests__/index.test.ts @@ -1,7 +1,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { ApiRegions } from '../../utils' +import { ApiRegions } from '../../common/utils' const testDestination = createTestIntegration(Destination) const MIXPANEL_API_SECRET = 'test-api-key' @@ -111,7 +111,7 @@ describe('Mixpanel.alias', () => { settings: { projectToken: MIXPANEL_PROJECT_TOKEN, apiSecret: MIXPANEL_API_SECRET, - sourceName: 'example segment source name', + sourceName: 'example segment source name' } }) expect(responses.length).toBe(1) diff --git a/packages/destination-actions/src/destinations/mixpanel/alias/index.ts b/packages/destination-actions/src/destinations/mixpanel/alias/index.ts index bc8f4a2279..e40b380284 100644 --- a/packages/destination-actions/src/destinations/mixpanel/alias/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/alias/index.ts @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { getApiServerUrl } from '../utils' +import { getApiServerUrl } from '../common/utils' const action: ActionDefinition = { title: 'Alias', diff --git a/packages/destination-actions/src/destinations/mixpanel/common/__test__/utils.test.ts b/packages/destination-actions/src/destinations/mixpanel/common/__test__/utils.test.ts new file mode 100644 index 0000000000..75218a2292 --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/common/__test__/utils.test.ts @@ -0,0 +1,54 @@ +import { getBrowser, getBrowserVersion } from '../utils' + +const userAgentToBrowserTestCase = [ + { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36', + browser: 'Chrome', + version: '117.0.0.0' + }, + { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + browser: 'Safari', + version: '17.0' + }, + { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + browser: 'Mobile Safari', + version: '17.0' + }, + { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.60', + browser: 'Microsoft Edge', + version: '117.0.2045.60' + } +] + +describe('Mixpanel Browser Utility Functions', () => { + describe('getBrowser', () => { + userAgentToBrowserTestCase.forEach((test) => { + it(`should parse well-formed userAgent: ${test.browser}`, () => { + expect(getBrowser(test.userAgent)).toEqual(test.browser) + }) + }) + + it(`return empty string for unknown browser`, () => { + expect(getBrowser(`Non-existent userAgent`)).toEqual(``) + }) + }) + + describe('getVersion', () => { + userAgentToBrowserTestCase.forEach((test) => { + it(`should parse well-formed userAgent: ${test.browser}`, () => { + expect(getBrowserVersion(test.userAgent)).toEqual(test.version) + }) + }) + + it(`return undefined for unknown browser`, () => { + expect(getBrowserVersion(`unknown userAgent Version/118.0`)).toBeUndefined() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/mixpanel/utils.ts b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts similarity index 76% rename from packages/destination-actions/src/destinations/mixpanel/utils.ts rename to packages/destination-actions/src/destinations/mixpanel/common/utils.ts index 53c8c2f28a..5063aad50b 100644 --- a/packages/destination-actions/src/destinations/mixpanel/utils.ts +++ b/packages/destination-actions/src/destinations/mixpanel/common/utils.ts @@ -45,6 +45,9 @@ export function getBrowser(userAgent: string): string { } else if (userAgent.includes('FxiOS')) { return 'Firefox iOS' } else if (userAgent.includes('Safari')) { + if (userAgent.includes('iPhone')) { + return `Mobile Safari` + } return 'Safari' } else if (userAgent.includes('Android')) { return 'Android Mobile' @@ -63,23 +66,24 @@ export function getBrowser(userAgent: string): string { export function getBrowserVersion(userAgent: string) { const browser = getBrowser(userAgent) + const versionRegexs: { [browser: string]: RegExp } = { - 'Internet Explorer Mobile': /rv:(\d+(\.\d+)?)/, - 'Microsoft Edge': /Edge?\/(\d+(\.\d+)?)/, - Chrome: /Chrome\/(\d+(\.\d+)?)/, - 'Chrome iOS': /CriOS\/(\d+(\.\d+)?)/, - 'UC Browser': /(UCBrowser|UCWEB)\/(\d+(\.\d+)?)/, - Safari: /Version\/(\d+(\.\d+)?)/, - 'Mobile Safari': /Version\/(\d+(\.\d+)?)/, - Opera: /(Opera|OPR)\/(\d+(\.\d+)?)/, - Firefox: /Firefox\/(\d+(\.\d+)?)/, - 'Firefox iOS': /FxiOS\/(\d+(\.\d+)?)/, - Konqueror: /Konqueror:(\d+(\.\d+)?)/, - BlackBerry: /BlackBerry (\d+(\.\d+)?)/, - 'Android Mobile': /android\s(\d+(\.\d+)?)/, - 'Samsung Internet': /SamsungBrowser\/(\d+(\.\d+)?)/, - 'Internet Explorer': /(rv:|MSIE )(\d+(\.\d+)?)/, - Mozilla: /rv:(\d+(\.\d+)?)/ + 'Internet Explorer Mobile': /rv:(\d+(\.\d+)+)/, + 'Microsoft Edge': /Edge?\/(\d+(\.\d+)+)/, + Chrome: /Chrome\/(\d+(\.\d+)+)/, + 'Chrome iOS': /CriOS\/(\d+(\.\d+)+)/, + 'UC Browser': /(UCBrowser|UCWEB)\/(\d+(\.\d+)+)/, + Safari: /Version\/(\d+(\.\d+)+)/, + 'Mobile Safari': /Version\/(\d+(\.\d+)+)/, + Opera: /(Opera|OPR)\/(\d+(\.\d+)+)/, + Firefox: /Firefox\/(\d+(\.\d+)+)/, + 'Firefox iOS': /FxiOS\/(\d+(\.\d+)+)/, + Konqueror: /Konqueror:(\d+(\.\d+)+)/, + BlackBerry: /BlackBerry (\d+(\.\d+)+)/, + 'Android Mobile': /android\s(\d+(\.\d+)+)/, + 'Samsung Internet': /SamsungBrowser\/(\d+(\.\d+)+)/, + 'Internet Explorer': /(rv:|MSIE )(\d+(\.\d+)+)/, + Mozilla: /rv:(\d+(\.\d+)+)/ } const regex = versionRegexs[browser] if (!regex) return regex @@ -87,6 +91,7 @@ export function getBrowserVersion(userAgent: string) { if (!matches) { return undefined } + return matches[matches.length - 2] } diff --git a/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts index 907992f273..742ba45c6d 100644 --- a/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/__tests__/index.test.ts @@ -1,7 +1,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { ApiRegions } from '../../utils' +import { ApiRegions } from '../../common/utils' const testDestination = createTestIntegration(Destination) const MIXPANEL_API_SECRET = 'test-api-key' diff --git a/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/index.ts b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/index.ts index 1002aa3081..b1635dbde6 100644 --- a/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/groupIdentifyUser/index.ts @@ -1,6 +1,6 @@ import { ActionDefinition, IntegrationError, omit } from '@segment/actions-core' import type { Settings } from '../generated-types' -import { getApiServerUrl } from '../utils' +import { getApiServerUrl } from '../common/utils' import type { Payload } from './generated-types' const action: ActionDefinition = { @@ -48,16 +48,16 @@ const action: ActionDefinition = { const traits = { ...omit(payload.traits, ['name']), - $name: payload.traits.name // transform to Mixpanel reserved property + $name: payload.traits.name // transform to Mixpanel reserved property } const data = { $token: settings.projectToken, $group_key: group_key, $group_id: group_id, - $set: traits, + $set: traits } - return request(`${ getApiServerUrl(settings.apiRegion) }/groups`, { + return request(`${getApiServerUrl(settings.apiRegion)}/groups`, { method: 'post', body: new URLSearchParams({ data: JSON.stringify(data) }) }) diff --git a/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts index 47c0fddcd3..97ba7a649a 100644 --- a/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/identifyUser/__tests__/index.test.ts @@ -1,7 +1,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { ApiRegions } from '../../utils' +import { ApiRegions } from '../../common/utils' const testDestination = createTestIntegration(Destination) const MIXPANEL_API_SECRET = 'test-api-key' @@ -11,7 +11,8 @@ const timestamp = '2021-08-17T15:21:15.449Z' describe('Mixpanel.identifyUser', () => { it('should validate action fields', async () => { const event = createTestEvent({ - timestamp, traits: { + timestamp, + traits: { abc: '123', created: '2022-10-12T00:00:00.000Z', email: 'joe@mixpanel.com', @@ -19,7 +20,7 @@ describe('Mixpanel.identifyUser', () => { lastName: 'Doe', username: 'Joe Doe', phone: '12345678', - name: 'Joe', + name: 'Joe' } }) @@ -75,20 +76,23 @@ describe('Mixpanel.identifyUser', () => { it('name should automatically be derived from the firstName and lastName traits if they are defined.', async () => { const event = createTestEvent({ - timestamp, traits: { + timestamp, + traits: { firstName: 'Joe', lastName: 'Doe' } }) const event2 = createTestEvent({ - timestamp, traits: { + timestamp, + traits: { firstName: 'Joe' } }) const event3 = createTestEvent({ - timestamp, traits: { + timestamp, + traits: { lastName: 'Doe' } }) @@ -270,7 +274,7 @@ describe('Mixpanel.identifyUser', () => { settings: { projectToken: MIXPANEL_PROJECT_TOKEN, apiSecret: MIXPANEL_API_SECRET, - sourceName: 'example segment source name', + sourceName: 'example segment source name' } }) expect(responses.length).toBe(2) diff --git a/packages/destination-actions/src/destinations/mixpanel/identifyUser/index.ts b/packages/destination-actions/src/destinations/mixpanel/identifyUser/index.ts index 109c48457b..4e86ec46db 100644 --- a/packages/destination-actions/src/destinations/mixpanel/identifyUser/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/identifyUser/index.ts @@ -2,7 +2,7 @@ import { ActionDefinition, IntegrationError, omit } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { getApiServerUrl, getConcatenatedName } from '../utils' +import { getApiServerUrl, getConcatenatedName } from '../common/utils' import { MixpanelEngageProperties, MixpanelEngageSet } from '../mixpanel-types' const action: ActionDefinition = { diff --git a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..0e928567c3 --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Mixpanel's identifyUser destination action: all fields 1`] = `"data=%7B%22event%22%3A%22%24identify%22%2C%22properties%22%3A%7B%22%24identified_id%22%3A%221SVsemB5FYy7%23Wu9%22%2C%22%24anon_id%22%3A%221SVsemB5FYy7%23Wu9%22%2C%22token%22%3A%221SVsemB5FYy7%23Wu9%22%2C%22segment_source_name%22%3A%221SVsemB5FYy7%23Wu9%22%7D%7D"`; + +exports[`Testing snapshot for Mixpanel's identifyUser destination action: required fields 1`] = `"data=%7B%22event%22%3A%22%24identify%22%2C%22properties%22%3A%7B%22%24identified_id%22%3A%221SVsemB5FYy7%23Wu9%22%2C%22%24anon_id%22%3A%221SVsemB5FYy7%23Wu9%22%2C%22token%22%3A%221SVsemB5FYy7%23Wu9%22%2C%22segment_source_name%22%3A%221SVsemB5FYy7%23Wu9%22%7D%7D"`; + +exports[`Testing snapshot for Mixpanel's identifyUser destination action: required fields 2`] = ` +Headers { + Symbol(map): Object { + "Content-Type": Array [ + "application/x-www-form-urlencoded;charset=UTF-8", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/index.test.ts new file mode 100644 index 0000000000..f7927ce76e --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/index.test.ts @@ -0,0 +1,148 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { ApiRegions } from '../../common/utils' + +const testDestination = createTestIntegration(Destination) +const MIXPANEL_API_SECRET = 'test-api-key' +const MIXPANEL_PROJECT_TOKEN = 'test-proj-token' +const timestamp = '2021-08-17T15:21:15.449Z' + +describe('Mixpanel.incrementProperties', () => { + const defaultProperties = { term: 'foo', increment: { searches: 1 } } + it('should use EU server URL', async () => { + const event = createTestEvent({ timestamp, event: 'search', properties: defaultProperties }) + + nock('https://api-eu.mixpanel.com').post('/engage').reply(200, {}) + nock('https://api-eu.mixpanel.com').post('/track').reply(200, {}) + + const responses = await testDestination.testAction('incrementProperties', { + event, + useDefaultMappings: true, + settings: { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET, + apiRegion: ApiRegions.EU + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.body).toMatchObject( + new URLSearchParams({ + data: JSON.stringify({ + $token: MIXPANEL_PROJECT_TOKEN, + $distinct_id: 'user1234', + $ip: '8.8.8.8', + $add: { + searches: 1 + } + }) + }) + ) + }) + + it('should default to US endpoint if apiRegion setting is undefined', async () => { + const event = createTestEvent({ timestamp, event: 'search', properties: defaultProperties }) + + nock('https://api.mixpanel.com').post('/engage').reply(200, {}) + nock('https://api.mixpanel.com').post('/track').reply(200, {}) + + const responses = await testDestination.testAction('incrementProperties', { + event, + useDefaultMappings: true, + settings: { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.body).toMatchObject( + new URLSearchParams({ + data: JSON.stringify({ + $token: MIXPANEL_PROJECT_TOKEN, + $distinct_id: 'user1234', + $ip: '8.8.8.8', + $add: { + searches: 1 + } + }) + }) + ) + }) + + it('should use anonymous_id as distinct_id if user_id is missing', async () => { + const event = createTestEvent({ userId: null, event: 'search', properties: defaultProperties }) + + nock('https://api.mixpanel.com').post('/track').reply(200, {}) + nock('https://api.mixpanel.com').post('/engage').reply(200, {}) + + const responses = await testDestination.testAction('incrementProperties', { + event, + useDefaultMappings: true, + settings: { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.body).toMatchObject( + new URLSearchParams({ + data: JSON.stringify({ + $token: MIXPANEL_PROJECT_TOKEN, + $distinct_id: event.anonymousId, + $ip: '8.8.8.8', + $add: { + searches: 1 + } + }) + }) + ) + }) + + it('should $add values to increment numerical properties', async () => { + const event = createTestEvent({ + timestamp, + event: 'search', + properties: { + abc: '123', + increment: { + positive: 2, + negative: -2 + } + } + }) + + nock('https://api.mixpanel.com').post('/track').reply(200, {}) + nock('https://api.mixpanel.com').post('/engage').reply(200, {}) + + const responses = await testDestination.testAction('incrementProperties', { + event, + useDefaultMappings: true, + settings: { + projectToken: MIXPANEL_PROJECT_TOKEN, + apiSecret: MIXPANEL_API_SECRET + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].data).toMatchObject({}) + expect(responses[0].options.body).toMatchObject( + new URLSearchParams({ + data: JSON.stringify({ + $token: MIXPANEL_PROJECT_TOKEN, + $distinct_id: 'user1234', + $ip: '8.8.8.8', + $add: { + positive: 2, + negative: -2 + } + }) + }) + ) + }) +}) diff --git a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/snapshot.test.ts b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/snapshot.test.ts new file mode 100644 index 0000000000..0a828e059d --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/__test__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'identifyUser' +const destinationSlug = 'Mixpanel' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/generated-types.ts b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/generated-types.ts new file mode 100644 index 0000000000..abaab5bb64 --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The IP address of the user. This is only used for geolocation and won't be stored. + */ + ip?: string + /** + * The unique user identifier set by you + */ + user_id?: string | null + /** + * The generated anonymous ID for the user + */ + anonymous_id?: string | null + /** + * Object of properties and the values to increment or decrement. For example: `{"purchases": 1, "items": 6}}. + */ + increment: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/mixpanel/incrementProperties/index.ts b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/index.ts new file mode 100644 index 0000000000..630f87315e --- /dev/null +++ b/packages/destination-actions/src/destinations/mixpanel/incrementProperties/index.ts @@ -0,0 +1,96 @@ +import { ActionDefinition, IntegrationError, PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import { MixpanelEngageProperties } from '../mixpanel-types' +import { getApiServerUrl } from '../common/utils' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Increment Properties', + description: + 'Increment the value of a user profile property. [Learn More](https://developer.mixpanel.com/reference/profile-numerical-add).', + defaultSubscription: 'type = "track"', + fields: { + ip: { + label: 'IP Address', + type: 'string', + description: "The IP address of the user. This is only used for geolocation and won't be stored.", + default: { + '@path': '$.context.ip' + } + }, + user_id: { + label: 'User ID', + type: 'string', + allowNull: true, + description: 'The unique user identifier set by you', + default: { + '@path': '$.userId' + } + }, + anonymous_id: { + label: 'Anonymous ID', + type: 'string', + allowNull: true, + description: 'The generated anonymous ID for the user', + default: { + '@path': '$.anonymousId' + } + }, + increment: { + label: 'Increment Numerical Properties', + type: 'object', + description: + 'Object of properties and the values to increment or decrement. For example: `{"purchases": 1, "items": 6}}.', + multiple: false, + required: true, + defaultObjectUI: 'keyvalue', + default: { + '@path': '$.properties.increment' + } + } + }, + + perform: async (request, { payload, settings }) => { + if (!settings.projectToken) { + throw new IntegrationError('Missing project token', 'Missing required field', 400) + } + + const apiServerUrl = getApiServerUrl(settings.apiRegion) + + const responses = [] + + if (payload.increment && Object.keys(payload.increment).length > 0) { + const keys = Object.keys(payload.increment) + if (keys.length > 20) { + throw new PayloadValidationError('Exceeded maximum of 20 properties for increment call') + } + const data: MixpanelEngageProperties = { + $token: settings.projectToken, + $distinct_id: payload.user_id ?? payload.anonymous_id, + $ip: payload.ip + } + data.$add = {} + + for (const key of keys) { + const value = payload.increment[key] + if (typeof value === 'string' || typeof value === 'number') { + if (isNaN(+value)) { + throw new IntegrationError(`The key "${key}" was not numeric`, 'Non numeric increment value', 400) + } + data.$add[key] = +value + } + } + + const response = request(`${apiServerUrl}/engage`, { + method: 'post', + body: new URLSearchParams({ data: JSON.stringify(data) }) + }) + + responses.push(response) + } + + return Promise.all(responses) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/mixpanel/index.ts b/packages/destination-actions/src/destinations/mixpanel/index.ts index 319e805220..c7090bb533 100644 --- a/packages/destination-actions/src/destinations/mixpanel/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/index.ts @@ -5,9 +5,10 @@ import type { Settings } from './generated-types' import identifyUser from './identifyUser' import groupIdentifyUser from './groupIdentifyUser' +import incrementProperties from './incrementProperties' import alias from './alias' -import { ApiRegions, StrictMode } from './utils' +import { ApiRegions, StrictMode } from './common/utils' import trackPurchase from './trackPurchase' @@ -126,7 +127,8 @@ const destination: DestinationDefinition = { identifyUser, groupIdentifyUser, alias, - trackPurchase + trackPurchase, + incrementProperties } } diff --git a/packages/destination-actions/src/destinations/mixpanel/mixpanel-types.ts b/packages/destination-actions/src/destinations/mixpanel/mixpanel-types.ts index 3b7ea382d6..d8bc63af62 100644 --- a/packages/destination-actions/src/destinations/mixpanel/mixpanel-types.ts +++ b/packages/destination-actions/src/destinations/mixpanel/mixpanel-types.ts @@ -82,4 +82,7 @@ export type MixpanelEngageProperties = { $distinct_id?: string | null $ip?: string $set?: MixpanelEngageSet + $add?: MixpanelIncrementPropertiesObject } + +export type MixpanelIncrementPropertiesObject = { [key: string]: number } diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts index 510f45dfe9..1fa99ed9d3 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/__tests__/index.test.ts @@ -1,7 +1,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { ApiRegions, StrictMode } from '../../utils' +import { ApiRegions, StrictMode } from '../../common/utils' const testDestination = createTestIntegration(Destination) const MIXPANEL_API_SECRET = 'test-api-key' diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/functions.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/functions.ts index 6cd3ef60e9..f175949209 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/functions.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/functions.ts @@ -3,7 +3,7 @@ import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import dayjs from '../../../lib/dayjs' import { MixpanelEventProperties } from '../mixpanel-types' -import { getBrowser, getBrowserVersion, cheapGuid } from '../utils' +import { getBrowser, getBrowserVersion, cheapGuid } from '../common/utils' const mixpanelReservedProperties = ['time', 'id', '$anon_id', 'distinct_id', '$group_id', '$insert_id', '$user_id'] diff --git a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts index 1445bb1ee3..ceb473230d 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackEvent/index.ts @@ -2,7 +2,7 @@ import { ActionDefinition, RequestClient } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { MixpanelEvent } from '../mixpanel-types' -import { getApiServerUrl } from '../utils' +import { getApiServerUrl } from '../common/utils' import { getEventProperties } from './functions' import { eventProperties } from '../mixpanel-properties' @@ -19,11 +19,11 @@ const getEventFromPayload = (payload: Payload, settings: Settings): MixpanelEven const processData = async (request: RequestClient, settings: Settings, payload: Payload[]) => { const events = payload.map((value) => getEventFromPayload(value, settings)) - return request(`${ getApiServerUrl(settings.apiRegion) }/import?strict=${ settings.strictMode ?? `1` }`, { + return request(`${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, { method: 'post', json: events, headers: { - authorization: `Basic ${ Buffer.from(`${ settings.apiSecret }:`).toString('base64') }` + authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` } }) } diff --git a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts index c9551e97fc..005a8653ea 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/__tests__/index.test.ts @@ -1,7 +1,7 @@ import nock from 'nock' import { createTestEvent, createTestIntegration, omit } from '@segment/actions-core' import Destination from '../../index' -import { ApiRegions, StrictMode } from '../../utils' +import { ApiRegions, StrictMode } from '../../common/utils' import { SegmentEvent } from '@segment/actions-core' const testDestination = createTestIntegration(Destination) @@ -285,7 +285,7 @@ describe('Mixpanel.trackPurchase', () => { id: 'abc123', distinct_id: 'abc123', $device_id: 'anon-2134', - $browser: 'Safari', + $browser: 'Mobile Safari', $current_url: 'https://segment.com/academy/', $insert_id: '112c2a3c-7242-4327-9090-48a89de6a4110', $lib_version: '2.11.1', @@ -337,7 +337,7 @@ describe('Mixpanel.trackPurchase', () => { id: 'abc123', distinct_id: 'abc123', $device_id: 'anon-2134', - $browser: 'Safari', + $browser: 'Mobile Safari', $current_url: 'https://segment.com/academy/', $insert_id: '0112c2a3c-7242-4327-9090-48a89de6a4110', $lib_version: '2.11.1', @@ -369,7 +369,7 @@ describe('Mixpanel.trackPurchase', () => { id: 'abc123', distinct_id: 'abc123', $device_id: 'anon-2134', - $browser: 'Safari', + $browser: 'Mobile Safari', $current_url: 'https://segment.com/academy/', $insert_id: '1112c2a3c-7242-4327-9090-48a89de6a4110', $lib_version: '2.11.1', @@ -407,7 +407,7 @@ describe('Mixpanel.trackPurchase', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(responses[0].options.body).toMatchInlineSnapshot( - `"[{\\"event\\":\\"Order Completed\\",\\"properties\\":{\\"time\\":1629213675449,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"affiliation\\":\\"Super Online Store\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"coupon\\":\\"Mixpanel Day\\",\\"currency\\":\\"USD\\",\\"products\\":[{\\"product_id\\":\\"507f1f77bcf86cd799439011\\",\\"sku\\":\\"45790-32\\",\\"name\\":\\"Monopoly: 3rd Edition\\",\\"price\\":19,\\"position\\":1,\\"quantity\\":2,\\"coupon\\":\\"MOUNTAIN\\",\\"brand\\":\\"Unknown\\",\\"category\\":\\"Games\\",\\"variant\\":\\"Black\\",\\"url\\":\\"https://www.example.com/product/path\\",\\"image_url\\":\\"https://www.example.com/product/path.jpg\\"},{\\"product_id\\":\\"505bd76785ebb509fc183733\\",\\"sku\\":\\"46493-32\\",\\"name\\":\\"Uno Card Game\\",\\"price\\":3,\\"position\\":2,\\"category\\":\\"Games\\",\\"custom\\":\\"xyz\\"}],\\"revenue\\":5.99,\\"shipping\\":1.5,\\"tax\\":3,\\"total\\":24.48}}]"` + `"[{\\"event\\":\\"Order Completed\\",\\"properties\\":{\\"time\\":1629213675449,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Mobile Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"affiliation\\":\\"Super Online Store\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"coupon\\":\\"Mixpanel Day\\",\\"currency\\":\\"USD\\",\\"products\\":[{\\"product_id\\":\\"507f1f77bcf86cd799439011\\",\\"sku\\":\\"45790-32\\",\\"name\\":\\"Monopoly: 3rd Edition\\",\\"price\\":19,\\"position\\":1,\\"quantity\\":2,\\"coupon\\":\\"MOUNTAIN\\",\\"brand\\":\\"Unknown\\",\\"category\\":\\"Games\\",\\"variant\\":\\"Black\\",\\"url\\":\\"https://www.example.com/product/path\\",\\"image_url\\":\\"https://www.example.com/product/path.jpg\\"},{\\"product_id\\":\\"505bd76785ebb509fc183733\\",\\"sku\\":\\"46493-32\\",\\"name\\":\\"Uno Card Game\\",\\"price\\":3,\\"position\\":2,\\"category\\":\\"Games\\",\\"custom\\":\\"xyz\\"}],\\"revenue\\":5.99,\\"shipping\\":1.5,\\"tax\\":3,\\"total\\":24.48}}]"` ) }) @@ -424,7 +424,7 @@ describe('Mixpanel.trackPurchase', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(responses[0].options.body).toMatchInlineSnapshot( - `"[{\\"event\\":\\"Order Completed\\",\\"properties\\":{\\"time\\":1629213675449,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"affiliation\\":\\"Super Online Store\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"coupon\\":\\"Mixpanel Day\\",\\"currency\\":\\"USD\\",\\"products\\":[{\\"product_id\\":\\"507f1f77bcf86cd799439011\\",\\"sku\\":\\"45790-32\\",\\"name\\":\\"Monopoly: 3rd Edition\\",\\"price\\":19,\\"position\\":1,\\"quantity\\":2,\\"coupon\\":\\"MOUNTAIN\\",\\"brand\\":\\"Unknown\\",\\"category\\":\\"Games\\",\\"variant\\":\\"Black\\",\\"url\\":\\"https://www.example.com/product/path\\",\\"image_url\\":\\"https://www.example.com/product/path.jpg\\"},{\\"product_id\\":\\"505bd76785ebb509fc183733\\",\\"sku\\":\\"46493-32\\",\\"name\\":\\"Uno Card Game\\",\\"price\\":3,\\"position\\":2,\\"category\\":\\"Games\\",\\"custom\\":\\"xyz\\"}],\\"revenue\\":5.99,\\"shipping\\":1.5,\\"tax\\":3,\\"total\\":24.48}},{\\"event\\":\\"Product Purchased\\",\\"properties\\":{\\"time\\":1629213675448,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"0112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"product_id\\":\\"507f1f77bcf86cd799439011\\",\\"sku\\":\\"45790-32\\",\\"category\\":\\"Games\\",\\"name\\":\\"Monopoly: 3rd Edition\\",\\"brand\\":\\"Unknown\\",\\"variant\\":\\"Black\\",\\"price\\":19,\\"quantity\\":2,\\"coupon\\":\\"MOUNTAIN\\",\\"position\\":1,\\"url\\":\\"https://www.example.com/product/path\\",\\"image_url\\":\\"https://www.example.com/product/path.jpg\\"}},{\\"event\\":\\"Product Purchased\\",\\"properties\\":{\\"time\\":1629213675447,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"1112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"product_id\\":\\"505bd76785ebb509fc183733\\",\\"sku\\":\\"46493-32\\",\\"category\\":\\"Games\\",\\"name\\":\\"Uno Card Game\\",\\"price\\":3,\\"position\\":2}}]"` + `"[{\\"event\\":\\"Order Completed\\",\\"properties\\":{\\"time\\":1629213675449,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Mobile Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"affiliation\\":\\"Super Online Store\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"coupon\\":\\"Mixpanel Day\\",\\"currency\\":\\"USD\\",\\"products\\":[{\\"product_id\\":\\"507f1f77bcf86cd799439011\\",\\"sku\\":\\"45790-32\\",\\"name\\":\\"Monopoly: 3rd Edition\\",\\"price\\":19,\\"position\\":1,\\"quantity\\":2,\\"coupon\\":\\"MOUNTAIN\\",\\"brand\\":\\"Unknown\\",\\"category\\":\\"Games\\",\\"variant\\":\\"Black\\",\\"url\\":\\"https://www.example.com/product/path\\",\\"image_url\\":\\"https://www.example.com/product/path.jpg\\"},{\\"product_id\\":\\"505bd76785ebb509fc183733\\",\\"sku\\":\\"46493-32\\",\\"name\\":\\"Uno Card Game\\",\\"price\\":3,\\"position\\":2,\\"category\\":\\"Games\\",\\"custom\\":\\"xyz\\"}],\\"revenue\\":5.99,\\"shipping\\":1.5,\\"tax\\":3,\\"total\\":24.48}},{\\"event\\":\\"Product Purchased\\",\\"properties\\":{\\"time\\":1629213675448,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Mobile Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"0112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"product_id\\":\\"507f1f77bcf86cd799439011\\",\\"sku\\":\\"45790-32\\",\\"category\\":\\"Games\\",\\"name\\":\\"Monopoly: 3rd Edition\\",\\"brand\\":\\"Unknown\\",\\"variant\\":\\"Black\\",\\"price\\":19,\\"quantity\\":2,\\"coupon\\":\\"MOUNTAIN\\",\\"position\\":1,\\"url\\":\\"https://www.example.com/product/path\\",\\"image_url\\":\\"https://www.example.com/product/path.jpg\\"}},{\\"event\\":\\"Product Purchased\\",\\"properties\\":{\\"time\\":1629213675447,\\"ip\\":\\"8.8.8.8\\",\\"id\\":\\"abc123\\",\\"$anon_id\\":\\"anon-2134\\",\\"distinct_id\\":\\"abc123\\",\\"$browser\\":\\"Mobile Safari\\",\\"$browser_version\\":\\"9.0\\",\\"$current_url\\":\\"https://segment.com/academy/\\",\\"$device_id\\":\\"anon-2134\\",\\"$identified_id\\":\\"abc123\\",\\"$insert_id\\":\\"1112c2a3c-7242-4327-9090-48a89de6a4110\\",\\"$lib_version\\":\\"2.11.1\\",\\"$locale\\":\\"en-US\\",\\"$source\\":\\"segment\\",\\"$user_id\\":\\"abc123\\",\\"mp_country_code\\":\\"United States\\",\\"mp_lib\\":\\"Segment Actions: analytics.js\\",\\"timezone\\":\\"Europe/Amsterdam\\",\\"event_original_name\\":\\"Order Completed\\",\\"order_id\\":\\"order-id-123\\",\\"checkout_id\\":\\"checkout-id-123\\",\\"product_id\\":\\"505bd76785ebb509fc183733\\",\\"sku\\":\\"46493-32\\",\\"category\\":\\"Games\\",\\"name\\":\\"Uno Card Game\\",\\"price\\":3,\\"position\\":2}}]"` ) }) }) diff --git a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts index 8d7831c411..f2564b8923 100644 --- a/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts +++ b/packages/destination-actions/src/destinations/mixpanel/trackPurchase/index.ts @@ -2,7 +2,7 @@ import { ActionDefinition, RequestClient, omit } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { MixpanelEvent } from '../mixpanel-types' -import { getApiServerUrl, cheapGuid } from '../utils' +import { getApiServerUrl, cheapGuid } from '../common/utils' import { getEventProperties } from '../trackEvent/functions' import { eventProperties, productsProperties } from '../mixpanel-properties' import dayjs from '../../../lib/dayjs' @@ -51,11 +51,11 @@ const getPurchaseEventsFromPayload = (payload: Payload, settings: Settings): Mix const processData = async (request: RequestClient, settings: Settings, payload: Payload[]) => { const events = payload.map((value) => getPurchaseEventsFromPayload(value, settings)).flat() - return request(`${ getApiServerUrl(settings.apiRegion) }/import?strict=${ settings.strictMode ?? `1` }`, { + return request(`${getApiServerUrl(settings.apiRegion)}/import?strict=${settings.strictMode ?? `1`}`, { method: 'post', json: events, headers: { - authorization: `Basic ${ Buffer.from(`${ settings.apiSecret }:`).toString('base64') }` + authorization: `Basic ${Buffer.from(`${settings.apiSecret}:`).toString('base64')}` } }) } diff --git a/packages/destination-actions/src/destinations/moengage/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moengage/__tests__/__snapshots__/snapshot.test.ts.snap index 1f890c3e43..5f435f0412 100644 --- a/packages/destination-actions/src/destinations/moengage/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/moengage/__tests__/__snapshots__/snapshot.test.ts.snap @@ -19,6 +19,7 @@ Object { "testType": "z(%@QRCz7h$8M)#]", }, "type": "z(%@QRCz7h$8M)#]", + "update_existing_only": false, "user_id": "z(%@QRCz7h$8M)#]", } `; @@ -31,6 +32,7 @@ Object { "os": Object {}, }, "type": "z(%@QRCz7h$8M)#]", + "update_existing_only": false, } `; @@ -54,6 +56,7 @@ Object { }, "timestamp": "2021-02-01T00:00:00.000Z", "type": "E@t!q#n(^u", + "update_existing_only": true, "user_id": "E@t!q#n(^u", } `; @@ -67,5 +70,6 @@ Object { }, "event": "E@t!q#n(^u", "type": "E@t!q#n(^u", + "update_existing_only": false, } `; diff --git a/packages/destination-actions/src/destinations/moengage/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moengage/__tests__/index.test.ts index 5a8fd3e6ec..5668458734 100644 --- a/packages/destination-actions/src/destinations/moengage/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/moengage/__tests__/index.test.ts @@ -23,7 +23,7 @@ describe('Moengage', () => { } await expect(testDestination.testAuthentication(settings)).rejects.toThrow( - `Endpoint Region must be one of: "DC_01", "DC_02", "DC_03", or "DC_04".` + `Endpoint Region must be one of: "DC_01", "DC_02", "DC_03", "DC_04", or "DC_05".` ) }) diff --git a/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap index a2a4065247..18a1ad7ba0 100644 --- a/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -19,6 +19,7 @@ Object { "testType": "UQmlPo5)xL", }, "type": "UQmlPo5)xL", + "update_existing_only": true, "user_id": "UQmlPo5)xL", } `; @@ -31,5 +32,6 @@ Object { "os": Object {}, }, "type": "UQmlPo5)xL", + "update_existing_only": false, } `; diff --git a/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/index.test.ts index c572561cd0..69ab065809 100644 --- a/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/moengage/identifyUser/__tests__/index.test.ts @@ -30,7 +30,7 @@ describe('ActionsMoengage.identifyUser', () => { expect(responses[0].status).toBe(200) expect(responses[0].data).toMatchObject({}) expect(responses[0].options.body).toBe( - `{"type":"track","user_id":"user1234","traits":{"name":"abc"},"context":{"app":{},"os":{},"library":{"version":"2.11.1"}},"anonymous_id":"${event.anonymousId}","timestamp":"${event.timestamp}"}` + `{"type":"track","user_id":"user1234","traits":{"name":"abc"},"context":{"app":{},"os":{},"library":{"version":"2.11.1"}},"anonymous_id":"${event.anonymousId}","timestamp":"${event.timestamp}","update_existing_only":false}` ) }) diff --git a/packages/destination-actions/src/destinations/moengage/identifyUser/generated-types.ts b/packages/destination-actions/src/destinations/moengage/identifyUser/generated-types.ts index ce82d32e0a..9a97cf1f9f 100644 --- a/packages/destination-actions/src/destinations/moengage/identifyUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/moengage/identifyUser/generated-types.ts @@ -9,6 +9,10 @@ export interface Payload { * The unique user identifier set by you */ userId?: string | null + /** + * Setting this to true will not create new users in MoEngage. Only existing users will be updated + */ + update_existing_only?: boolean /** * The generated anonymous ID for the user */ diff --git a/packages/destination-actions/src/destinations/moengage/identifyUser/index.ts b/packages/destination-actions/src/destinations/moengage/identifyUser/index.ts index 6aa461f53b..4d09b9ae87 100644 --- a/packages/destination-actions/src/destinations/moengage/identifyUser/index.ts +++ b/packages/destination-actions/src/destinations/moengage/identifyUser/index.ts @@ -26,6 +26,13 @@ const action: ActionDefinition = { '@path': '$.userId' } }, + update_existing_only: { + label: 'Update existing users only', + type: 'boolean', + description: 'Setting this to true will not create new users in MoEngage. Only existing users will be updated', + required: false, + default: false + }, anonymousId: { label: 'Anonymous ID', type: 'string', @@ -92,7 +99,8 @@ const action: ActionDefinition = { library: { version: payload.library_version } }, anonymous_id: payload.anonymousId, - timestamp: payload.timestamp + timestamp: payload.timestamp, + update_existing_only: payload.update_existing_only || false } const endpoint = getEndpointByRegion(settings.region) @@ -107,4 +115,4 @@ const action: ActionDefinition = { } } -export default action +export default action \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/moengage/index.ts b/packages/destination-actions/src/destinations/moengage/index.ts index d064b0da67..a348f051bb 100644 --- a/packages/destination-actions/src/destinations/moengage/index.ts +++ b/packages/destination-actions/src/destinations/moengage/index.ts @@ -46,6 +46,10 @@ const destination: DestinationDefinition = { { label: 'DataCenter-04', value: 'DC_04' + }, + { + label: 'DataCenter-05', + value: 'DC_05' } ], default: 'DC_01' diff --git a/packages/destination-actions/src/destinations/moengage/regional-endpoints.ts b/packages/destination-actions/src/destinations/moengage/regional-endpoints.ts index 35c52715f0..99bed822a0 100644 --- a/packages/destination-actions/src/destinations/moengage/regional-endpoints.ts +++ b/packages/destination-actions/src/destinations/moengage/regional-endpoints.ts @@ -2,7 +2,8 @@ export const endpoints = { DC_01: 'https://api-01.moengage.com', DC_02: 'https://api-02.moengage.com', DC_03: 'https://api-03.moengage.com', - DC_04: 'https://api-04.moengage.com' + DC_04: 'https://api-04.moengage.com', + DC_05: 'https://api-05.moengage.com' } type Region = 'DC_01' | 'DC_02' diff --git a/packages/destination-actions/src/destinations/moengage/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moengage/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap index dc6e6f6def..91c759c5ce 100644 --- a/packages/destination-actions/src/destinations/moengage/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/moengage/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -20,6 +20,7 @@ Object { }, "timestamp": "2021-02-01T00:00:00.000Z", "type": "[3(wtAyZqr", + "update_existing_only": true, "user_id": "[3(wtAyZqr", } `; @@ -33,5 +34,6 @@ Object { }, "event": "[3(wtAyZqr", "type": "[3(wtAyZqr", + "update_existing_only": false, } `; diff --git a/packages/destination-actions/src/destinations/moengage/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/moengage/trackEvent/generated-types.ts index 3fcb22b8c1..400024d0b4 100644 --- a/packages/destination-actions/src/destinations/moengage/trackEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/moengage/trackEvent/generated-types.ts @@ -39,4 +39,8 @@ export interface Payload { properties?: { [k: string]: unknown } + /** + * If set to true, events from the Segment will only trigger updates for users who already exist in Moengage. + */ + update_existing_only?: boolean } diff --git a/packages/destination-actions/src/destinations/moengage/trackEvent/index.ts b/packages/destination-actions/src/destinations/moengage/trackEvent/index.ts index 8c57a15258..b3bb14fabe 100644 --- a/packages/destination-actions/src/destinations/moengage/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/moengage/trackEvent/index.ts @@ -82,7 +82,14 @@ const action: ActionDefinition = { default: { '@path': '$.properties' } - } + }, + update_existing_only: { + label: 'Update Existing Users Only', + type: 'boolean', + description: 'If set to true, events from the Segment will only trigger updates for users who already exist in Moengage.', + required: false, + default: false + }, }, perform: async (request, { payload, settings }) => { if (!settings.api_id || !settings.api_key) { @@ -100,7 +107,9 @@ const action: ActionDefinition = { library: { version: payload.library_version } }, properties: payload.properties, - timestamp: payload.timestamp + timestamp: payload.timestamp, + update_existing_only: payload.update_existing_only || false + } const endpoint = getEndpointByRegion(settings.region) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/__tests__/convert.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/__tests__/convert.test.ts new file mode 100644 index 0000000000..e7a131ed02 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/__tests__/convert.test.ts @@ -0,0 +1,443 @@ +import { PayloadValidationError } from '@segment/actions-core' +import { EventType } from '../common/event' +import { EventPayload as SegmentEventPayload } from '../common/payload/segment' +import { EventPayload as MolocoEventPayload } from '../common/payload/moloco' + +import { convertEvent } from '../common/convert' + +const TEST_EVENT_TYPE = EventType.Home + +describe('Moloco Rmp', () => { + describe('testConvertEvent', () => { + it('tests an event payload with all fields', async () => { + const input: SegmentEventPayload = { + event_id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'IOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + session_id: 'c3d5-fewf-11ee-9a73-0n5e570313ef', + items: [ + { + id: '123', + currency: 'USD', + price: 12.34, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + currency: 'USD', + price: 56.78, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + price: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + price: 5.00 + } + } + + const expectedOutput: MolocoEventPayload = { + event_type: TEST_EVENT_TYPE, + id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + channel_type: 'APP', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'IOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + session_id: 'c3d5-fewf-11ee-9a73-0n5e570313ef', + items: [ + { + id: '123', + price: { + currency: 'USD', + amount: 12.34 + }, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + price: { + currency: 'USD', + amount: 56.78 + }, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + amount: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + amount: 5.00 + } + } + + const output: MolocoEventPayload = convertEvent({ eventType: TEST_EVENT_TYPE, payload: input, settings: { channel_type: 'APP', platformId: 'any_plat_id', apiKey: 'any_api_key'}}) + expect(output).toEqual(expectedOutput) + }) + + it('tests an event payload with all fields, but os.name should be capitalized', async () => { + const input: SegmentEventPayload = { + event_id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'iOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + session_id: 'c3d5-fewf-11ee-9a73-0n5e570313ef', + items: [ + { + id: '123', + currency: 'USD', + price: 12.34, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + currency: 'USD', + price: 56.78, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + price: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + price: 5.00 + } + } + + const expectedOutput: MolocoEventPayload = { + event_type: TEST_EVENT_TYPE, + id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + channel_type: 'APP', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'IOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + session_id: 'c3d5-fewf-11ee-9a73-0n5e570313ef', + items: [ + { + id: '123', + price: { + currency: 'USD', + amount: 12.34 + }, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + price: { + currency: 'USD', + amount: 56.78 + }, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + amount: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + amount: 5.00 + } + } + + const output: MolocoEventPayload = convertEvent({ eventType: TEST_EVENT_TYPE, payload: input, settings: { channel_type: 'APP', platformId: 'any_plat_id', apiKey: 'any_api_key'}}) + expect(output).toEqual(expectedOutput) + }) + + it('tests an event payload with iPadOS, it should be converted into IOS', async () => { + const input: SegmentEventPayload = { + event_id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'iPadOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + session_id: 'c3d5-fewf-11ee-9a73-0n5e570313ef', + items: [ + { + id: '123', + currency: 'USD', + price: 12.34, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + currency: 'USD', + price: 56.78, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + price: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + price: 5.00 + } + } + + const expectedOutput: MolocoEventPayload = { + event_type: TEST_EVENT_TYPE, + id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + channel_type: 'APP', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'IOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + session_id: 'c3d5-fewf-11ee-9a73-0n5e570313ef', + items: [ + { + id: '123', + price: { + currency: 'USD', + amount: 12.34 + }, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + price: { + currency: 'USD', + amount: 56.78 + }, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + amount: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + amount: 5.00 + } + } + + const output: MolocoEventPayload = convertEvent({ eventType: TEST_EVENT_TYPE, payload: input, settings: { channel_type: 'APP', platformId: 'any_plat_id', apiKey: 'any_api_key'}}) + expect(output).toEqual(expectedOutput) + }) + + + it('tests an event payload with a missing field (session_id)', async () => { + const input: SegmentEventPayload = { + event_id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'IOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + items: [ + { + id: '123', + currency: 'USD', + price: 12.34, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + currency: 'USD', + price: 56.78, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + price: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + price: 5.00 + } + } + + const expectedOutput: MolocoEventPayload = { + event_type: TEST_EVENT_TYPE, + id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + channel_type: 'APP', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'IOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + items: [ + { + id: '123', + price: { + currency: 'USD', + amount: 12.34 + }, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + price: { + currency: 'USD', + amount: 56.78 + }, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ], + revenue: { + currency: 'USD', + amount: 69.12, + }, + search_query: 'iphone', + page_id: '/home', + referrer_page_id: 'google.com', + shipping_charge: { + currency: 'USD', + amount: 5.00 + } + } + + const output: MolocoEventPayload = convertEvent({ eventType: TEST_EVENT_TYPE, payload: input, settings: { channel_type: 'APP', platformId: 'any_plat_id', apiKey: 'any_api_key'}}) + expect(output).toEqual(expectedOutput) + }) + + it('tests whether items with price by without currency throws a validation error', async () => { + const input: SegmentEventPayload = { + event_id: '12e64c12-f386-42c9-871b-8dg3e539ad19', + timestamp: '2024-02-05T23:37:42.848Z', + user_id: 'wcsf20ge-c3d5-11ee-9a73-0n5e570313ef', + device: { + os: 'IOS', + os_version: '15.0.2', + advertising_id: '7acefbed-d1f6-4e4e-aa26-74e93dd017e4', + unique_device_id: '2b6f0cc904d137be2e1730235f5664094b831186', + model: 'iPhone 12', + ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF', + language: 'en', + ip: '1192.158.1.38' + }, + session_id: 'c3d5-fewf-11ee-9a73-0n5e570313ef', + items: [ + { + id: '123', + price: 12.34, + quantity: 1, + seller_id: 'cs032b-11ee-9a73-0n5e570313ef', + }, + { + id: '456', + currency: 'USD', + price: 56.78, + quantity: 2, + seller_id: 'cs032b-11ee-9a73-w5e570313ef', + } + ] + } + + expect(() => convertEvent({ eventType: TEST_EVENT_TYPE, payload: input, settings: { channel_type: 'APP', platformId: 'any_plat_id', apiKey: 'any_api_key'} })).toThrowError(PayloadValidationError) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/__tests__/index.test.ts new file mode 100644 index 0000000000..1d08e7940c --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/__tests__/index.test.ts @@ -0,0 +1,1185 @@ +import nock from 'nock' +import Definition from '../index' +import type { SegmentEvent } from '@segment/actions-core' +import { createTestIntegration } from '@segment/actions-core' +import { EventType } from '../common/event' +import { EventPayload } from '../common/payload/moloco' +import { v4 as uuidv4 } from '@lukeed/uuid' + +const testDestination = createTestIntegration(Definition) +// Home is chosen as a test event type because it does not require any of the optional fields +// Check the requirements in the ../home/index.ts file +const TEST_ACTION_SLUG = 'home' +const TEST_EVENT_TYPE = EventType.Home + +const AUTH = { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' +} + + +describe('Moloco Rmp', () => { + // TEST 1: Test the default mappings. The input event data are automatically collected fields + // Custom mapping options are not provided so the default mappings are used + // This tests whether the default mappings are working as expected + describe('test default mappings for WEB/iOS/Andorid events', () => { + it('should validate default mappings for WEB event', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.JS column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const webEvent = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + ip: '8.8.8.8', + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + page: { + path: '/academy/', + referrer: '', + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + userAgent: + 'Mozilla/5.0 (Chrome; intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: webEvent.messageId, + timestamp: webEvent.timestamp, + channel_type: 'SITE', + user_id: webEvent.userId, + device: { + ua: webEvent.context.userAgent, + ip: webEvent.context.ip, + }, + session_id: webEvent.anonymousId, + page_id: webEvent.context.page.path, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: webEvent, + settings: AUTH, + useDefaultMappings: true, + mapping: { + channel_type: 'SITE', + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate default mappings for iOS event', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS-IOS column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const iosEvent = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'ios', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Apple', + model: 'iPhone', + name: 'iPhone', + }, + library: { + name: 'analytics.iOS', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false + }, + os: { + name: 'iOS', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750 + }, + traits: {}, + timezone: 'America/Los_Angeles', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: iosEvent.messageId, + timestamp: iosEvent.timestamp, + channel_type: 'APP', + user_id: iosEvent.userId, + device: { + advertising_id: iosEvent.context.device.advertisingId, + ip: iosEvent.context.ip, + model: iosEvent.context.device.model, + os: iosEvent.context.os.name.toUpperCase(), + os_version: iosEvent.context.os.version, + unique_device_id: iosEvent.context.device.id, + }, + session_id: iosEvent.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: iosEvent, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + channel_type: 'APP', + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate default mappings for Andorid event', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.ANDROID column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const androidEvent = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'android', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Samsung', + model: 'Galaxy S10', + name: 'galaxy', + }, + library: { + name: 'analytics.ANDROID', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false, + bluetooth: false, + }, + os: { + name: 'Google Android', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750, + density: 2.0 + }, + traits: {}, + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Mobile Safari/537.36', + timezone: 'America/Los_Angeles', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: androidEvent.messageId, + timestamp: androidEvent.timestamp, + channel_type: 'APP', + user_id: androidEvent.userId, + device: { + advertising_id: androidEvent.context.device.advertisingId, + ip: androidEvent.context.ip, + model: androidEvent.context.device.model, + os: androidEvent.context.os.name.toUpperCase(), + os_version: androidEvent.context.os.version, + ua: androidEvent.context.userAgent, + unique_device_id: androidEvent.context.device.id, + }, + session_id: androidEvent.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: androidEvent, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + channel_type: 'APP', + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should not throw an error even though an input value that a default mapping is pointing is not given', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + // ip: '8.8.8.8', -- ip is not given, but the default mapping is pointing to it + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + page: { + path: '/academy/', + referrer: '', + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + userAgent: + 'Mozilla/5.0 (Chrome; intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: event.messageId, + timestamp: event.timestamp, + channel_type: 'SITE', + user_id: event.userId, + device: { + ua: event.context.userAgent, + // ip: event.context.ip, -- absent even though there is a default mapping for it + }, + session_id: event.anonymousId, + page_id: event.context.page.path, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: event, + settings: AUTH, + useDefaultMappings: true, + mapping: { + channel_type: 'SITE', + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + }) + + + // TEST 2: Test the custom mappings. The input event data are automatically collected fields + // Custom mapping options are provided so the default mappings are not used + // This tests + // 1. whether the custom mappings override the default mappings + // 2. whether array mappings are working as expected, object array should be possible to be created from both object and array + describe('test custom mapping', () => { + it('should validate custom mappings, event_id is default to anonymousId, but mapped to eventId', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = { + eventId: 'test-event-id', + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + ip: '8.8.8.8', + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + page: { + path: '/academy/', + referrer: '', + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + userAgent: + 'Mozilla/5.0 (Chrome; intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: event.eventId, + timestamp: event.timestamp, + channel_type: 'SITE', + user_id: event.userId, + device: { + ua: event.context.userAgent, + ip: event.context.ip, + }, + session_id: event.anonymousId, + page_id: event.context.page.path, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: event, + settings: AUTH, + useDefaultMappings: true, + mapping: { + channel_type: 'SITE', + event_id: { '@path': '$.eventId' } + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate custom mappings, event_id is default to anonymousId, but mapped to eventId', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = { + eventId: 'test-event-id', + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + ip: '8.8.8.8', + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + page: { + path: '/academy/', + referrer: '', + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + userAgent: + 'Mozilla/5.0 (Chrome; intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Safari/537.36', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: event.eventId, + timestamp: event.timestamp, + channel_type: 'SITE', + user_id: event.userId, + device: { + ua: event.context.userAgent, + ip: event.context.ip, + }, + session_id: event.anonymousId, + page_id: event.context.page.path, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: event, + settings: AUTH, + useDefaultMappings: true, + mapping: { + timestamp: { '@path': '$.timestamp' }, + channel_type: 'SITE', + event_id: { '@path': '$.eventId' } + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate custom mappings for an object array mapping(items). The input IS NOT an array.' , async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.ANDROID column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const androidEvent = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + product: { + id: '507f191', + name: 'Monopoly: 3rd Edition', + price: 19.99, + brand: 'Hasbro', + currency: 'USD', + }, + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'android', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Samsung', + model: 'Galaxy S10', + name: 'galaxy', + }, + library: { + name: 'analytics.ANDROID', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false, + bluetooth: false, + }, + os: { + name: 'Google Android', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750, + density: 2.0 + }, + traits: {}, + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Mobile Safari/537.36', + timezone: 'America/Los_Angeles', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: androidEvent.messageId, + timestamp: androidEvent.timestamp, + channel_type: 'APP', + user_id: androidEvent.userId, + device: { + advertising_id: androidEvent.context.device.advertisingId, + ip: androidEvent.context.ip, + model: androidEvent.context.device.model, + os: androidEvent.context.os.name.toUpperCase(), + os_version: androidEvent.context.os.version, + ua: androidEvent.context.userAgent, + unique_device_id: androidEvent.context.device.id, + }, + items: [ + { + id: androidEvent.context.product.id, + price: { + amount: androidEvent.context.product.price, + currency: androidEvent.context.product.currency, + } + } + ], + session_id: androidEvent.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: androidEvent, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + channel_type: 'APP', + items: [ + { + id: { '@path': '$.context.product.id' }, + price: { '@path': '$.context.product.price' }, + currency: { '@path': '$.context.product.currency' } + }, + ] + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate custom mappings for an object array mapping(items). The input IS an array.' , async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.ANDROID column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const androidEvent = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + product: [ + { + id: '507f191', + name: 'Monopoly: 3rd Edition', + price: 19.99, + brand: 'Hasbro', + currency: 'USD', + }, + { + id: 'nae2d1', + name: 'Hogwarts: 3rd Edition', + price: 29.99, + brand: 'Hasbro', + currency: 'USD', + } + ], + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'android', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Samsung', + model: 'Galaxy S10', + name: 'galaxy', + }, + library: { + name: 'analytics.ANDROID', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false, + bluetooth: false, + }, + os: { + name: 'Google Android', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750, + density: 2.0 + }, + traits: {}, + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Mobile Safari/537.36', + timezone: 'America/Los_Angeles', + } + } as const; + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: androidEvent.messageId, + timestamp: androidEvent.timestamp, + channel_type: 'APP', + user_id: androidEvent.userId, + device: { + advertising_id: androidEvent.context.device.advertisingId, + ip: androidEvent.context.ip, + model: androidEvent.context.device.model, + os: androidEvent.context.os.name.toUpperCase(), + os_version: androidEvent.context.os.version, + ua: androidEvent.context.userAgent, + unique_device_id: androidEvent.context.device.id, + }, + items: [ + { + id: androidEvent.context.product[0].id, + price: { + amount: androidEvent.context.product[0].price, + currency: androidEvent.context.product[0].currency, + } + }, + { + id: androidEvent.context.product[1].id, + price: { + amount: androidEvent.context.product[1].price, + currency: androidEvent.context.product[0].currency, + } + } + ], + session_id: androidEvent.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: androidEvent, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + channel_type: 'APP', + items: { + '@arrayPath': [ + '$.context.product', + { + id: { '@path': '$.id' }, + price: { '@path': '$.price' }, + currency: { '@path': '$.currency' } + } + ] + } + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate items mapping with currency / when both default currency and currency for each item are given, it should use the latter.' , async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.ANDROID column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const androidEvent = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + defaultCurrency: 'KRW', + product: [ + { + id: '507f191', + name: 'Monopoly: 3rd Edition', + price: 19.99, + brand: 'Hasbro', + currency: 'USD', + }, + { + id: 'nae2d1', + name: 'Hogwarts: 3rd Edition', + price: 29.99, + brand: 'Hasbro', + currency: 'USD', + } + ], + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'android', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Samsung', + model: 'Galaxy S10', + name: 'galaxy', + }, + library: { + name: 'analytics.ANDROID', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false, + bluetooth: false, + }, + os: { + name: 'Google Android', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750, + density: 2.0 + }, + traits: {}, + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Mobile Safari/537.36', + timezone: 'America/Los_Angeles', + } + } + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: androidEvent.messageId, + timestamp: androidEvent.timestamp, + channel_type: 'APP', + user_id: androidEvent.userId, + device: { + advertising_id: androidEvent.context.device.advertisingId, + ip: androidEvent.context.ip, + model: androidEvent.context.device.model, + os: androidEvent.context.os.name.toUpperCase(), + os_version: androidEvent.context.os.version, + ua: androidEvent.context.userAgent, + unique_device_id: androidEvent.context.device.id, + }, + items: [ + { + id: androidEvent.context.product[0].id, + price: { + amount: androidEvent.context.product[0].price, + currency: androidEvent.context.product[0].currency, + } + }, + { + id: androidEvent.context.product[1].id, + price: { + amount: androidEvent.context.product[1].price, + currency: androidEvent.context.product[0].currency, + } + } + ], + session_id: androidEvent.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: androidEvent as SegmentEvent, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + timestamp: { '@path': '$.timestamp' }, + channel_type: 'APP', + default_currency: { '@path': '$.context.defaultCurrency' }, + items: { + '@arrayPath': [ + '$.context.product', + { + id: { '@path': '$.id' }, + price: { '@path': '$.price' }, + currency: { '@path': '$.currency' } + } + ] + } + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate items mapping with currency / only default currency is given and it is used for each item.' , async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.ANDROID column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const androidEvent = { + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + defaultCurrency: 'JPY', + product: [ + { + id: '507f191', + name: 'Monopoly: 3rd Edition', + price: 19.99, + brand: 'Hasbro' + }, + { + id: 'nae2d1', + name: 'Hogwarts: 3rd Edition', + price: 29.99, + brand: 'Hasbro' + } + ], + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'android', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Samsung', + model: 'Galaxy S10', + name: 'galaxy', + }, + library: { + name: 'analytics.ANDROID', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false, + bluetooth: false, + }, + os: { + name: 'Google Android', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750, + density: 2.0 + }, + traits: {}, + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Mobile Safari/537.36', + timezone: 'America/Los_Angeles', + } + } + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: androidEvent.messageId, + timestamp: androidEvent.timestamp, + channel_type: 'APP', + user_id: androidEvent.userId, + device: { + advertising_id: androidEvent.context.device.advertisingId, + ip: androidEvent.context.ip, + model: androidEvent.context.device.model, + os: androidEvent.context.os.name.toUpperCase(), + os_version: androidEvent.context.os.version, + ua: androidEvent.context.userAgent, + unique_device_id: androidEvent.context.device.id, + }, + items: [ + { + id: androidEvent.context.product[0].id, + price: { + amount: androidEvent.context.product[0].price, + currency: androidEvent.context.defaultCurrency, + } + }, + { + id: androidEvent.context.product[1].id, + price: { + amount: androidEvent.context.product[1].price, + currency: androidEvent.context.defaultCurrency, + } + } + ], + session_id: androidEvent.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: androidEvent as SegmentEvent, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + timestamp: { '@path': '$.timestamp' }, + channel_type: 'APP', + default_currency: { '@path': '$.context.defaultCurrency' }, + items: { + '@arrayPath': [ + '$.context.product', + { + id: { '@path': '$.id' }, + price: { '@path': '$.price' }, + currency: { '@path': '$.currency' } + } + ] + } + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate the page_id conversion when both "page_id" and "page_identifier_tokens" are given ("page_id" should be used).' , async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.ANDROID column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const event = { + pageId: 'page-id-1234', + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'android', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Samsung', + model: 'Galaxy S10', + name: 'galaxy', + }, + library: { + name: 'analytics.ANDROID', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false, + bluetooth: false, + }, + os: { + name: 'Google Android', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750, + density: 2.0 + }, + traits: {}, + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Mobile Safari/537.36', + timezone: 'America/Los_Angeles', + event: 'Product List Viewed', + vertical: 'fruit' + } + } as const + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: event.messageId, + timestamp: event.timestamp, + channel_type: 'APP', + user_id: event.userId, + device: { + advertising_id: event.context.device.advertisingId, + ip: event.context.ip, + model: event.context.device.model, + os: event.context.os.name.toUpperCase(), + os_version: event.context.os.version, + ua: event.context.userAgent, + unique_device_id: event.context.device.id, + }, + page_id: event.pageId, // -- still uses the pageId + session_id: event.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: event, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + timestamp: { '@path': '$.timestamp' }, + channel_type: 'APP', + page_id: { '@path': '$.pageId' }, + page_identifier_tokens: { + event: { '@path': '$.context.event' }, + vertical: { '@path': '$.context.vertical' } + } + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + + it('should validate the page_id conversion when only "page_identifier_tokens" is given.' , async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + // A test event case with automatically collected fields + // Check the table's ANALYTICS.ANDROID column in the following link + // https://segment-docs.netlify.app/docs/connections/spec/common/#context-fields-automatically-collected + const event = { + pageId: 'page-id-1234', + anonymousId: 'anonId1234', + event: 'Test Event', + messageId: uuidv4(), + properties: {}, + receivedAt: new Date().toISOString(), + sentAt: new Date().toISOString(), + timestamp: new Date().toISOString(), + traits: {}, + type: 'track', + userId: 'user1234', + context: { + app: { + name: 'AppName', + version: '1.0.0', + build: '1', + }, + device: { + type: 'android', + id: '12345', + advertisingId: '12345', + adTrackingEnabled: true, + manufacturer: 'Samsung', + model: 'Galaxy S10', + name: 'galaxy', + }, + library: { + name: 'analytics.ANDROID', + version: '2.11.1' + }, + ip: '8.8.8.8', + locale: 'en-US', + network: { + carrier: 'T-Mobile US', + cellular: true, + wifi: false, + bluetooth: false, + }, + os: { + name: 'Google Android', + version: '14.4.2' + }, + screen: { + height: 1334, + width: 750, + density: 2.0 + }, + traits: {}, + userAgent: 'Mozilla/5.0 (Linux; Android 10; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/94.0.4606.81 Mobile Safari/537.36', + timezone: 'America/Los_Angeles', + event: 'Product List Viewed', + vertical: 'fruit' + } + } as const + + const expectedPayload: EventPayload = { + event_type: TEST_EVENT_TYPE, + id: event.messageId, + timestamp: event.timestamp, + channel_type: 'APP', + user_id: event.userId, + device: { + advertising_id: event.context.device.advertisingId, + ip: event.context.ip, + model: event.context.device.model, + os: event.context.os.name.toUpperCase(), + os_version: event.context.os.version, + ua: event.context.userAgent, + unique_device_id: event.context.device.id, + }, + page_id: "event:Product List Viewed;vertical:fruit", // stringified from pageIdentifierTokens + session_id: event.anonymousId, + } + + const responses = await testDestination.testAction(TEST_ACTION_SLUG, { + event: event, + settings: { ...AUTH, channel_type: 'APP'}, + useDefaultMappings: true, + mapping: { + timestamp: { '@path': '$.timestamp' }, + channel_type: 'APP', + // pageId: { '@path': '$.pageId' }, -- no mapping for page_id + page_identifier_tokens: { + event: { '@path': '$.context.event' }, + vertical: { '@path': '$.context.vertical' } + } + }, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toEqual(expectedPayload) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..66ac7e1f4b --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MolocoRmp's addToCart destination action: all fields 1`] = ` +Object { + "channel_type": "2Tz2Ek0OF", + "device": Object { + "advertising_id": "2Tz2Ek0OF", + "ip": "2Tz2Ek0OF", + "language": "2Tz2Ek0OF", + "model": "2Tz2Ek0OF", + "os": "2TZ2EK0OF", + "os_version": "2Tz2Ek0OF", + "ua": "2Tz2Ek0OF", + "unique_device_id": "2Tz2Ek0OF", + }, + "event_type": "ADD_TO_CART", + "id": "2Tz2Ek0OF", + "items": Array [ + Object { + "id": "2Tz2Ek0OF", + "price": Object { + "amount": -35340104037826.56, + "currency": "INR", + }, + "quantity": -3534010403782656, + "seller_id": "2Tz2Ek0OF", + }, + ], + "page_id": "2Tz2Ek0OF", + "session_id": "2Tz2Ek0OF", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "2Tz2Ek0OF", +} +`; + +exports[`Testing snapshot for MolocoRmp's addToCart destination action: required fields 1`] = ` +Object { + "channel_type": "2Tz2Ek0OF", + "event_type": "ADD_TO_CART", + "id": "2Tz2Ek0OF", + "items": Array [ + Object { + "id": "2Tz2Ek0OF", + }, + ], + "page_id": "2Tz2Ek0OF", + "session_id": "2Tz2Ek0OF", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "2Tz2Ek0OF", +} +`; diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/index.test.ts new file mode 100644 index 0000000000..6d08a95534 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/index.test.ts @@ -0,0 +1,106 @@ +import nock from 'nock' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.addToCart', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + item: { + id: '123', + price: 100, + currency: 'USD', + quantity: 1, + sellerId: 'seller123', + } + } + }) + + const responses = await testDestination.testAction('addToCart', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + timestamp: { '@path': '$.timestamp' }, + items: [ + { + id: { + '@path': '$.properties.item.id' + }, + price: { + '@path': '$.properties.item.price' + }, + currency: { + '@path': '$.properties.item.currency' + }, + quantity: { + '@path': '$.properties.item.quantity' + }, + sellerId: { + '@path': '$.properties.item.sellerId' + }, + } + ] + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should fail to build an event because it misses a required field', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + item: { + id: '123', + price: 100, + currency: 'USD', + quantity: 1, + sellerId: 'seller123', + } + } + }) + + await expect(testDestination.testAction('addToCart', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + // items: [ + // { + // id: { + // '@path': '$.properties.item.id' + // }, + // price: { + // '@path': '$.properties.item.price' + // }, + // currency: { + // '@path': '$.properties.item.currency' + // }, + // quantity: { + // '@path': '$.properties.item.quantity' + // }, + // sellerId: { + // '@path': '$.properties.item.sellerId' + // }, + // } + // ] -- missing required field + }, + useDefaultMappings: true, + })).rejects.toThrowError(AggregateAjvError) + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..6d1a9157c7 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'addToCart' +const destinationSlug = 'MolocoRmp' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToCart/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/generated-types.ts new file mode 100644 index 0000000000..4547f2209d --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/generated-types.ts @@ -0,0 +1,98 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToCart/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/index.ts new file mode 100644 index 0000000000..3e7de30e15 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToCart/index.ts @@ -0,0 +1,57 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id, + page_identifier_tokens, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import { convertEvent } from '../common/convert' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + + +const action: ActionDefinition = { + title: 'Add to Cart', + description: 'Represents a user adding an item to their cart', + defaultSubscription: 'type = "track" and event = "Product Added"', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items: { + ...items, + required: true, + default: { + '@arrayPath': [ + '$.properties', + { + id: { '@path': '$.product_id' }, + price: { '@path': '$.price' }, + currency: { '@path': '$.currency' }, + quantity: { '@path': '$.quantity' }, + seller_id: { '@path': '$.seller_id'} + } + ] + } + }, + page_id, + page_identifier_tokens + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.AddToCart, payload, settings }) + return client.sendEvent(body) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/__tests__/index.test.ts new file mode 100644 index 0000000000..ccd4a566cf --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/__tests__/index.test.ts @@ -0,0 +1,107 @@ +import nock from 'nock' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.addToWishlist', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + + id: '123', + price: 100, + currency: 'USD', + quantity: 1, + sellerId: 'seller123', + revenue: 100 + } + }) + + const responses = await testDestination.testAction('addToWishlist', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + timestamp: { '@path': '$.timestamp' }, + items: [ + { + id: { + '@path': '$.properties.id' + }, + price: { + '@path': '$.properties.price' + }, + currency: { + '@path': '$.properties.currency' + }, + quantity: { + '@path': '$.properties.quantity' + }, + sellerId: { + '@path': '$.properties.sellerId' + }, + } + ] + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should fail to build an event because it misses a required field', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + item: { + id: '123', + price: 100, + currency: 'USD', + quantity: 1, + sellerId: 'seller123', + } + } + }) + + await expect(testDestination.testAction('addToWishlist', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + + // items: [ + // { + // id: { + // '@path': '$.properties.item.id' + // }, + // price: { + // '@path': '$.properties.item.price' + // }, + // currency: { + // '@path': '$.properties.item.currency' + // }, + // quantity: { + // '@path': '$.properties.item.quantity' + // }, + // sellerId: { + // '@path': '$.properties.item.sellerId' + // }, + // } + // ] -- missing required field + }, + useDefaultMappings: true, + })).rejects.toThrowError(AggregateAjvError) + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/generated-types.ts new file mode 100644 index 0000000000..1988ae98b9 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/generated-types.ts @@ -0,0 +1,111 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * Revenue of the event + */ + revenue?: { + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency: string + } + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/index.ts new file mode 100644 index 0000000000..91976cb19a --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/addToWishlist/index.ts @@ -0,0 +1,59 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + revenue, + page_id, + page_identifier_tokens, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import { convertEvent } from '../common/convert' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + + +const action: ActionDefinition = { + title: 'Add to Wishlist', + description: 'Represents a user adding an item to their wishlist', + defaultSubscription: 'type = "track" and event = "Product Added to Wishlist"', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items: { + ...items, + required: true, + default: { + '@arrayPath': [ + '$.properties', + { + id: { '@path': '$.product_id' }, + price: { '@path': '$.price' }, + currency: { '@path': '$.currency' }, + quantity: { '@path': '$.quantity' }, + seller_id: { '@path': '$.seller_id'} + } + ] + } + }, + revenue, + page_id, + page_identifier_tokens, + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.AddtoWishlist, payload, settings }) + return client.sendEvent(body) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/moloco-rmp/common/convert.ts b/packages/destination-actions/src/destinations/moloco-rmp/common/convert.ts new file mode 100644 index 0000000000..6ac83185e9 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/common/convert.ts @@ -0,0 +1,93 @@ +import { PayloadValidationError } from '@segment/actions-core' +import { EventType } from './event' +import { Settings } from '../generated-types' +import { + EventPayload as SegmentEventPayload, + ItemPayload as SegmentItemPayload, + DevicePayload as SegmentDevicePayload +} from './payload/segment' +import { + EventPayload as MolocoEventPayload, + ItemPayload as MolocoItemPayload, + DevicePayload as MolocoDevicePayload +} from './payload/moloco' + + +// This function coverts the SegmentEventPayload to MolocoEventPayload +// SegmentEventPayload is the payload that went through the mapping defined in the Segment UI +// MolocoEventPayload is the payload that will be sent to the Moloco RMP API +export function convertEvent(args: { eventType: EventType, payload: SegmentEventPayload, settings: Settings }): MolocoEventPayload { + const { eventType, payload, settings } = args; + + return { + event_type: eventType, + channel_type: settings.channel_type, + timestamp: payload.timestamp, + id: payload.event_id ?? undefined, + user_id: payload.user_id ?? undefined, + device: payload.device ? convertDevicePayload(payload.device): undefined, + session_id: payload.session_id ?? undefined, + revenue: payload.revenue ? { + amount: payload.revenue.price, + currency: payload.revenue.currency + } : undefined, + search_query: payload.search_query ?? undefined, + referrer_page_id: payload.referrer_page_id ?? undefined, + shipping_charge: payload.shipping_charge ?{ + amount: payload.shipping_charge.price, + currency: payload.shipping_charge.currency + }: undefined, + items: payload.items ? payload.items.map(item => convertItemPayload({ payload: item, defaultCurrency: payload.default_currency })) : undefined, + page_id: payload.page_id || (payload.page_identifier_tokens ? convertPageIdentifierTokensToPageId(payload.page_identifier_tokens) : undefined) + } as MolocoEventPayload +} + +function convertItemPayload(args: { payload: SegmentItemPayload, defaultCurrency: string | undefined }): MolocoItemPayload { + const { payload, defaultCurrency } = args; + + const actualCurrency = payload.currency ?? defaultCurrency + + if ((payload.price !== undefined && actualCurrency === undefined) || (payload.price === undefined && actualCurrency !== undefined)) { + throw new PayloadValidationError('Price and Currency/Default Currency should be both present or both absent'); + } + + return { + id: payload.id, + quantity: payload.quantity, + seller_id: payload.seller_id, + price: payload.price && actualCurrency ? { + amount: payload.price, + currency: actualCurrency + } : undefined + } as MolocoItemPayload; +} + +function convertOs(os: string): string { + os = os.toUpperCase(); + + if (os === 'IPADOS') { + os = 'IOS'; + } + + return os +} + +function convertDevicePayload(payload: SegmentDevicePayload): MolocoDevicePayload { + return { + os: payload.os ? convertOs(payload.os) : undefined, + os_version: payload.os_version ?? undefined, + advertising_id: payload.advertising_id ?? undefined, + unique_device_id: payload.unique_device_id ?? undefined, + model: payload.model ?? undefined, + ua: payload.ua ?? undefined, + language: payload.language ?? undefined, + ip: payload.ip ?? undefined, + } as MolocoDevicePayload +} + +function convertPageIdentifierTokensToPageId(tokens: { [k: string]: unknown } | undefined): string { + if (tokens === undefined) { + return '' + } + return Object.entries(tokens).map(([key, value]) => `${key}:${value}`).join(';') +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/common/event.ts b/packages/destination-actions/src/destinations/moloco-rmp/common/event.ts new file mode 100644 index 0000000000..882b378749 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/common/event.ts @@ -0,0 +1,10 @@ +export enum EventType { + Search = 'SEARCH', + ItemPageView = 'ITEM_PAGE_VIEW', + AddToCart = 'ADD_TO_CART', + Purchase = 'PURCHASE', + AddtoWishlist = 'ADD_TO_WISHLIST', + Home = 'HOME', + Land = 'LAND', + PageView = 'PAGE_VIEW' +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/common/fields.ts b/packages/destination-actions/src/destinations/moloco-rmp/common/fields.ts new file mode 100644 index 0000000000..f3da9b6dec --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/common/fields.ts @@ -0,0 +1,358 @@ +import { InputField } from '@segment/actions-core/destination-kit/types' + +export const event_id: InputField = { + label: 'Event ID', + description: 'Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters.', + type: 'string', + required: false, + default: { + '@path': '$.messageId' + } +} + +export const timestamp: InputField = { + label: 'Timestamp', + description: 'Timestamp that the event happened at.', + type: 'datetime', + required: true, + default: { + '@path': '$.timestamp' + } +} + +export const user_id: InputField = { + label: 'User ID', + description: 'User Identifier for the platform. The length should not exceed 128 characters.', + type: 'string', + required: false, + default: { + '@path': '$.userId' + } +} + +export const device: InputField = { + label: 'Device', + description: `Device information of the event`, + type: 'object', + required: false, + properties: { + os: { + label: 'OS', + description: 'OS of the device. "ios" or "android" must be included for the APP channel type.', + type: 'string', + required: false, + }, + os_version: { + label: 'OS Version', + description: 'Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1")', + type: 'string', + required: false, + }, + advertising_id: { + label: 'Advertising ID', + description: 'For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4)', + type: 'string', + required: false, + }, + unique_device_id: { + label: 'Unique Device ID', + description: `For app traffic, a unique identifier for the device being used should be provided in this field. + Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + The length of this id should not exceed 128 characters.`, + type: 'string', + required: false, + }, + model: { + label: 'Model', + description: 'Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro")', + type: 'string', + required: false, + }, + ua: { + label: 'User Agent', + description: 'User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF")', + type: 'string', + required: false, + }, + language: { + label: 'Language', + description: 'ISO-639-1 alpha-2 language code. (e.g., "en")', + type: 'string', + required: false + }, + ip: { + label: 'IP Address', + description: 'IP in IPv4 format. (e.g., 216.212.237.213)', + type: 'string', + required: false, + } + }, + default: { + os: { '@path': '$.context.os.name' }, + os_version: { '@path': '$.context.os.version' }, + advertising_id: { '@path': '$.context.device.advertisingId' }, + unique_device_id: { '@path': '$.context.device.id' }, + model: { '@path': '$.context.device.model' }, + ua: { '@path': '$.context.userAgent' }, + ip: { '@path': '$.context.ip' } + } +} + +export const session_id: InputField = { + label: 'Session ID', + description: 'Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters.', + type: 'string', + required: false, + default: { + '@path': '$.anonymousId' + } +} + +export const default_currency: InputField = { + label: 'Default Currency', + description: 'The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items.', + choices: [ + {label: 'UNKNOWN_CURRENCY', value: 'UNKNOWN_CURRENCY'}, + {label: 'USD', value: 'USD'}, + {label: 'KRW', value: 'KRW'}, + {label: 'JPY', value: 'JPY'}, + {label: 'EUR', value: 'EUR'}, + {label: 'GBP', value: 'GBP'}, + {label: 'SEK', value: 'SEK'}, + {label: 'INR', value: 'INR'}, + {label: 'THB', value: 'THB'}, + {label: 'IDR', value: 'IDR'}, + {label: 'CNY', value: 'CNY'}, + {label: 'CAD', value: 'CAD'}, + {label: 'RUB', value: 'RUB'}, + {label: 'BRL', value: 'BRL'}, + {label: 'SGD', value: 'SGD'}, + {label: 'HKD', value: 'HKD'}, + {label: 'AUD', value: 'AUD'}, + {label: 'PLN', value: 'PLN'}, + {label: 'DKK', value: 'DKK'}, + {label: 'VND', value: 'VND'}, + {label: 'MYR', value: 'MYR'}, + {label: 'PHP', value: 'PHP'}, + {label: 'TRY', value: 'TRY'}, + {label: 'VEF', value: 'VEF'} + ], + default: 'USD', + type: 'string', + required: false +} + +export const items: InputField = { + label: 'Items', + description: 'Item information list related to the event.', + type: 'object', + required: false, + multiple: true, + properties: { + id: { + label: 'ID', + description: 'Unique identifier of the Item.', + type: 'string', + required: true + }, + price: { + label: 'Price', + description: 'Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated.', + type: 'number', + required: false + }, + currency: { + label: 'Currency', + description: 'Currency information. This field is required if the Price field is populated.', + choices: [ + {label: 'UNKNOWN_CURRENCY', value: 'UNKNOWN_CURRENCY'}, + {label: 'USD', value: 'USD'}, + {label: 'KRW', value: 'KRW'}, + {label: 'JPY', value: 'JPY'}, + {label: 'EUR', value: 'EUR'}, + {label: 'GBP', value: 'GBP'}, + {label: 'SEK', value: 'SEK'}, + {label: 'INR', value: 'INR'}, + {label: 'THB', value: 'THB'}, + {label: 'IDR', value: 'IDR'}, + {label: 'CNY', value: 'CNY'}, + {label: 'CAD', value: 'CAD'}, + {label: 'RUB', value: 'RUB'}, + {label: 'BRL', value: 'BRL'}, + {label: 'SGD', value: 'SGD'}, + {label: 'HKD', value: 'HKD'}, + {label: 'AUD', value: 'AUD'}, + {label: 'PLN', value: 'PLN'}, + {label: 'DKK', value: 'DKK'}, + {label: 'VND', value: 'VND'}, + {label: 'MYR', value: 'MYR'}, + {label: 'PHP', value: 'PHP'}, + {label: 'TRY', value: 'TRY'}, + {label: 'VEF', value: 'VEF'} + ], + type: 'string', + required: false, + }, + quantity: { + label: 'Quantity', + description: 'Quantity of the item. Recommended.', + type: 'integer', + required: false + }, + seller_id: { + label: 'Seller ID', + description: 'Unique identifier of the Seller.', + type: 'string', + required: false + } + }, + default: { + '@arrayPath': [ + '$.properties.products', + { + id: { '@path': '$.product_id' }, + price: { '@path': '$.price' }, + currency: { '@path': '$.currency' }, + quantity: { '@path': '$.quantity' }, + seller_id: { '@path': '$.seller_id'} + } + ] + } +} + +export const revenue: InputField = { + label: 'Revenue', + description: 'Revenue of the event', + type: 'object', + required: false, + additionalProperties: false, + properties: { + price: { + label: 'Price', + description: 'Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated.', + type: 'number', + required: true + }, + currency: { + label: 'Currency', + description: 'Currency information. This field is required if the Price field is populated.', + choices: [ + {label: 'UNKNOWN_CURRENCY', value: 'UNKNOWN_CURRENCY'}, + {label: 'USD', value: 'USD'}, + {label: 'KRW', value: 'KRW'}, + {label: 'JPY', value: 'JPY'}, + {label: 'EUR', value: 'EUR'}, + {label: 'GBP', value: 'GBP'}, + {label: 'SEK', value: 'SEK'}, + {label: 'INR', value: 'INR'}, + {label: 'THB', value: 'THB'}, + {label: 'IDR', value: 'IDR'}, + {label: 'CNY', value: 'CNY'}, + {label: 'CAD', value: 'CAD'}, + {label: 'RUB', value: 'RUB'}, + {label: 'BRL', value: 'BRL'}, + {label: 'SGD', value: 'SGD'}, + {label: 'HKD', value: 'HKD'}, + {label: 'AUD', value: 'AUD'}, + {label: 'PLN', value: 'PLN'}, + {label: 'DKK', value: 'DKK'}, + {label: 'VND', value: 'VND'}, + {label: 'MYR', value: 'MYR'}, + {label: 'PHP', value: 'PHP'}, + {label: 'TRY', value: 'TRY'}, + {label: 'VEF', value: 'VEF'} + ], + type: 'string', + required: true, + }, + }, + default: { + price: { '@path': '$.properties.revenue' }, + currency: { '@path': '$.properties.currency' } + } +} + +export const search_query: InputField = { + label: 'Search Query', + description: 'Query string for the search.', + type: 'string', + required: false, + default: { + '@path': '$.properties.query' + } +} + +export const page_id: InputField = { + label: 'Page ID', + description: `A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar".`, + type: 'string', + required: false, + default: { + '@path': '$.context.page.path' + } +} + +export const page_identifier_tokens: InputField = { + label: 'Page Identifier Tokens', + description: 'Tokens that can be used to identify a page. Alternative to page_id with a lower priority.', + type: 'object', + defaultObjectUI: 'keyvalue', + required: false +} + +export const referrer_page_id: InputField = { + label: 'Referrer Page ID', + description: 'Similar to referrer in HTTP, this value indicates from which page the user came to the current page.', + type: 'string', + required: false, + default: { + '@path': '$.context.page.referrer' + } +} + +export const shipping_charge: InputField = { + label: 'Shipping Charge', + description: 'Shipping charge’s monetary amount in a specific currency.', + type: 'object', + required: false, + properties: { + price: { + label: 'Price', + description: 'Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated.', + type: 'number', + required: true + }, + currency: { + label: 'Currency', + description: 'Currency information. This field is required if the Price field is populated.', + choices: [ + {label: 'UNKNOWN_CURRENCY', value: 'UNKNOWN_CURRENCY'}, + {label: 'USD', value: 'USD'}, + {label: 'KRW', value: 'KRW'}, + {label: 'JPY', value: 'JPY'}, + {label: 'EUR', value: 'EUR'}, + {label: 'GBP', value: 'GBP'}, + {label: 'SEK', value: 'SEK'}, + {label: 'INR', value: 'INR'}, + {label: 'THB', value: 'THB'}, + {label: 'IDR', value: 'IDR'}, + {label: 'CNY', value: 'CNY'}, + {label: 'CAD', value: 'CAD'}, + {label: 'RUB', value: 'RUB'}, + {label: 'BRL', value: 'BRL'}, + {label: 'SGD', value: 'SGD'}, + {label: 'HKD', value: 'HKD'}, + {label: 'AUD', value: 'AUD'}, + {label: 'PLN', value: 'PLN'}, + {label: 'DKK', value: 'DKK'}, + {label: 'VND', value: 'VND'}, + {label: 'MYR', value: 'MYR'}, + {label: 'PHP', value: 'PHP'}, + {label: 'TRY', value: 'TRY'}, + {label: 'VEF', value: 'VEF'} + ], + type: 'string', + required: true + } + } +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/moloco-rmp/common/payload/moloco.ts b/packages/destination-actions/src/destinations/moloco-rmp/common/payload/moloco.ts new file mode 100644 index 0000000000..cbf6063d04 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/common/payload/moloco.ts @@ -0,0 +1,164 @@ +// This is a generalization of a payload to be delivered to the Moloco RMP API. +// ./segment/payload should be converted into this interface after converting through ../body-builder/buildBody +export type EventPayload = { + /** + * Event Type. Available options are the followings + * SEARCH: Represents a user searching for an item + * ITEM_PAGE_VIEW: Represents a user viewing an item page + * ADD_TO_CART: Represents a user adding an item to their cart + * PURCHASE: Represents a user purchasing an item + * ADD_TO_WHISHLIST: Represents a user adding an item to their wishlist + * HOME: Represents a user visiting a home page + * LAND: Represents a user visiting the client’s website from an external source (ex. Google Shopping) + * PAGE_VIEW: Represents a user viewing a certain page that is pertinent to sequence-based ML model training (Ex. a user browsing sneakers) + */ + event_type: string + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * Type of channel, either APP or SITE + */ + channel_type: string + /** + * User Identifier for the platform. Recommended to hash it before sending for anonymization. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: DevicePayload + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * Item information list related to the event. + */ + items?: ItemPayload[] + /** + * Revenue of the event + */ + revenue?: MoneyPayload + /** + * Query string for the search. + */ + search_query?: string + /** + * A string that can identify a context of the event, + * such as "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar. + * Any value is acceptable if it helps identifying unique pages. + */ + page_id?: string + /** + * Similar to referer in HTTP, this value indicates from which page the user came to the current page. + */ + referrer_page_id?: string + /** + * Shipping charge’s monetary amount in a specific currency. + */ + shipping_charge?: MoneyPayload +} + + +// Generalized payload to be passed to Moloco RMP API +// after ./segement/ItemPayload going through the conversion logic +export type ItemPayload = { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Price information of the item + */ + price?: MoneyPayload + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string +} + +// Generalized payload to be passed to Moloco RMP API +// after ./segement/MoneyPayload going through the conversion logic +export interface MoneyPayload { + /** + * Currency information. Available options are the followings + * UNKNOWN_CURRENCY: Unknown currency. + * USD: US Dollar. + * KRW: Korean Won. + * JPY: Japanese Yen. + * EUR: EU Euro. + * GBP: British Pound. + * SEK: Swedish Krona. + * INR: India Rupee. + * THB: Thailand Baht. + * IDR: Indonesia Rupiah. + * CNY: China Yuan. + * CAD: Canada Dollar. + * RUB: Russia Ruble. + * BRL: Brazil Real. + * SGD: Singapore Dollar. + * HKD: Hong Kong Dollar. + * AUD: Autrailia Dollar. + * PLN: Poland Zloty. + * DKK: Denmark Krone. + * VND: Viet Nam Dong. + * MYR: Malaysia Ringgit. + * PHP: Philippines Peso. + * TRY: Turkey Lira. + * VEF: Venezuela Bolívar. + */ + currency: string + /** + * Amount of money. (e.g., 12.34 for $12.34 if currency is "USD") + */ + amount: number +} + +// Generalized payload to be passed to Moloco RMP API +// after ./segement/DevicePayload going through the conversion logic +export interface DevicePayload { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/common/payload/segment.ts b/packages/destination-actions/src/destinations/moloco-rmp/common/payload/segment.ts new file mode 100644 index 0000000000..dfe68febae --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/common/payload/segment.ts @@ -0,0 +1,143 @@ +// Generalized Payload for ../event/MolocoEvent +// This payload is a generalization of all the possible payloads +// that will be auto-generated from the ./bin/run generate:types command +// (https://github.com/segmentio/action-destinations/tree/main?tab=readme-ov-file#actions-cli). +export interface EventPayload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: DevicePayload + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items?: ItemPayload[] + /** + * Revenue of the event + */ + revenue?: { + /** + * Monetary amount without currency. (e.g., 12.34 for $12.34 if currency is "USD") + */ + price: number + /** + * Currency information + */ + currency: string + } + /** + * Query string for the search. + */ + search_query?: string + /** + * A string that can identify a context of the event, + * such as "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar. + * Any value is acceptable if it helps identifying unique pages. + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } + /** + * Similar to referrer in HTTP, this value indicates from which page the user came to the current page. + */ + referrer_page_id?: string + /** + * Shipping charge’s monetary amount in a specific currency. + */ + shipping_charge?: { + /** + * Monetary amount without currency. (e.g., 12.34 for $12.34 if currency is "USD") + */ + price: number + /** + * Currency information + */ + currency: string + } +} + + +// Generalized payload for ../fields/createItemInputField, note that it is not an array type +export interface ItemPayload { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency. (e.g., 12.34 for $12.34 if currency is "USD") required if currency is provided + */ + price?: number + /** + * Currency information, required if price is provided + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string +} + +// Generalized payload for ../fields/createDeviceInputField +export interface DevicePayload { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/common/request-client.ts b/packages/destination-actions/src/destinations/moloco-rmp/common/request-client.ts new file mode 100644 index 0000000000..2657dd5218 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/common/request-client.ts @@ -0,0 +1,39 @@ +import { + RequestClient, + ModifiedResponse +} from '@segment/actions-core' +import type { Settings } from '../generated-types' + +export class MolocoAPIClient { + url: string + platform: string + apiKey: string + + request: RequestClient + + constructor(request: RequestClient, settings: Settings) { + this.platform = settings.platformId + this.apiKey = settings.apiKey + this.request = request + + this.url = this.getEndpoint() + } + + private getEndpoint() { + return `https://${this.platform.replace(/_/g, '-')}-evt.rmp-api.moloco.com/cdp/SEGMENT` + } + + async sendEvent(body: Record): Promise { + const headers = { + 'x-api-key': this.apiKey, + 'x-platform-id': this.platform, + 'Content-Type': 'application/json' + } + + return await this.request(this.url, { + method: 'POST', + headers, + json: body + }) + } +} \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/moloco-rmp/common/settings.ts b/packages/destination-actions/src/destinations/moloco-rmp/common/settings.ts new file mode 100644 index 0000000000..bbd1c5946f --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/common/settings.ts @@ -0,0 +1,8 @@ +// Generalized interface for auth.authentication.fields +// All destination actions, will use the auth.authentication to define the fields for the authentication scheme, +// and the corresponding interface will be automatically generated. +// This interface is the generalized version of that auto-generated interface. +export interface Settings { + platformId: string + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/generated-types.ts new file mode 100644 index 0000000000..ef736085c0 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * ID of the platform + */ + platformId: string + /** + * The API key for the platform + */ + apiKey: string + /** + * Type of channel, either APP or SITE. Defaults to SITE. + */ + channel_type: string +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..633fc9531b --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MolocoRmp's home destination action: all fields 1`] = ` +Object { + "channel_type": "M7i8t#AejMHF0ojzM0NV", + "device": Object { + "advertising_id": "M7i8t#AejMHF0ojzM0NV", + "ip": "M7i8t#AejMHF0ojzM0NV", + "language": "M7i8t#AejMHF0ojzM0NV", + "model": "M7i8t#AejMHF0ojzM0NV", + "os": "M7I8T#AEJMHF0OJZM0NV", + "os_version": "M7i8t#AejMHF0ojzM0NV", + "ua": "M7i8t#AejMHF0ojzM0NV", + "unique_device_id": "M7i8t#AejMHF0ojzM0NV", + }, + "event_type": "HOME", + "id": "M7i8t#AejMHF0ojzM0NV", + "items": Array [ + Object { + "id": "M7i8t#AejMHF0ojzM0NV", + "price": Object { + "amount": 84218396593356.8, + "currency": "VEF", + }, + "quantity": 8421839659335680, + "seller_id": "M7i8t#AejMHF0ojzM0NV", + }, + ], + "page_id": "M7i8t#AejMHF0ojzM0NV", + "session_id": "M7i8t#AejMHF0ojzM0NV", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "M7i8t#AejMHF0ojzM0NV", +} +`; + +exports[`Testing snapshot for MolocoRmp's home destination action: required fields 1`] = ` +Object { + "channel_type": "M7i8t#AejMHF0ojzM0NV", + "event_type": "HOME", + "id": "M7i8t#AejMHF0ojzM0NV", + "page_id": "M7i8t#AejMHF0ojzM0NV", + "session_id": "M7i8t#AejMHF0ojzM0NV", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "M7i8t#AejMHF0ojzM0NV", +} +`; diff --git a/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/index.test.ts new file mode 100644 index 0000000000..1d7e39bcd3 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/index.test.ts @@ -0,0 +1,30 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.home', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent() + const responses = await testDestination.testAction('home', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + timestamp: { '@path': '$.timestamp' } + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + // There is no `HOME` specific required fields +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..95fbcd36bc --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/home/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'home' +const destinationSlug = 'MolocoRmp' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/home/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/home/generated-types.ts new file mode 100644 index 0000000000..01517a2bde --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/home/generated-types.ts @@ -0,0 +1,98 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items?: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/home/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/home/index.ts new file mode 100644 index 0000000000..264c591897 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/home/index.ts @@ -0,0 +1,41 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id, + page_identifier_tokens, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import { convertEvent } from '../common/convert' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Home', + defaultSubscription: 'type = "page" and properties.name = "Home"', + description: 'Represents a user visiting a home page', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id, + page_identifier_tokens, + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.Home, payload, settings }) + return client.sendEvent(body) + } +} + +export default action \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/moloco-rmp/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/index.ts new file mode 100644 index 0000000000..12630e1076 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/index.ts @@ -0,0 +1,123 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import home from './home' + +import search from './search' + +import addToWishlist from './addToWishlist' + +import itemPageView from './itemPageView' + +import pageView from './pageView' + +import land from './land' + +import purchase from './purchase' + +import addToCart from './addToCart' + +const destination: DestinationDefinition = { + name: 'Moloco Rmp', + slug: 'actions-moloco-rmp', + mode: 'cloud', + description: 'This destination sends user events to Moloco RMP for machine learning and ad attribution.', + authentication: { + scheme: 'custom', + fields: { + platformId: { + label: 'Platform ID', + description: 'ID of the platform', + type: 'string', + required: true + }, + apiKey: { + label: 'API Key', + description: 'The API key for the platform', + type: 'password', + required: true + }, + channel_type: { + label: 'Channel Type', + description: 'Type of channel, either APP or SITE. Defaults to SITE.', + type: 'string', + required: true, + choices: [ + { label: 'App', value: 'APP' }, + { label: 'Site', value: 'SITE' } + ] + } + } + }, + presets: [ + { + name: 'Search', + subscribe: 'type = "track" and event = "Products Searched"', + partnerAction: 'search', + mapping: defaultValues(search.fields), + type: 'automatic' + }, + { + name: 'Purchase', + subscribe: 'type = "track" and event = "Order Completed"', + partnerAction: 'purchase', + mapping: defaultValues(purchase.fields), + type: 'automatic' + }, + { + name: 'Page View', + subscribe: 'type = "page" and properties.name != "Home" and properties.name != "Land"', + partnerAction: 'pageView', + mapping: defaultValues(pageView.fields), + type: 'automatic' + }, + { + name: 'Land', + subscribe: 'type = "page" and properties.name = "Land"', + partnerAction: 'land', + mapping: defaultValues(land.fields), + type: 'automatic' + }, + { + name: 'Item Page View', + subscribe: 'type = "track" and event = "Product Viewed"', + partnerAction: 'itemPageView', + mapping: defaultValues(itemPageView.fields), + type: 'automatic' + }, + { + name: 'Home', + subscribe: 'type = "page" and properties.name = "Home"', + partnerAction: 'home', + mapping: defaultValues(home.fields), + type: 'automatic' + }, + { + name: 'Add to Wishlist', + subscribe: 'type = "track" and event = "Product Added to Wishlist"', + partnerAction: 'addToWishlist', + mapping: defaultValues(addToWishlist.fields), + type: 'automatic' + }, + { + name: 'Add to Cart', + subscribe: 'type = "track" and event = "Product Added"', + partnerAction: 'addToCart', + mapping: defaultValues(addToCart.fields), + type: 'automatic' + } + ], + actions: { + home, + search, + addToWishlist, + itemPageView, + pageView, + land, + purchase, + addToCart + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..9612e445bc --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MolocoRmp's itemPageView destination action: all fields 1`] = ` +Object { + "channel_type": "SYvlLHdgX", + "device": Object { + "advertising_id": "SYvlLHdgX", + "ip": "SYvlLHdgX", + "language": "SYvlLHdgX", + "model": "SYvlLHdgX", + "os": "SYVLLHDGX", + "os_version": "SYvlLHdgX", + "ua": "SYvlLHdgX", + "unique_device_id": "SYvlLHdgX", + }, + "event_type": "ITEM_PAGE_VIEW", + "id": "SYvlLHdgX", + "items": Array [ + Object { + "id": "SYvlLHdgX", + "price": Object { + "amount": -35543909044060.16, + "currency": "INR", + }, + "quantity": -3554390904406016, + "seller_id": "SYvlLHdgX", + }, + ], + "page_id": "SYvlLHdgX", + "session_id": "SYvlLHdgX", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "SYvlLHdgX", +} +`; + +exports[`Testing snapshot for MolocoRmp's itemPageView destination action: required fields 1`] = ` +Object { + "channel_type": "SYvlLHdgX", + "event_type": "ITEM_PAGE_VIEW", + "id": "SYvlLHdgX", + "items": Array [ + Object { + "id": "SYvlLHdgX", + }, + ], + "page_id": "SYvlLHdgX", + "session_id": "SYvlLHdgX", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "SYvlLHdgX", +} +`; diff --git a/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/index.test.ts new file mode 100644 index 0000000000..963ef4afbd --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/index.test.ts @@ -0,0 +1,108 @@ +import nock from 'nock' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.itemPageView', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + item: { + id: '123', + price: 100, + currency: 'USD', + quantity: 1, + sellerId: 'seller123', + } + } + }) + + const responses = await testDestination.testAction('itemPageView', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE', + }, + mapping: { + timestamp: { '@path': '$.timestamp' }, + + items: [ + { + id: { + '@path': '$.properties.item.id' + }, + price: { + '@path': '$.properties.item.price' + }, + currency: { + '@path': '$.properties.item.currency' + }, + quantity: { + '@path': '$.properties.item.quantity' + }, + sellerId: { + '@path': '$.properties.item.sellerId' + } + } + ] + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should fail to build an event because it misses a required field', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + item: { + id: '123', + price: 100, + currency: 'USD', + quantity: 1, + sellerId: 'seller123' + } + } + }) + + await expect(testDestination.testAction('itemPageView', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE', + }, + mapping: { + // items: [ + // { + // id: { + // '@path': '$.properties.item.id' + // }, + // price: { + // '@path': '$.properties.item.price' + // }, + // currency: { + // '@path': '$.properties.item.currency' + // }, + // quantity: { + // '@path': '$.properties.item.quantity' + // }, + // sellerId: { + // '@path': '$.properties.item.sellerId' + // } + // } + // ] -- missing required field + }, + useDefaultMappings: true, + })).rejects.toThrowError(AggregateAjvError) + }) + +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..9555d944a8 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'itemPageView' +const destinationSlug = 'MolocoRmp' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/generated-types.ts new file mode 100644 index 0000000000..4547f2209d --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/generated-types.ts @@ -0,0 +1,98 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/index.ts new file mode 100644 index 0000000000..74c2cc5a7c --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/itemPageView/index.ts @@ -0,0 +1,56 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id, + page_identifier_tokens, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import { convertEvent } from '../common/convert' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Item Page View', + description: 'Represents a user viewing an item page', + defaultSubscription: 'type = "track" and event = "Product Viewed"', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items: { + ...items, + required: true, + default: { + '@arrayPath': [ + '$.properties', + { + id: { '@path': '$.product_id' }, + price: { '@path': '$.price' }, + currency: { '@path': '$.currency' }, + quantity: { '@path': '$.quantity' }, + seller_id: { '@path': '$.seller_id'} + } + ] + } + }, + page_id, + page_identifier_tokens, + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.ItemPageView, payload, settings }) + return client.sendEvent(body) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..170d144c42 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MolocoRmp's land destination action: all fields 1`] = ` +Object { + "channel_type": "ziisL0MJUd6S", + "device": Object { + "advertising_id": "ziisL0MJUd6S", + "ip": "ziisL0MJUd6S", + "language": "ziisL0MJUd6S", + "model": "ziisL0MJUd6S", + "os": "ZIISL0MJUD6S", + "os_version": "ziisL0MJUd6S", + "ua": "ziisL0MJUd6S", + "unique_device_id": "ziisL0MJUd6S", + }, + "event_type": "LAND", + "id": "ziisL0MJUd6S", + "items": Array [ + Object { + "id": "ziisL0MJUd6S", + "price": Object { + "amount": -7208881776230.4, + "currency": "CAD", + }, + "quantity": -720888177623040, + "seller_id": "ziisL0MJUd6S", + }, + ], + "page_id": "ziisL0MJUd6S", + "referrer_page_id": "ziisL0MJUd6S", + "session_id": "ziisL0MJUd6S", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "ziisL0MJUd6S", +} +`; + +exports[`Testing snapshot for MolocoRmp's land destination action: required fields 1`] = ` +Object { + "channel_type": "ziisL0MJUd6S", + "event_type": "LAND", + "id": "ziisL0MJUd6S", + "page_id": "ziisL0MJUd6S", + "referrer_page_id": "ziisL0MJUd6S", + "session_id": "ziisL0MJUd6S", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "ziisL0MJUd6S", +} +`; diff --git a/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/index.test.ts new file mode 100644 index 0000000000..425f04e3d2 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/index.test.ts @@ -0,0 +1,103 @@ +import nock from 'nock' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.land', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + context: { + ip: '8.8.8.8', + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + location: { + city: 'San Francisco', + country: 'United States', + latitude: 40.2964197, + longitude: -76.9411617, + speed: 0 + }, + page: { + path: '/academy/', + referrer: 'testesttest', + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + timezone: 'Europe/Amsterdam', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + } + }) + + const responses = await testDestination.testAction('land', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + timestamp: { '@path': '$.timestamp' }, + + // referrer_page_id is default to context.page.referrer + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should fail to build an event because it misses a required field', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + context: { + ip: '8.8.8.8', + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + location: { + city: 'San Francisco', + country: 'United States', + latitude: 40.2964197, + longitude: -76.9411617, + speed: 0 + }, + page: { + path: '/academy/', + referrer: '', // missing referrer, should raise an error because referrer_page_id is required which is default to context.page.referrer + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + timezone: 'Europe/Amsterdam', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + } + }) + + await expect(testDestination.testAction('land', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + + }, + useDefaultMappings: true, + })).rejects.toThrowError(AggregateAjvError) + }) +}) \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..20da133a0b --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/land/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'land' +const destinationSlug = 'MolocoRmp' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/land/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/land/generated-types.ts new file mode 100644 index 0000000000..ede561eb0b --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/land/generated-types.ts @@ -0,0 +1,102 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items?: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } + /** + * Similar to referrer in HTTP, this value indicates from which page the user came to the current page. + */ + referrer_page_id: string +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/land/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/land/index.ts new file mode 100644 index 0000000000..ee10e1e54c --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/land/index.ts @@ -0,0 +1,46 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id, + page_identifier_tokens, + referrer_page_id, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { convertEvent } from '../common/convert' + +const action: ActionDefinition = { + title: 'Land', + description: 'Represents a user visiting the client’s website from an external source (ex. Google Shopping)', + defaultSubscription: 'type = "page" and properties.name = "Land"', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id, + page_identifier_tokens, + referrer_page_id: { + ...referrer_page_id, + required: true + }, + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.Land, payload, settings }) + return client.sendEvent(body) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..510db67f41 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MolocoRmp's pageView destination action: all fields 1`] = ` +Object { + "channel_type": "W97yQ", + "device": Object { + "advertising_id": "W97yQ", + "ip": "W97yQ", + "language": "W97yQ", + "model": "W97yQ", + "os": "W97YQ", + "os_version": "W97yQ", + "ua": "W97yQ", + "unique_device_id": "W97yQ", + }, + "event_type": "PAGE_VIEW", + "id": "W97yQ", + "items": Array [ + Object { + "id": "W97yQ", + "price": Object { + "amount": -82844332243025.92, + "currency": "UNKNOWN_CURRENCY", + }, + "quantity": -8284433224302592, + "seller_id": "W97yQ", + }, + ], + "page_id": "W97yQ", + "referrer_page_id": "W97yQ", + "session_id": "W97yQ", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "W97yQ", +} +`; + +exports[`Testing snapshot for MolocoRmp's pageView destination action: required fields 1`] = ` +Object { + "channel_type": "W97yQ", + "event_type": "PAGE_VIEW", + "id": "W97yQ", + "page_id": "W97yQ", + "referrer_page_id": "W97yQ", + "session_id": "W97yQ", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "W97yQ", +} +`; diff --git a/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/index.test.ts new file mode 100644 index 0000000000..50bafc0482 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/index.test.ts @@ -0,0 +1,56 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.pageView', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + context: { + ip: '8.8.8.8', + library: { + name: 'analytics.js', + version: '2.11.1' + }, + locale: 'en-US', + location: { + city: 'San Francisco', + country: 'United States', + latitude: 40.2964197, + longitude: -76.9411617, + speed: 0 + }, + page: { + path: '/academy/', + referrer: '', + search: '', + title: 'Analytics Academy', + url: 'https://segment.com/academy/' + }, + timezone: 'Europe/Amsterdam', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + } + }) + + const responses = await testDestination.testAction('pageView', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + + // page_id is default to context.page.path + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) +}) \ No newline at end of file diff --git a/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..28e2fb17ae --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/pageView/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'pageView' +const destinationSlug = 'MolocoRmp' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/pageView/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/pageView/generated-types.ts new file mode 100644 index 0000000000..968d56ee82 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/pageView/generated-types.ts @@ -0,0 +1,102 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items?: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". Either page_id or page_identifier_tokens is required. + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. Either page_id or page_identifier_tokens is required. + */ + page_identifier_tokens?: { + [k: string]: unknown + } + /** + * Similar to referrer in HTTP, this value indicates from which page the user came to the current page. + */ + referrer_page_id?: string +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/pageView/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/pageView/index.ts new file mode 100644 index 0000000000..7b0a343222 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/pageView/index.ts @@ -0,0 +1,51 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id, + page_identifier_tokens, + referrer_page_id, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import { convertEvent } from '../common/convert' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Page View', + description: + 'Represents a user viewing a certain page that is pertinent to sequence-based ML model training (Ex. a user browsing sneakers)', + defaultSubscription: 'type = "page" and properties.name != "Home" and properties.name != "Land"', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + page_id: { + ...page_id, + description: page_id.description + ' Either page_id or page_identifier_tokens is required.' + }, + page_identifier_tokens: { + ...page_identifier_tokens, + description: page_identifier_tokens.description + ' Either page_id or page_identifier_tokens is required.' + + }, + referrer_page_id + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.PageView, payload, settings }) + return client.sendEvent(body) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..9518c39a58 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MolocoRmp's purchase destination action: all fields 1`] = ` +Object { + "channel_type": "Gxw(AXLlJ#6Uf]*Hp", + "device": Object { + "advertising_id": "Gxw(AXLlJ#6Uf]*Hp", + "ip": "Gxw(AXLlJ#6Uf]*Hp", + "language": "Gxw(AXLlJ#6Uf]*Hp", + "model": "Gxw(AXLlJ#6Uf]*Hp", + "os": "GXW(AXLLJ#6UF]*HP", + "os_version": "Gxw(AXLlJ#6Uf]*Hp", + "ua": "Gxw(AXLlJ#6Uf]*Hp", + "unique_device_id": "Gxw(AXLlJ#6Uf]*Hp", + }, + "event_type": "PURCHASE", + "id": "Gxw(AXLlJ#6Uf]*Hp", + "items": Array [ + Object { + "id": "Gxw(AXLlJ#6Uf]*Hp", + "price": Object { + "amount": 52157945353338.88, + "currency": "DKK", + }, + "quantity": 5215794535333888, + "seller_id": "Gxw(AXLlJ#6Uf]*Hp", + }, + ], + "page_id": "Gxw(AXLlJ#6Uf]*Hp", + "revenue": Object { + "amount": 52157945353338.88, + "currency": "DKK", + }, + "session_id": "Gxw(AXLlJ#6Uf]*Hp", + "shipping_charge": Object { + "amount": 52157945353338.88, + "currency": "DKK", + }, + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "Gxw(AXLlJ#6Uf]*Hp", +} +`; + +exports[`Testing snapshot for MolocoRmp's purchase destination action: required fields 1`] = ` +Object { + "channel_type": "Gxw(AXLlJ#6Uf]*Hp", + "event_type": "PURCHASE", + "id": "Gxw(AXLlJ#6Uf]*Hp", + "items": Array [ + Object { + "id": "Gxw(AXLlJ#6Uf]*Hp", + }, + ], + "page_id": "Gxw(AXLlJ#6Uf]*Hp", + "revenue": Object { + "amount": 52157945353338.88, + "currency": "DKK", + }, + "session_id": "Gxw(AXLlJ#6Uf]*Hp", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "Gxw(AXLlJ#6Uf]*Hp", +} +`; diff --git a/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/index.test.ts new file mode 100644 index 0000000000..917f416d39 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/index.test.ts @@ -0,0 +1,107 @@ +import nock from 'nock' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.purchase', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + item: { + id: '123', + price: 100, + quantity: 1, + sellerId: 'seller123' + }, + currency: 'USD', + revenue: 100 + } + }) + + const responses = await testDestination.testAction('purchase', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE', + }, + mapping: { + timestamp: { '@path': '$.timestamp' }, + items: [ + { + id: { + '@path': '$.properties.item.id' + }, + price: { + '@path': '$.properties.item.price' + }, + currency: { + '@path': '$.properties.currency' + }, + quantity: { + '@path': '$.properties.item.quantity' + }, + sellerId: { + '@path': '$.properties.item.sellerId' + } + } + ], + revenue: { + price: { + '@path': '$.properties.revenue' + }, + currency: { + '@path': '$.properties.currency' + } + } + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should fail to build an event because it misses a required field (items)', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + item: { + id: '123', + price: 100, + quantity: 1, + sellerId: 'seller123' + }, + currency: 'USD', + revenue: 100 + } + }) + + await expect(testDestination.testAction('purchase', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + // items: -- missing mapping for a required field + revenue: { + price: { + '@path': '$.properties.revenue' + }, + currency: { + '@path': '$.properties.currency' + } + } + }, + useDefaultMappings: true, + })).rejects.toThrowError(AggregateAjvError) + }) + +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..4b6ff6dfb2 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/purchase/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'purchase' +const destinationSlug = 'MolocoRmp' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/purchase/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/purchase/generated-types.ts new file mode 100644 index 0000000000..8bc56bbafa --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/purchase/generated-types.ts @@ -0,0 +1,124 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * Revenue of the event + */ + revenue: { + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency: string + } + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } + /** + * Shipping charge’s monetary amount in a specific currency. + */ + shipping_charge?: { + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency: string + } +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/purchase/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/purchase/index.ts new file mode 100644 index 0000000000..2ca0063aac --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/purchase/index.ts @@ -0,0 +1,51 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + revenue, + page_id, + page_identifier_tokens, + shipping_charge, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import { convertEvent } from '../common/convert' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Purchase', + description: 'Represents a user purchasing an item', + defaultSubscription: 'type = "track" and event = "Order Completed"', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items: { + ...items, + required: true + }, + revenue: { + ...revenue, + required: true + }, + page_id, + page_identifier_tokens, + shipping_charge + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.Purchase, payload, settings}) + return client.sendEvent(body) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..d72fac60c2 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for MolocoRmp's search destination action: all fields 1`] = ` +Object { + "channel_type": "TVdeoPEfmlhx4", + "device": Object { + "advertising_id": "TVdeoPEfmlhx4", + "ip": "TVdeoPEfmlhx4", + "language": "TVdeoPEfmlhx4", + "model": "TVdeoPEfmlhx4", + "os": "TVDEOPEFMLHX4", + "os_version": "TVdeoPEfmlhx4", + "ua": "TVdeoPEfmlhx4", + "unique_device_id": "TVdeoPEfmlhx4", + }, + "event_type": "SEARCH", + "id": "TVdeoPEfmlhx4", + "items": Array [ + Object { + "id": "TVdeoPEfmlhx4", + "price": Object { + "amount": 456586774446.08, + "currency": "RUB", + }, + "quantity": 45658677444608, + "seller_id": "TVdeoPEfmlhx4", + }, + ], + "page_id": "TVdeoPEfmlhx4", + "referrer_page_id": "TVdeoPEfmlhx4", + "search_query": "TVdeoPEfmlhx4", + "session_id": "TVdeoPEfmlhx4", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "TVdeoPEfmlhx4", +} +`; + +exports[`Testing snapshot for MolocoRmp's search destination action: required fields 1`] = ` +Object { + "channel_type": "TVdeoPEfmlhx4", + "event_type": "SEARCH", + "id": "TVdeoPEfmlhx4", + "page_id": "TVdeoPEfmlhx4", + "referrer_page_id": "TVdeoPEfmlhx4", + "search_query": "TVdeoPEfmlhx4", + "session_id": "TVdeoPEfmlhx4", + "timestamp": "2021-02-01T00:00:00.000Z", + "user_id": "TVdeoPEfmlhx4", +} +`; diff --git a/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/index.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/index.test.ts new file mode 100644 index 0000000000..4ce5afb768 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/index.test.ts @@ -0,0 +1,68 @@ +import nock from 'nock' +import { AggregateAjvError } from '@segment/ajv-human-errors' +import { createTestEvent, createTestIntegration, } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +describe('MolocoRmp.search', () => { + it('should successfully build an event and send', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + context: { + page: { + query: "Test Query" + } + } + } + }) + + const responses = await testDestination.testAction('search', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + timestamp: { '@path': '$.timestamp' }, + search_query: { '@path': '$.properties.context.page.query'} + }, + useDefaultMappings: true, + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + + it('should fail to build an event because it misses a required field', async () => { + nock(/.*/).persist().post(/.*/).reply(200) + + const event = createTestEvent({ + properties: { + context: { + page: { + query: "Test Query" + } + } + } + }) + + await expect(testDestination.testAction('search', { + event, + settings: { + platformId: 'foo', + apiKey: 'bar', + channel_type: 'SITE' + }, + mapping: { + // searchQuery: { + // '@path': '$.properties.context.page.query' + // } -- missing required field + }, + useDefaultMappings: true, + })).rejects.toThrowError(AggregateAjvError) + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..9048c0bfad --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/search/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'search' +const destinationSlug = 'MolocoRmp' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/moloco-rmp/search/generated-types.ts b/packages/destination-actions/src/destinations/moloco-rmp/search/generated-types.ts new file mode 100644 index 0000000000..46130fa8ec --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/search/generated-types.ts @@ -0,0 +1,106 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Unique ID generated by the client to suppress duplicate events. The length should not exceed 128 characters. + */ + event_id?: string + /** + * Timestamp that the event happened at. + */ + timestamp: string | number + /** + * User Identifier for the platform. The length should not exceed 128 characters. + */ + user_id?: string + /** + * Device information of the event + */ + device?: { + /** + * OS of the device. "ios" or "android" must be included for the APP channel type. + */ + os?: string + /** + * Device OS version, which is taken from the device without manipulation or normalization. (e.g., "14.4.1") + */ + os_version?: string + /** + * For app traffic, IDFA of iOS or ADID of android should be filled in this field. (e.g., 7acefbed-d1f6-4e4e-aa26-74e93dd017e4) + */ + advertising_id?: string + /** + * For app traffic, a unique identifier for the device being used should be provided in this field. + * Clients can issue identifiers for their user devices or use their IDFV values if using iOS apps. + * The length of this id should not exceed 128 characters. + */ + unique_device_id?: string + /** + * Device model, which is taken from the device without manipulation or normalization. (e.g., "iPhone 11 Pro") + */ + model?: string + /** + * User Agent. (e.g., "Mozilla/5.0 (iPhone; CPU iPhone OS 14_4_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/111FFF") + */ + ua?: string + /** + * ISO-639-1 alpha-2 language code. (e.g., "en") + */ + language?: string + /** + * IP in IPv4 format. (e.g., 216.212.237.213) + */ + ip?: string + } + /** + * Identifier for tracking users regardless of sign-in status. The length should not exceed 128 characters. + */ + session_id?: string + /** + * The default currency value. Defaults to "USD". If this is set, it will be used as a default currency value for items. + */ + default_currency?: string + /** + * Item information list related to the event. + */ + items?: { + /** + * Unique identifier of the Item. + */ + id: string + /** + * Monetary amount without currency, e.g. 12.34. This field is required if the Currency field is populated. + */ + price?: number + /** + * Currency information. This field is required if the Price field is populated. + */ + currency?: string + /** + * Quantity of the item. Recommended. + */ + quantity?: number + /** + * Unique identifier of the Seller. + */ + seller_id?: string + }[] + /** + * Query string for the search. + */ + search_query: string + /** + * A string value used to uniquely identify a page. For example: "electronics", "categories/12312", "azd911d" or "/classes/foo/lectures/bar". + */ + page_id?: string + /** + * Tokens that can be used to identify a page. Alternative to page_id with a lower priority. + */ + page_identifier_tokens?: { + [k: string]: unknown + } + /** + * Similar to referrer in HTTP, this value indicates from which page the user came to the current page. + */ + referrer_page_id?: string +} diff --git a/packages/destination-actions/src/destinations/moloco-rmp/search/index.ts b/packages/destination-actions/src/destinations/moloco-rmp/search/index.ts new file mode 100644 index 0000000000..e29d0612b3 --- /dev/null +++ b/packages/destination-actions/src/destinations/moloco-rmp/search/index.ts @@ -0,0 +1,48 @@ +import type { ActionDefinition } from '@segment/actions-core' +import { EventType } from '../common/event' +import { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + search_query, + page_id, + page_identifier_tokens, + referrer_page_id, +} from '../common/fields' +import { MolocoAPIClient } from '../common/request-client' +import { convertEvent } from '../common/convert' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Search', + defaultSubscription: 'type = "track" and event = "Products Searched"', + description: 'Represents a user searching for an item', + fields: { + event_id, + timestamp, + user_id, + device, + session_id, + default_currency, + items, + search_query: { + ...search_query, + required: true + }, + page_id, + page_identifier_tokens, + referrer_page_id + }, + perform: (request, {payload, settings}) => { + const client = new MolocoAPIClient(request, settings) + const body = convertEvent({ eventType: EventType.Search, payload, settings }) + return client.sendEvent(body) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/movable-ink/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/movable-ink/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..4d2c9aa07a --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-movable-ink destination: sendEntireEvent action - all fields 1`] = ` +Object { + "testType": "LmFmzlF7F", +} +`; + +exports[`Testing snapshot for actions-movable-ink destination: sendEntireEvent action - required fields 1`] = `""`; + +exports[`Testing snapshot for actions-movable-ink destination: sendEntireEvent action - required fields 2`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Basic dGVzdDp0ZXN0", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/movable-ink/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/movable-ink/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..6a709799ae --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/__tests__/snapshot.test.ts @@ -0,0 +1,85 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-movable-ink' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: { + movable_ink_url: 'https://www.test.com', + username: 'test', + password: 'test' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: { + movable_ink_url: 'https://www.test.com', + username: 'test', + password: 'test' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/movable-ink/generated-types.ts b/packages/destination-actions/src/destinations/movable-ink/generated-types.ts new file mode 100644 index 0000000000..60aa1f83e0 --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Movable Ink username + */ + username: string + /** + * Your Movable Ink Access Secret. + */ + password: string + /** + * The Movable Ink URL to send data to. + */ + movable_ink_url: string +} diff --git a/packages/destination-actions/src/destinations/movable-ink/index.ts b/packages/destination-actions/src/destinations/movable-ink/index.ts new file mode 100644 index 0000000000..28c512b121 --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/index.ts @@ -0,0 +1,53 @@ +import { defaultValues, DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import sendEntireEvent from './sendEntireEvent' + +const destination: DestinationDefinition = { + name: 'Movable Ink', + slug: 'actions-movable-ink', + mode: 'cloud', + description: 'Send Segment analytics events to Movable Ink', + authentication: { + scheme: 'basic', + fields: { + username: { + label: 'Username', + description: 'Your Movable Ink username', + type: 'string', + required: true + }, + password: { + label: 'Access Secret', + description: 'Your Movable Ink Access Secret.', + type: 'string', + required: true + }, + movable_ink_url: { + label: 'Movable Ink URL', + description: 'The Movable Ink URL to send data to.', + type: 'string', + required: true + } + } + }, + extendRequest({ settings }) { + return { + username: settings.username, + password: settings.password + } + }, + presets: [ + { + name: 'Send Entire Event', + partnerAction: 'sendEntireEvent', + subscribe: 'type = "identify" or type = "track" or type = "page" or type = "screen" or type = "group"', + mapping: defaultValues(sendEntireEvent.fields), + type: 'automatic' + } + ], + actions: { + sendEntireEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..034a4790e7 --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,22 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-movable-ink's sendEntireEvent destination action: all fields 1`] = ` +Object { + "testType": "LmFmzlF7F", +} +`; + +exports[`Testing snapshot for actions-movable-ink's sendEntireEvent destination action: required fields 1`] = `""`; + +exports[`Testing snapshot for actions-movable-ink's sendEntireEvent destination action: required fields 2`] = ` +Headers { + Symbol(map): Object { + "authorization": Array [ + "Basic dGVzdDp0ZXN0", + ], + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..b752792e87 --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/index.test.ts @@ -0,0 +1,59 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { Settings } from '../../generated-types' + +const testDestination = createTestIntegration(Destination) +const actionSlug = 'sendEntireEvent' + +const settings: Settings = { + movable_ink_url: 'https://www.test.com', + username: 'test', + password: 'test' +} + +const event = createTestEvent({ + type: 'track', + event: 'Webhook Event Happened', + userId: 'user1234', + anonymousId: '72d7bed1-4f42-4f2f-8955-72677340546b', + timestamp: '2022-03-30T17:24:58Z', + properties: { + order_id: 'order_1', + random_field: 'random_field_value', + categories: [{ id: 'cat1' }], + revenue: 100, + products: [{ id: 'pid1' }] + } +}) + +describe('MovableInk.sendEntireEvent', () => { + it('should send entire payload to Movable Ink', async () => { + nock(settings.movable_ink_url) + .post(/.*/) + .reply(200) + + const responses = await testDestination.testAction(actionSlug, { + event, + settings: settings, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + type: 'track', + event: 'Webhook Event Happened', + userId: 'user1234', + anonymousId: '72d7bed1-4f42-4f2f-8955-72677340546b', + timestamp: '2022-03-30T17:24:58Z', + properties: { + order_id: 'order_1', + random_field: 'random_field_value', + categories: [{ id: 'cat1' }], + revenue: 100, + products: [{ id: 'pid1' }] + } + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..57ddd41b71 --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/__tests__/snapshot.test.ts @@ -0,0 +1,83 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendEntireEvent' +const destinationSlug = 'actions-movable-ink' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: { + movable_ink_url: 'https://www.test.com', + username: 'test', + password: 'test' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: { + movable_ink_url: 'https://www.test.com', + username: 'test', + password: 'test' + }, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/generated-types.ts b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/generated-types.ts new file mode 100644 index 0000000000..06eaa2646a --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/generated-types.ts @@ -0,0 +1,16 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * HTTP headers to send with each request. Only ASCII characters are supported. + */ + headers?: { + [k: string]: unknown + } + /** + * Payload to deliver to webhook URL (JSON-encoded). + */ + data?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/index.ts b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/index.ts new file mode 100644 index 0000000000..c351f46275 --- /dev/null +++ b/packages/destination-actions/src/destinations/movable-ink/sendEntireEvent/index.ts @@ -0,0 +1,44 @@ +import { ActionDefinition, PayloadValidationError, IntegrationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Send Entire Event', + description: 'Send an entire Segment event to Movable Ink', + fields: { + headers: { + label: 'Headers', + description: 'HTTP headers to send with each request. Only ASCII characters are supported.', + type: 'object', + defaultObjectUI: 'keyvalue:only' + }, + data: { + label: 'Data', + description: 'Payload to deliver to webhook URL (JSON-encoded).', + type: 'object', + default: { '@path': '$.' } + } + }, + perform: (request, { settings, payload }) => { + const url = settings.movable_ink_url + if (!url) + throw new IntegrationError( + '"Movable Ink URL" setting or "Movable Ink URL" field must be populated', + 'MISSING_DESTINATION_URL', + 400 + ) + + try { + return request(url, { + method: 'POST', + headers: payload.headers as Record, + json: payload.data + }) + } catch (error) { + if (error instanceof TypeError) throw new PayloadValidationError(error.message) + throw error + } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/optimizely-advanced-audience-targeting/syncAudience/index.ts b/packages/destination-actions/src/destinations/optimizely-advanced-audience-targeting/syncAudience/index.ts index 7d4ec5205c..2a58d7522f 100644 --- a/packages/destination-actions/src/destinations/optimizely-advanced-audience-targeting/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/optimizely-advanced-audience-targeting/syncAudience/index.ts @@ -33,7 +33,8 @@ const action: ActionDefinition = { custom_audience_name: { label: 'Custom Audience Name', description: 'Name of custom audience to add or remove the user from', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_key' @@ -42,7 +43,8 @@ const action: ActionDefinition = { segment_computation_action: { label: 'Segment Computation Action', description: 'Segment computation class used to determine payload is for an Audience', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_class' @@ -52,7 +54,8 @@ const action: ActionDefinition = { segment_computation_id: { label: 'Segment Computation ID', description: 'Segment computation ID', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, default: { '@path': '$.context.personas.computation_id' @@ -73,7 +76,8 @@ const action: ActionDefinition = { }, timestamp: { label: 'Timestamp', - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, description: 'Timestamp indicates when the user was added or removed from the Audience', default: { diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/__snapshots__/snapshot.test.ts.snap index a4bd74c15d..611b1a360e 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/__snapshots__/snapshot.test.ts.snap @@ -12,6 +12,7 @@ Object { ], "timestamp": "p$KWIG5p0vR(@gNw)lv@", "total": "p$KWIG5p0vR(@gNw)lv@", + "type": "p$KWIG5p0vR(@gNw)lv@", "user_identifiers": Object { "anonymousId": "p$KWIG5p0vR(@gNw)lv@", "email": "agijit@cove.ly", @@ -26,6 +27,7 @@ Object { "action": "p$KWIG5p0vR(@gNw)lv@", "order_id": "p$KWIG5p0vR(@gNw)lv@", "timestamp": "p$KWIG5p0vR(@gNw)lv@", + "type": "custom", "user_identifiers": Object {}, } `; @@ -35,6 +37,7 @@ Object { "action": "SjDZyt(p", "campaign": "SjDZyt(p", "campaign_event_value": "SjDZyt(p", + "campaign_id": "SjDZyt(p", "timestamp": "SjDZyt(p", "type": "email", "user_identifiers": Object { @@ -51,6 +54,7 @@ Object { "action": "SjDZyt(p", "campaign": "SjDZyt(p", "campaign_event_value": null, + "campaign_id": "SjDZyt(p", "timestamp": "SjDZyt(p", "type": "email", "user_identifiers": Object { @@ -59,6 +63,31 @@ Object { } `; +exports[`Testing snapshot for actions-optimizely-data-platform destination: nonEcommCustomEvent action - all fields 1`] = ` +Object { + "action": "3!#ax", + "data": Object { + "testType": "3!#ax", + }, + "timestamp": "3!#ax", + "type": "3!#ax", + "user_identifiers": Object { + "anonymousId": "3!#ax", + "email": "elenal@fen.uz", + "optimizely_vuid": "3!#ax", + "userId": "3!#ax", + }, +} +`; + +exports[`Testing snapshot for actions-optimizely-data-platform destination: nonEcommCustomEvent action - required fields 1`] = ` +Object { + "timestamp": "3!#ax", + "type": "custom", + "user_identifiers": Object {}, +} +`; + exports[`Testing snapshot for actions-optimizely-data-platform destination: upsertContact action - all fields 1`] = ` Object { "address": Object { @@ -70,13 +99,16 @@ Object { }, "age": 89357760918978.56, "company": "WnJP3)h29qx6Q9XcA@qx", - "dob": "2021-02-01T00:00:00.000Z", + "dob_day": 1, + "dob_month": 2, + "dob_year": 2021, "first_name": "WnJP3)h29qx6Q9XcA@qx", "gender": "WnJP3)h29qx6Q9XcA@qx", "image_url": "WnJP3)h29qx6Q9XcA@qx", "last_name": "WnJP3)h29qx6Q9XcA@qx", "name": "WnJP3)h29qx6Q9XcA@qx", "phone": "WnJP3)h29qx6Q9XcA@qx", + "testType": "WnJP3)h29qx6Q9XcA@qx", "title": "WnJP3)h29qx6Q9XcA@qx", "user_identifiers": Object { "anonymousId": "WnJP3)h29qx6Q9XcA@qx", diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/index.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/index.test.ts index 6470dfa403..dfc0039843 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/index.test.ts @@ -6,7 +6,6 @@ const testDestination = createTestIntegration(Definition) describe('Optimizely Data Platform', () => { describe('testAuthentication', () => { - it('should validate authentication inputs', async () => { nock('https://function.zaius.app/twilio_segment').post('/auth').reply(200) diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/snapshot.test.ts index 99e7dce990..236bc581aa 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/__tests__/snapshot.test.ts @@ -21,8 +21,8 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { properties: eventData }) - settingsData['apiKey'] = 'abc123'; - settingsData['region'] = 'US'; + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' const responses = await testDestination.testAction(actionSlug, { event: event, @@ -58,8 +58,8 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { properties: eventData }) - settingsData['apiKey'] = 'abc123'; - settingsData['region'] = 'US'; + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' const responses = await testDestination.testAction(actionSlug, { event: event, diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 9c65ae04f0..04dce0f4d6 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -12,6 +12,7 @@ Object { ], "timestamp": "FX3MHiX9P^tIkXKVCa", "total": "FX3MHiX9P^tIkXKVCa", + "type": "FX3MHiX9P^tIkXKVCa", "user_identifiers": Object { "anonymousId": "FX3MHiX9P^tIkXKVCa", "email": "oriiwo@hovevut.bn", @@ -26,6 +27,7 @@ Object { "action": "FX3MHiX9P^tIkXKVCa", "order_id": "FX3MHiX9P^tIkXKVCa", "timestamp": "FX3MHiX9P^tIkXKVCa", + "type": "custom", "user_identifiers": Object {}, } `; diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/index.test.ts index debd7f9944..4643f72371 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/__tests__/index.test.ts @@ -31,12 +31,26 @@ describe('OptimizelyDataPlatform.trackEvent', () => { apiKey: 'abc123', region: 'US' }, - useDefaultMappings: true + mapping: { + user_identifiers: { + anonymousId: 'anonId1234', + userId: 'user1234' + }, + event_type: 'whatever', + event_action: 'purchase', + products: [ + { product_id: '12345', qty: 2 }, + { product_id: '67890', qty: 5 } + ], + order_id: '1234', + total: 20, + timestamp: '2024-03-01T18:11:27.649Z' + } }) - const expectedBody = `"{\\"user_identifiers\\":{\\"anonymousId\\":\\"anonId1234\\",\\"userId\\":\\"user1234\\"},\\"action\\":\\"purchase\\",\\"timestamp\\":\\"${productEvent.timestamp}\\",\\"order_id\\":\\"1234\\",\\"total\\":\\"20\\",\\"products\\":[{\\"product_id\\":\\"12345\\",\\"qty\\":2},{\\"product_id\\":\\"67890\\",\\"qty\\":5}]}"` - expect(response[0].status).toBe(201) - expect(response[0].options.body).toMatchInlineSnapshot(expectedBody) + expect(response[0].options.body).toMatchInlineSnapshot( + `"{\\"user_identifiers\\":{\\"anonymousId\\":\\"anonId1234\\",\\"userId\\":\\"user1234\\"},\\"action\\":\\"purchase\\",\\"type\\":\\"whatever\\",\\"timestamp\\":\\"2024-03-01T18:11:27.649Z\\",\\"order_id\\":\\"1234\\",\\"total\\":\\"20\\",\\"products\\":[{\\"product_id\\":\\"12345\\",\\"qty\\":2},{\\"product_id\\":\\"67890\\",\\"qty\\":5}]}"` + ) }) }) diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/generated-types.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/generated-types.ts index c22e0c7df4..ff39b8e445 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/generated-types.ts @@ -23,7 +23,11 @@ export interface Payload { optimizely_vuid?: string } /** - * The name of the Optimizely event to send + * The Optimizely Event Type. Defaults to "custom" if not provided + */ + event_type?: string + /** + * The name of the Optimizely Event Action. */ event_action: string /** diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/index.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/index.ts index 6bb77d3c2a..00b03c9d0a 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/index.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/customEvent/index.ts @@ -1,15 +1,21 @@ import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_identifiers, event_action, products, order_id, total, timestamp } from '../fields' +import { user_identifiers, event_type, products, order_id, total, timestamp } from '../fields' import { hosts } from '../utils' const action: ActionDefinition = { title: 'Ecommerce Event', - description: 'Send Segment track() events to Optimizely Data Platform', + description: 'Send Segment Ecommerce track() events to Optimizely Data Platform', fields: { user_identifiers: user_identifiers, - event_action: { ...event_action }, + event_type: { ...event_type }, + event_action: { + label: 'Optimizely Event Action', + description: 'The name of the Optimizely Event Action.', + type: 'string', + required: true + }, products: { ...products }, order_id: { ...order_id }, total: { ...total }, @@ -21,6 +27,7 @@ const action: ActionDefinition = { const body = { user_identifiers: payload.user_identifiers, action: payload.event_action, + type: payload.event_type ?? 'custom', timestamp: payload.timestamp, order_id: payload.order_id, total: payload.total, diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/__snapshots__/snapshot.test.ts.snap index 2f4520e9a1..5a05da92e7 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -5,6 +5,7 @@ Object { "action": "4v7TZkJBZ*J1n#7wW@T%", "campaign": "4v7TZkJBZ*J1n#7wW@T%", "campaign_event_value": "4v7TZkJBZ*J1n#7wW@T%", + "campaign_id": "4v7TZkJBZ*J1n#7wW@T%", "timestamp": "4v7TZkJBZ*J1n#7wW@T%", "type": "email", "user_identifiers": Object { @@ -21,6 +22,7 @@ Object { "action": "4v7TZkJBZ*J1n#7wW@T%", "campaign": "4v7TZkJBZ*J1n#7wW@T%", "campaign_event_value": null, + "campaign_id": "4v7TZkJBZ*J1n#7wW@T%", "timestamp": "4v7TZkJBZ*J1n#7wW@T%", "type": "email", "user_identifiers": Object { diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/index.test.ts index 33a46ad4f6..a739babeca 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/index.test.ts @@ -38,12 +38,20 @@ describe('OptimizelyDataPlatform.emailEvent', () => { apiKey: 'abc123', region: 'US' }, - useDefaultMappings: true + mapping: { + user_identifiers: { + anonymousId: 'anonId1234', + userId: 'user1234', + email: 'test@test.com' + }, + event_type: 'email', + event_action: 'opened', + campaign: 'opti-test-campaign', + timestamp: '2024-03-01T18:11:27.649Z' + } }) - const expectedBody = `"{\\"type\\":\\"email\\",\\"action\\":\\"Email Opened\\",\\"campaign\\":\\"opti-test-campaign\\",\\"user_identifiers\\":{\\"anonymousId\\":\\"anonId1234\\",\\"userId\\":\\"user1234\\",\\"email\\":\\"test.email@test.com\\"},\\"campaign_event_value\\":\\"https://url-from-email-clicked.com\\",\\"timestamp\\":\\"${emailEvent.timestamp}\\"}"` - expect(response[0].status).toBe(201) - expect(response[0].options.body).toMatchInlineSnapshot(expectedBody) + expect(response[0].options.body).toMatchInlineSnapshot(`"{\\"type\\":\\"email\\",\\"action\\":\\"opened\\",\\"campaign\\":\\"opti-test-campaign\\",\\"user_identifiers\\":{\\"anonymousId\\":\\"anonId1234\\",\\"userId\\":\\"user1234\\",\\"email\\":\\"test@test.com\\"},\\"campaign_event_value\\":null,\\"timestamp\\":\\"2024-03-01T18:11:27.649Z\\"}"`) }) }) diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/snapshot.test.ts index 740296081a..1537860449 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/__tests__/snapshot.test.ts @@ -21,8 +21,8 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac properties: eventData }) - settingsData['apiKey'] = 'abc123'; - settingsData['region'] = 'US'; + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' const responses = await testDestination.testAction(actionSlug, { event: event, @@ -57,8 +57,8 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac properties: eventData }) - settingsData['apiKey'] = 'abc123'; - settingsData['region'] = 'US'; + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' const responses = await testDestination.testAction(actionSlug, { event: event, diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/generated-types.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/generated-types.ts index 2243a1c659..66778c0e41 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/generated-types.ts @@ -23,17 +23,17 @@ export interface Payload { optimizely_vuid?: string } /** - * The name of the Optimizely event to send + * The name of the Optimizely Event Action. */ event_action: string - /** - * The campaign unique identifier - */ - campaign_id?: string /** * The campaign name */ campaign: string + /** + * The campaign unique identifier + */ + campaign_id?: string /** * URL of the link which was clicked */ diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/index.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/index.ts index 66f0a4f882..43b1adb677 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/index.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/emailEvent/index.ts @@ -1,22 +1,19 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { timestamp, email_action_identifiers, event_action } from '../fields' +import { timestamp, email_action_identifiers } from '../fields' import { hosts } from '../utils' const action: ActionDefinition = { title: 'Email Event', - description: 'Send Segment track() events containing email related details to Optimizely Data Platform', + description: 'Send email related Segment track() events to Optimizely Data Platform', fields: { user_identifiers: email_action_identifiers, - event_action: event_action, - campaign_id: { - label: 'Campaign ID', - description: 'The campaign unique identifier', + event_action: { + label: 'Optimizely Event Action', + description: 'The name of the Optimizely Event Action.', type: 'string', - default: { - '@path': '$.properties.campaign_id' - } + required: true }, campaign: { label: 'Campaign Name', @@ -27,6 +24,14 @@ const action: ActionDefinition = { '@path': '$.properties.campaign_name' } }, + campaign_id: { + label: 'Campaign ID', + description: 'The campaign unique identifier', + type: 'string', + default: { + '@path': '$.properties.campaign_id' + } + }, link_url: { label: 'Link URL', description: 'URL of the link which was clicked', @@ -44,6 +49,7 @@ const action: ActionDefinition = { type: 'email', action: payload.event_action, campaign: payload.campaign, + campaign_id: payload.campaign_id, user_identifiers: payload.user_identifiers, campaign_event_value: payload.link_url ?? null, timestamp: payload.timestamp diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/fields.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/fields.ts index 9509032cae..a4fc646004 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/fields.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/fields.ts @@ -1,15 +1,32 @@ import { InputField, Directive } from '@segment/actions-core/destination-kit/types' -export const event_action: InputField = { - label: 'Optimizely Event Name', - description: 'The name of the Optimizely event to send', +export const event_type: InputField = { + label: 'Optimizely Event Type', + description: 'The Optimizely Event Type. Defaults to "custom" if not provided', type: 'string', - required: true, + required: false, default: { '@path': '$.event' } } +export const event_action: InputField = { + label: 'Optimizely Event Action', + description: 'The name of the Optimizely Event Action.', + type: 'string', + required: false +} + +export const data: InputField = { + label: 'Event Properties', + description: 'Additional information to send with your custom event', + type: 'object', + required: false, + default: { + '@path': '$.properties' + } +} + export const user_identifiers: InputField = { label: 'User identifiers', description: 'User identifier details to send to Optimizely. ', diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/index.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/index.ts index a81cd1424a..893ae67b07 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/index.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/index.ts @@ -2,6 +2,7 @@ import { defaultValues } from '@segment/actions-core' import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import customEvent from './customEvent' +import nonEcommCustomEvent from './nonEcommCustomEvent' import upsertContact from './upsertContact' import emailEvent from './emailEvent' import { hosts } from './utils' @@ -70,7 +71,8 @@ const destination: DestinationDefinition = { partnerAction: 'customEvent', mapping: { ...singleProductFields, - event_action: 'product_viewed' + event_type: 'product', + event_action: 'detail' }, type: 'automatic' }, @@ -80,7 +82,8 @@ const destination: DestinationDefinition = { partnerAction: 'customEvent', mapping: { ...singleProductFields, - event_action: 'product_added_to_cart' + event_type: 'product', + event_action: 'add_to_cart' }, type: 'automatic' }, @@ -90,7 +93,8 @@ const destination: DestinationDefinition = { partnerAction: 'customEvent', mapping: { ...singleProductFields, - event_action: 'product_removed_from_cart' + event_type: 'product', + event_action: 'remove_from_cart' }, type: 'automatic' }, @@ -100,7 +104,18 @@ const destination: DestinationDefinition = { partnerAction: 'customEvent', mapping: { ...defaultValues(customEvent.fields), - event_action: 'purchase_completed' + event_type: 'order', + event_action: 'purchase' + }, + type: 'automatic' + }, + { + name: 'Email Sent', + subscribe: 'type = "track" and event = "Email Sent"', + partnerAction: 'emailEvent', + mapping: { + ...defaultValues(emailEvent.fields), + event_action: 'sent' }, type: 'automatic' }, @@ -110,7 +125,7 @@ const destination: DestinationDefinition = { partnerAction: 'emailEvent', mapping: { ...defaultValues(emailEvent.fields), - event_action: 'email_clicked' + event_action: 'click' }, type: 'automatic' }, @@ -120,7 +135,7 @@ const destination: DestinationDefinition = { partnerAction: 'emailEvent', mapping: { ...defaultValues(emailEvent.fields), - event_action: 'email_opened' + event_action: 'open' }, type: 'automatic' }, @@ -130,7 +145,7 @@ const destination: DestinationDefinition = { partnerAction: 'emailEvent', mapping: { ...defaultValues(emailEvent.fields), - event_action: 'email_unsubscribed' + event_action: 'unsubscribe' }, type: 'automatic' }, @@ -140,13 +155,14 @@ const destination: DestinationDefinition = { partnerAction: 'emailEvent', mapping: { ...defaultValues(emailEvent.fields), - event_action: 'email_marked_as_spam' + event_action: 'spam_report' }, type: 'automatic' } ], actions: { customEvent, + nonEcommCustomEvent, upsertContact, emailEvent } diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..04dce0f4d6 --- /dev/null +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for OptimizelyDataPlatform's customEvent destination action: all fields 1`] = ` +Object { + "action": "FX3MHiX9P^tIkXKVCa", + "order_id": "FX3MHiX9P^tIkXKVCa", + "products": Array [ + Object { + "product_id": "FX3MHiX9P^tIkXKVCa", + "qty": 66713950523228.16, + }, + ], + "timestamp": "FX3MHiX9P^tIkXKVCa", + "total": "FX3MHiX9P^tIkXKVCa", + "type": "FX3MHiX9P^tIkXKVCa", + "user_identifiers": Object { + "anonymousId": "FX3MHiX9P^tIkXKVCa", + "email": "oriiwo@hovevut.bn", + "optimizely_vuid": "FX3MHiX9P^tIkXKVCa", + "userId": "FX3MHiX9P^tIkXKVCa", + }, +} +`; + +exports[`Testing snapshot for OptimizelyDataPlatform's customEvent destination action: required fields 1`] = ` +Object { + "action": "FX3MHiX9P^tIkXKVCa", + "order_id": "FX3MHiX9P^tIkXKVCa", + "timestamp": "FX3MHiX9P^tIkXKVCa", + "type": "custom", + "user_identifiers": Object {}, +} +`; diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..ffc2303d85 --- /dev/null +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/index.test.ts @@ -0,0 +1,46 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const productEvent = createTestEvent({ + type: 'track', + event: 'custom', + timestamp: '2024-02-09T15:30:51.046Z', + properties: { + custom_field: 'hello', + custom_field_num: 12345 + } +}) + +describe('OptimizelyDataPlatform.nonEcommCustomEvent', () => { + it('Should fire non ecomm custom event', async () => { + nock('https://function.zaius.app/twilio_segment').post('/custom_event').reply(201, {}) + + const response = await testDestination.testAction('nonEcommCustomEvent', { + event: productEvent, + settings: { + apiKey: 'abc123', + region: 'US' + }, + mapping: { + user_identifiers: { + anonymousId: 'anonId1234', + userId: 'user1234' + }, + event_type: 'custom', + event_action: 'custom', + timestamp: '2024-02-09T15:30:51.046Z', + data: { + custom_field: 'hello', + custom_field_num: 12345 + } + } + }) + + expect(response[0].status).toBe(201) + expect(response[0].options.body).toMatchInlineSnapshot( + `"{\\"user_identifiers\\":{\\"anonymousId\\":\\"anonId1234\\",\\"userId\\":\\"user1234\\"},\\"action\\":\\"custom\\",\\"type\\":\\"custom\\",\\"timestamp\\":\\"2024-02-09T15:30:51.046Z\\",\\"data\\":{\\"custom_field\\":\\"hello\\",\\"custom_field_num\\":12345}}"` + ) + }) +}) diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..2e3b243f31 --- /dev/null +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/__tests__/snapshot.test.ts @@ -0,0 +1,81 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'customEvent' +const destinationSlug = 'OptimizelyDataPlatform' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/generated-types.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/generated-types.ts new file mode 100644 index 0000000000..778994aaae --- /dev/null +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/generated-types.ts @@ -0,0 +1,43 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * User identifier details to send to Optimizely. + */ + user_identifiers: { + /** + * Segment Anonymous ID + */ + anonymousId?: string + /** + * Segment User ID + */ + userId?: string + /** + * User Email address + */ + email?: string + /** + * Optimizely VUID - user cookie generated created by Optimizely Javascript library + */ + optimizely_vuid?: string + } + /** + * The Optimizely Event Type. Defaults to "custom" if not provided + */ + event_type?: string + /** + * The name of the Optimizely Event Action. + */ + event_action?: string + /** + * Additional information to send with your custom event + */ + data?: { + [k: string]: unknown + } + /** + * Event timestamp + */ + timestamp: string +} diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/index.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/index.ts new file mode 100644 index 0000000000..445dac138c --- /dev/null +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/nonEcommCustomEvent/index.ts @@ -0,0 +1,35 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { user_identifiers, event_type, event_action, data, timestamp } from '../fields' +import { hosts } from '../utils' + +const action: ActionDefinition = { + title: 'Custom Event', + description: 'Send Segment custom track() events to Optimizely Data Platform', + fields: { + user_identifiers: user_identifiers, + event_type: { ...event_type }, + event_action: { ...event_action }, + data: { ...data }, + timestamp: { ...timestamp } + }, + perform: (request, { payload, settings }) => { + const host = hosts[settings.region] + + const body = { + user_identifiers: payload.user_identifiers, + action: payload.event_action, + type: payload.event_type ?? 'custom', + timestamp: payload.timestamp, + data: payload.data + } + + return request(`${host}/custom_event`, { + method: 'post', + json: body + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/__snapshots__/snapshot.test.ts.snap index 2a66744629..ce0b7bd4c1 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/__snapshots__/snapshot.test.ts.snap @@ -11,13 +11,16 @@ Object { }, "age": 10705663206359.04, "company": "uMyg@6QjI31r!", - "dob": "2021-02-01T00:00:00.000Z", + "dob_day": 1, + "dob_month": 2, + "dob_year": 2021, "first_name": "uMyg@6QjI31r!", "gender": "uMyg@6QjI31r!", "image_url": "uMyg@6QjI31r!", "last_name": "uMyg@6QjI31r!", "name": "uMyg@6QjI31r!", "phone": "uMyg@6QjI31r!", + "testType": "uMyg@6QjI31r!", "title": "uMyg@6QjI31r!", "user_identifiers": Object { "anonymousId": "uMyg@6QjI31r!", diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/index.test.ts index 5874606c65..80a2d9d7f2 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/index.test.ts @@ -37,12 +37,27 @@ describe('OptimizelyDataPlatform.upsertContact', () => { apiKey: 'abc123', region: 'US' }, - useDefaultMappings: true + mapping: { + user_identifiers: { + anonymousId: 'anonId1234', + userId: 'user1234', + email: 'test@test.com' + }, + title: 'Mr', + name: 'John Doe', + first_name: 'John', + last_name: 'Doe', + age: 50, + dob_year: 1990, + dob_month: 1, + dob_day: 1 + } }) - const expectedBody = `"{\\"user_identifiers\\":{\\"anonymousId\\":\\"anonId1234\\",\\"userId\\":\\"user1234\\",\\"email\\":\\"test.email@test.com\\"},\\"title\\":\\"Mr\\",\\"name\\":\\"John Doe\\",\\"first_name\\":\\"John\\",\\"last_name\\":\\"Doe\\",\\"age\\":50,\\"dob\\":\\"01/01/1990\\",\\"gender\\":\\"male\\",\\"phone\\":\\"1234567890\\",\\"address\\":{\\"street\\":\\"Victoria st\\",\\"city\\":\\"London\\",\\"state\\":\\"London\\",\\"country\\":\\"UK\\"},\\"company\\":\\"Optimizely\\",\\"image_url\\":\\"https://image-url.com\\"}"`; - - expect(response[0].status).toBe(201); - expect(response[0].options.body).toMatchInlineSnapshot(expectedBody) + expect(response[0].status).toBe(201) + // The expected body is a stringified JSON object + expect(response[0].options.body).toMatchInlineSnapshot( + `"{\\"user_identifiers\\":{\\"anonymousId\\":\\"anonId1234\\",\\"userId\\":\\"user1234\\",\\"email\\":\\"test@test.com\\"},\\"title\\":\\"Mr\\",\\"name\\":\\"John Doe\\",\\"age\\":50}"` + ) }) }) diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/snapshot.test.ts index c570c53896..b75f60da83 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/__tests__/snapshot.test.ts @@ -21,8 +21,8 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac properties: eventData }) - settingsData['apiKey'] = 'abc123'; - settingsData['region'] = 'US'; + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' const responses = await testDestination.testAction(actionSlug, { event: event, @@ -57,9 +57,9 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac properties: eventData }) - settingsData['apiKey'] = 'abc123'; - settingsData['region'] = 'US'; - + settingsData['apiKey'] = 'abc123' + settingsData['region'] = 'US' + const responses = await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/generated-types.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/generated-types.ts index 9770e7ce1d..93bf3e1f21 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/generated-types.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/generated-types.ts @@ -87,4 +87,10 @@ export interface Payload { * The user's avatar image URL. */ avatar?: string + /** + * Additional user profile details + */ + additional_traits?: { + [k: string]: unknown + } } diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/index.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/index.ts index e6798ead21..cc78530ba1 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/index.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/upsertContact/index.ts @@ -2,7 +2,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { user_identifiers } from '../fields' -import { hosts } from '../utils' +import { hosts, getDOBDetails } from '../utils' const action: ActionDefinition = { title: 'Upsert Contact', @@ -109,19 +109,26 @@ const action: ActionDefinition = { type: 'string', description: "The user's avatar image URL.", default: { '@path': '$.traits.avatar' } + }, + additional_traits: { + label: 'Addition User Traits', + type: 'object', + defaultObjectUI: 'keyvalue', + description: "Additional user profile details" } }, perform: (request, { payload, settings }) => { const host = hosts[settings.region] const body = { + ...payload.additional_traits, user_identifiers: payload.user_identifiers, title: payload.title, name: payload.name, first_name: payload.firstname, last_name: payload.lastname, age: payload.age, - dob: payload.DOB, + ...getDOBDetails(payload.DOB), gender: payload.gender, phone: payload.phone, address: payload.address, diff --git a/packages/destination-actions/src/destinations/optimizely-data-platform/utils.ts b/packages/destination-actions/src/destinations/optimizely-data-platform/utils.ts index 14162977a8..ae610bb0eb 100644 --- a/packages/destination-actions/src/destinations/optimizely-data-platform/utils.ts +++ b/packages/destination-actions/src/destinations/optimizely-data-platform/utils.ts @@ -3,3 +3,20 @@ export const hosts: { [key: string]: string } = { EU: 'https://function.eu1.ocp.optimizely.com/twilio_segment', AU: 'https://function.au1.ocp.optimizely.com/twilio_segment' } + +export const getDOBDetails = (dob: string | null | number | undefined) => { + if ( dob === undefined || dob === null || dob === '') { + return undefined + } + + const date = new Date(dob) + + if(isNaN(date.getTime())) { + return undefined + } + + return { + dob_year: date.getFullYear(), dob_month: date.getMonth() + 1, dob_day: date.getDate() + } +} + diff --git a/packages/destination-actions/src/destinations/optimizely-feature-experimentation-actions/trackEvent/index.ts b/packages/destination-actions/src/destinations/optimizely-feature-experimentation-actions/trackEvent/index.ts index 4dd13db520..61509c7b80 100644 --- a/packages/destination-actions/src/destinations/optimizely-feature-experimentation-actions/trackEvent/index.ts +++ b/packages/destination-actions/src/destinations/optimizely-feature-experimentation-actions/trackEvent/index.ts @@ -93,7 +93,8 @@ const action: ActionDefinition = { }, uuid: { label: 'Unique ID', - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'Unique ID for the event', required: true, default: { diff --git a/packages/destination-actions/src/destinations/outfunnel/forwardGroupEvent/index.ts b/packages/destination-actions/src/destinations/outfunnel/forwardGroupEvent/index.ts index 86b98d7dbb..f9c2d827de 100644 --- a/packages/destination-actions/src/destinations/outfunnel/forwardGroupEvent/index.ts +++ b/packages/destination-actions/src/destinations/outfunnel/forwardGroupEvent/index.ts @@ -1,7 +1,7 @@ -import { ActionDefinition } from '@segment/actions-core'; -import type { Settings } from '../generated-types'; -import type { Payload } from './generated-types'; -import { getEndpoint } from '../utils'; +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getEndpoint } from '../utils' const action: ActionDefinition = { title: 'Forward group event', @@ -9,14 +9,16 @@ const action: ActionDefinition = { defaultSubscription: 'type = "group"', fields: { action: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, description: 'Indicates which action was triggered', label: 'Action name', default: 'group' }, user_id: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'The identifier of the user', label: 'User ID', default: { @@ -24,7 +26,8 @@ const action: ActionDefinition = { } }, anonymous_id: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'Anonymous ID of the user', label: 'Anonymous ID', default: { @@ -32,7 +35,8 @@ const action: ActionDefinition = { } }, group_id: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'ID of the group', label: 'Group ID', default: { @@ -48,7 +52,8 @@ const action: ActionDefinition = { } }, timestamp: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, description: 'The time the event occured in UTC', label: 'Event timestamp', @@ -75,12 +80,12 @@ const action: ActionDefinition = { }, perform: async (request, { settings, payload }) => { - const endpoint = getEndpoint(settings.userId); + const endpoint = getEndpoint(settings.userId) return request(endpoint, { method: 'POST', json: payload - }); + }) } } diff --git a/packages/destination-actions/src/destinations/outfunnel/forwardIdentifyEvent/index.ts b/packages/destination-actions/src/destinations/outfunnel/forwardIdentifyEvent/index.ts index 74a529dd1a..ccb18d842d 100644 --- a/packages/destination-actions/src/destinations/outfunnel/forwardIdentifyEvent/index.ts +++ b/packages/destination-actions/src/destinations/outfunnel/forwardIdentifyEvent/index.ts @@ -1,7 +1,7 @@ -import { ActionDefinition } from '@segment/actions-core'; -import type { Settings } from '../generated-types'; -import type { Payload } from './generated-types'; -import { getEndpoint } from '../utils'; +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getEndpoint } from '../utils' const action: ActionDefinition = { title: 'Forward identify event', @@ -9,14 +9,16 @@ const action: ActionDefinition = { defaultSubscription: 'type = "identify"', fields: { action: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, description: 'Indicates which action was triggered', label: 'Action name', default: 'identify' }, user_id: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'The identifier of the user', label: 'User ID', default: { @@ -24,7 +26,8 @@ const action: ActionDefinition = { } }, anonymous_id: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'Anonymous ID of the user', label: 'Anonymous ID', default: { @@ -41,7 +44,8 @@ const action: ActionDefinition = { } }, timestamp: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, description: 'The time the event occured in UTC', label: 'Event timestamp', @@ -68,12 +72,12 @@ const action: ActionDefinition = { }, perform: async (request, { settings, payload }) => { - const endpoint = getEndpoint(settings.userId); + const endpoint = getEndpoint(settings.userId) return request(endpoint, { method: 'POST', json: payload - }); + }) } } diff --git a/packages/destination-actions/src/destinations/outfunnel/forwardTrackEvent/index.ts b/packages/destination-actions/src/destinations/outfunnel/forwardTrackEvent/index.ts index 84e2d4d7c8..c6d837d01c 100644 --- a/packages/destination-actions/src/destinations/outfunnel/forwardTrackEvent/index.ts +++ b/packages/destination-actions/src/destinations/outfunnel/forwardTrackEvent/index.ts @@ -1,7 +1,7 @@ -import { ActionDefinition } from '@segment/actions-core'; -import type { Settings } from '../generated-types'; -import type { Payload } from './generated-types'; -import { getEndpoint } from '../utils'; +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { getEndpoint } from '../utils' const action: ActionDefinition = { title: 'Forward track event', @@ -9,7 +9,8 @@ const action: ActionDefinition = { defaultSubscription: 'type = "track"', fields: { action: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, description: 'Indicates which action was triggered', label: 'Action name', @@ -25,7 +26,8 @@ const action: ActionDefinition = { } }, user_id: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'The identifier of the user who performed the event', label: 'User ID', default: { @@ -33,7 +35,8 @@ const action: ActionDefinition = { } }, anonymous_id: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'Anonymous ID of the user', label: 'Anonymous ID', default: { @@ -61,7 +64,8 @@ const action: ActionDefinition = { } }, timestamp: { - type: 'hidden', + type: 'string', + unsafe_hidden: true, required: true, description: 'The time the event occured in UTC', label: 'Event timestamp', @@ -87,7 +91,7 @@ const action: ActionDefinition = { } }, perform: async (request, { settings, payload }) => { - const endpoint = getEndpoint(settings.userId); + const endpoint = getEndpoint(settings.userId) return request(endpoint, { method: 'POST', @@ -96,4 +100,4 @@ const action: ActionDefinition = { } } -export default action; +export default action diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts b/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts index 260980e804..59ded2fde9 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/pinterest-capi-custom-data.ts @@ -48,7 +48,7 @@ export const custom_data_field: InputField = { num_items: { label: 'Number of Items', description: 'Total number of products in the event. ', - type: 'string' + type: 'integer' }, order_id: { label: 'Order ID', diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap index 78707fd8a5..fa3dca3bef 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/__snapshots__/index.test.ts.snap @@ -12,11 +12,11 @@ Object { "content_ids": undefined, "contents": undefined, "currency": undefined, - "num_items": undefined, + "num_items": 2, "opt_out_type": undefined, "order_id": undefined, "search_string": undefined, - "value": "undefined", + "value": "2000", }, "device_brand": undefined, "device_carrier": undefined, diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts index 0796e29183..7487051e98 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/__tests__/index.test.ts @@ -111,6 +111,10 @@ describe('PinterestConversionApi', () => { client_user_agent: '5.5.5.5', client_ip_address: 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + }, + custom_data: { + num_items: '2', + value: 2000 } } }) diff --git a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts index bfc6dc06a6..605e89d920 100644 --- a/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts +++ b/packages/destination-actions/src/destinations/pinterest-conversions/reportConversionEvent/generated-types.ts @@ -122,7 +122,7 @@ export interface Payload { /** * Total number of products in the event. */ - num_items?: string + num_items?: number /** * Order ID */ diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/generated-types.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/generated-types.ts index c23ed4202f..5d761f2b91 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/generated-types.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/generated-types.ts @@ -58,9 +58,9 @@ export interface Payload { */ lost_reason?: string /** - * Visibility of the deal. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 1 -Owner & followers (private), 3 - Entire company (shared) + * Visibility of the deal. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans */ - visible_to?: number + visible_to?: string /** * If the deal is created, use this timestamp as the creation timestamp. Format: YYY-MM-DD HH:MM:SS */ diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/index.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/index.ts index b9505d71e1..527f2da8f5 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/index.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdateDeal/index.ts @@ -142,11 +142,13 @@ const action: ActionDefinition = { visible_to: { label: 'Visible To', description: - 'Visibility of the deal. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 1 -Owner & followers (private), 3 - Entire company (shared)', - type: 'integer', + "Visibility of the deal. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans", + type: 'string', choices: [ - { label: 'Owner & followers (private)', value: 1 }, - { label: 'Entire company (shared)', value: 3 } + { label: 'Owner & followers (private)', value: '1' }, + { label: 'Entire company (shared)', value: '3' }, + { label: "Owner's visibility group and sub-groups", value: '5' }, + { label: 'Entire company', value: '7' } ], required: false }, diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/generated-types.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/generated-types.ts index d4f0e16e8b..3a0f1958b8 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/generated-types.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/generated-types.ts @@ -38,11 +38,7 @@ export interface Payload { */ expected_close_date?: string /** - * Visibility of the Lead. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. + * Visibility of the Lead. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans */ - visible_to?: number - /** - * If the lead is created, use this timestamp as the creation timestamp. Format: YYY-MM-DD HH:MM:SS - */ - add_time?: string | number + visible_to?: string } diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/index.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/index.ts index e8f5757dc3..60ae9f0b38 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/index.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdateLead/index.ts @@ -113,19 +113,15 @@ const action: ActionDefinition = { visible_to: { label: 'Visible To', description: - 'Visibility of the Lead. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user.', - type: 'integer', + "Visibility of the Lead. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans", + type: 'string', choices: [ - { label: 'Owner & followers (private)', value: 1 }, - { label: 'Entire company (shared)', value: 3 } + { label: 'Owner & followers (private)', value: '1' }, + { label: 'Entire company (shared)', value: '3' }, + { label: "Owner's visibility group and sub-groups", value: '5' }, + { label: 'Entire company', value: '7' } ], required: false - }, - add_time: { - label: 'Created At', - description: 'If the lead is created, use this timestamp as the creation timestamp. Format: YYY-MM-DD HH:MM:SS', - type: 'datetime', - required: false } }, @@ -156,8 +152,7 @@ const action: ActionDefinition = { visible_to: payload.visible_to, person_id: personId || undefined, organization_id: organizationId || undefined, - value: payload.amount && payload.currency ? leadValue : undefined, - add_time: payload.add_time ? `${payload.add_time}` : undefined + value: payload.amount && payload.currency ? leadValue : undefined } if (!lead.id && !lead.person_id && !lead.organization_id) { diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/generated-types.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/generated-types.ts index 8d05997e1f..43dac9adce 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/generated-types.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/generated-types.ts @@ -14,9 +14,9 @@ export interface Payload { */ name?: string /** - * Visibility of the Organization. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. + * Visibility of the Organization. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans */ - visible_to?: number + visible_to?: string /** * If the organization is created, use this timestamp as the creation timestamp. Format: YYY-MM-DD HH:MM:SS */ diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/index.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/index.ts index 13b87beab7..38282cc2dc 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/index.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdateOrganization/index.ts @@ -40,11 +40,13 @@ const action: ActionDefinition = { visible_to: { label: 'Visible To', description: - 'Visibility of the Organization. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user.', - type: 'integer', + "Visibility of the Organization. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans", + type: 'string', choices: [ - { label: 'Owner & followers (private)', value: 1 }, - { label: 'Entire company (shared)', value: 3 } + { label: 'Owner & followers (private)', value: '1' }, + { label: 'Entire company (shared)', value: '3' }, + { label: "Owner's visibility group and sub-groups", value: '5' }, + { label: 'Entire company', value: '7' } ], required: false }, diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/generated-types.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/generated-types.ts index 0d343ba9b9..36e9cb4080 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/generated-types.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/generated-types.ts @@ -22,9 +22,9 @@ export interface Payload { */ phone?: string[] /** - * Visibility of the Person. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. + * Visibility of the Person. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans */ - visible_to?: number + visible_to?: string /** * If the person is created, use this timestamp as the creation timestamp. Format: YYY-MM-DD HH:MM:SS */ diff --git a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/index.ts b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/index.ts index e832bf4421..08cfa03166 100644 --- a/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/index.ts +++ b/packages/destination-actions/src/destinations/pipedrive/createUpdatePerson/index.ts @@ -60,11 +60,13 @@ const action: ActionDefinition = { visible_to: { label: 'Visible To', description: - 'Visibility of the Person. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user.', - type: 'integer', + "Visibility of the Person. If omitted, visibility will be set to the default visibility setting of this item type for the authorized user. 'Owner's visibility group and sub-groups' and 'Entire company' options only available with Professional or Enterprise plans", + type: 'string', choices: [ - { label: 'Owner & followers (private)', value: 1 }, - { label: 'Entire company (shared)', value: 3 } + { label: 'Owner & followers (private)', value: '1' }, + { label: 'Entire company (shared)', value: '3' }, + { label: "Owner's visibility group and sub-groups", value: '5' }, + { label: 'Entire company', value: '7' } ], required: false }, diff --git a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/deals.ts b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/deals.ts index d2a913f076..a027ba8704 100644 --- a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/deals.ts +++ b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/deals.ts @@ -11,7 +11,7 @@ export interface Deal extends Record { expected_close_date?: string probability?: number lost_reason?: string - visible_to?: number + visible_to?: string add_time?: string id?: number diff --git a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/leads.ts b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/leads.ts index 48b6e4cd50..0d9e4bdfc4 100644 --- a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/leads.ts +++ b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/leads.ts @@ -5,12 +5,11 @@ export interface Lead extends Record { title: string expected_close_date?: string - visible_to?: number + visible_to?: string id?: string person_id?: number organization_id?: number - add_time?: string } export type LeadValue = { diff --git a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/organizations.ts b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/organizations.ts index 8f8de0893e..6e75c808e7 100644 --- a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/organizations.ts +++ b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/organizations.ts @@ -4,7 +4,7 @@ import type { RequestClient } from '@segment/actions-core' export interface Organization { name?: string add_time?: string - visible_to?: number + visible_to?: string } export async function createOrUpdateOrganizationById( diff --git a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/persons.ts b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/persons.ts index 884b4de348..7fe9471ba1 100644 --- a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/persons.ts +++ b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/persons.ts @@ -6,7 +6,7 @@ export interface Person { email?: string[] phone?: string[] add_time?: string - visible_to?: number + visible_to?: string } export async function createOrUpdatePersonById( diff --git a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts index dbfee624ab..b15847d899 100644 --- a/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts +++ b/packages/destination-actions/src/destinations/pipedrive/pipedriveApi/pipedrive-client.ts @@ -90,7 +90,11 @@ class PipedriveClient { return cachedFields } const response = await this._request( - `https://${this.settings.domain}.pipedrive.com/api/v1/${pipedriveFieldMap[item]}` + `https://${this.settings.domain}.pipedrive.com/api/v1/${pipedriveFieldMap[item]}`, + { + method: 'GET', + skipResponseCloning: true + } ) const body = response.data const fields = body.data.map((f) => ({ diff --git a/packages/destination-actions/src/destinations/responsys/__tests__/index.test.ts b/packages/destination-actions/src/destinations/responsys/__tests__/index.test.ts new file mode 100644 index 0000000000..802df42730 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/__tests__/index.test.ts @@ -0,0 +1,25 @@ +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { Settings } from '../generated-types' + +const testDestination = createTestIntegration(Definition) + +describe('Responsys', () => { + describe('testAuthentication', () => { + it('should validate settings correctly', async () => { + const settings: Settings = { + segmentWriteKey: 'testKey', + username: 'testUser', + userPassword: 'testPassword', + baseUrl: 'https://example.com', + profileListName: 'TESTLIST', + insertOnNoMatch: true, + matchColumnName1: 'EMAIL_ADDRESS', + updateOnMatch: 'REPLACE_ALL', + defaultPermissionStatus: 'OPTOUT' + } + + await expect(testDestination.testAuthentication(settings)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/responsys/generated-types.ts b/packages/destination-actions/src/destinations/responsys/generated-types.ts new file mode 100644 index 0000000000..78f9308fcb --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/generated-types.ts @@ -0,0 +1,76 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Optionally forward Responses from Segment's requests to Responsys to a Segment Source. + */ + segmentWriteKey?: string + /** + * Segment Region to forward responses from Responsys to. Segment Source WriteKey must also be populated + */ + segmentWriteKeyRegion?: string + /** + * Responsys username + */ + username: string + /** + * Responsys password + */ + userPassword: string + /** + * Responsys endpoint URL. Refer to Responsys documentation for more details. Must start with 'HTTPS://'. See [Responsys docs](https://docs.oracle.com/en/cloud/saas/marketing/responsys-develop/API/GetStarted/Authentication/auth-endpoints-rest.htm). + */ + baseUrl: string + /** + * Name of the Profile Extension Table's Contact List. + */ + profileListName: string + /** + * Profile Extension Table (PET) Name. Required if using the "Send Custom Traits" Action. + */ + profileExtensionTable?: string + /** + * Indicates what should be done for records where a match is not found. + */ + insertOnNoMatch: boolean + /** + * First match column for determining whether an insert or update should occur. + */ + matchColumnName1: string + /** + * Second match column for determining whether an insert or update should occur. + */ + matchColumnName2?: string + /** + * Controls how the existing record should be updated. Defaults to Replace All. + */ + updateOnMatch: string + /** + * Value of incoming preferred email format data. For example, 'T' may represent a preference for Text formatted email. + */ + textValue?: string + /** + * Operator to join match column names. + */ + matchOperator?: string + /** + * Value of incoming opt-out status data that represents an optout status. For example, 'O' may represent an opt-out status. + */ + optoutValue?: string + /** + * String containing comma-separated channel codes that if specified will result in record rejection when the channel address field is null. See [Responsys API docs](https://docs.oracle.com/en/cloud/saas/marketing/responsys-rest-api/op-rest-api-v1.3-lists-listname-members-post.html) + */ + rejectRecordIfChannelEmpty?: string + /** + * This value must be specified as either OPTIN or OPTOUT. defaults to OPTOUT. + */ + defaultPermissionStatus: string + /** + * Value of incoming preferred email format data. For example, 'H' may represent a preference for HTML formatted email. + */ + htmlValue?: string + /** + * Value of incoming opt-in status data that represents an opt-in status. For example, 'I' may represent an opt-in status. + */ + optinValue?: string +} diff --git a/packages/destination-actions/src/destinations/responsys/index.ts b/packages/destination-actions/src/destinations/responsys/index.ts new file mode 100644 index 0000000000..1217391a82 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/index.ts @@ -0,0 +1,205 @@ +import { DestinationDefinition, IntegrationError } from '@segment/actions-core' +import type { Settings } from './generated-types' +import sendCustomTraits from './sendCustomTraits' +import sendAudience from './sendAudience' +import upsertListMember from './upsertListMember' + +interface RefreshTokenResponse { + authToken: string +} + +const destination: DestinationDefinition = { + name: 'Responsys (Actions)', + slug: 'actions-responsys', + mode: 'cloud', + description: 'Send Profile List Member and Profile Extension Table data to Responsys.', + authentication: { + scheme: 'oauth2', + fields: { + segmentWriteKey: { + label: 'Segment Source WriteKey', + description: "Optionally forward Responses from Segment's requests to Responsys to a Segment Source.", + type: 'string', + required: false + }, + segmentWriteKeyRegion: { + label: 'Segment WriteKey Region', + description: + 'Segment Region to forward responses from Responsys to. Segment Source WriteKey must also be populated', + type: 'string', + choices: [ + { label: 'US', value: 'US' }, + { label: 'EU', value: 'EU' } + ], + required: false, + default: 'US' + }, + username: { + label: 'Username', + description: 'Responsys username', + type: 'string', + required: true + }, + userPassword: { + label: 'Password', + description: 'Responsys password', + type: 'string', + required: true + }, + baseUrl: { + label: 'Responsys endpoint URL', + description: + "Responsys endpoint URL. Refer to Responsys documentation for more details. Must start with 'HTTPS://'. See [Responsys docs](https://docs.oracle.com/en/cloud/saas/marketing/responsys-develop/API/GetStarted/Authentication/auth-endpoints-rest.htm).", + type: 'string', + format: 'uri', + required: true + }, + profileListName: { + label: 'List Name', + description: "Name of the Profile Extension Table's Contact List.", + type: 'string', + required: true + }, + profileExtensionTable: { + label: 'PET Name', + description: 'Profile Extension Table (PET) Name. Required if using the "Send Custom Traits" Action.', + type: 'string', + required: false + }, + insertOnNoMatch: { + label: 'Insert On No Match', + description: 'Indicates what should be done for records where a match is not found.', + type: 'boolean', + default: true, + required: true + }, + matchColumnName1: { + label: 'First Column Match', + description: 'First match column for determining whether an insert or update should occur.', + type: 'string', + choices: [ + { label: 'RIID', value: 'RIID' }, + { label: 'CUSTOMER_ID', value: 'CUSTOMER_ID' }, + { label: 'EMAIL_ADDRESS', value: 'EMAIL_ADDRESS' }, + { label: 'MOBILE_NUMBER', value: 'MOBILE_NUMBER' }, + { label: 'EMAIL_MD5_HASH', value: 'EMAIL_MD5_HASH' }, + { label: 'EMAIL_SHA256_HASH', value: 'EMAIL_SHA256_HASH' } + ], + default: 'EMAIL_ADDRESS', + required: true + }, + matchColumnName2: { + label: 'Second Column Match', + description: 'Second match column for determining whether an insert or update should occur.', + type: 'string', + choices: [ + { label: 'RIID', value: 'RIID' }, + { label: 'CUSTOMER_ID', value: 'CUSTOMER_ID' }, + { label: 'EMAIL_ADDRESS', value: 'EMAIL_ADDRESS' }, + { label: 'MOBILE_NUMBER', value: 'MOBILE_NUMBER' }, + { label: 'EMAIL_MD5_HASH', value: 'EMAIL_MD5_HASH' }, + { label: 'EMAIL_SHA256_HASH', value: 'EMAIL_SHA256_HASH' } + ] + }, + updateOnMatch: { + label: 'Update On Match', + description: 'Controls how the existing record should be updated. Defaults to Replace All.', + type: 'string', + required: true, + choices: [ + { label: 'Replace All', value: 'REPLACE_ALL' }, + { label: 'No Update', value: 'NO_UPDATE' } + ], + default: 'REPLACE_ALL' + }, + textValue: { + label: 'Text Value', + description: + "Value of incoming preferred email format data. For example, 'T' may represent a preference for Text formatted email.", + type: 'string' + }, + matchOperator: { + label: 'Match Operator', + description: 'Operator to join match column names.', + type: 'string', + choices: [ + { label: 'None', value: 'NONE' }, + { label: 'And', value: 'AND' } + ], + default: 'AND' + }, + optoutValue: { + label: 'Optout Value', + description: + "Value of incoming opt-out status data that represents an optout status. For example, 'O' may represent an opt-out status.", + type: 'string' + }, + rejectRecordIfChannelEmpty: { + label: 'Reject Record If Channel Empty', + description: + 'String containing comma-separated channel codes that if specified will result in record rejection when the channel address field is null. See [Responsys API docs](https://docs.oracle.com/en/cloud/saas/marketing/responsys-rest-api/op-rest-api-v1.3-lists-listname-members-post.html)', + type: 'string' + }, + defaultPermissionStatus: { + label: 'Default Permission Status', + description: 'This value must be specified as either OPTIN or OPTOUT. defaults to OPTOUT.', + type: 'string', + required: true, + choices: [ + { label: 'Opt In', value: 'OPTIN' }, + { label: 'Opt Out', value: 'OPTOUT' } + ], + default: 'OPTOUT' + }, + htmlValue: { + label: 'Preferred Email Format', + description: + "Value of incoming preferred email format data. For example, 'H' may represent a preference for HTML formatted email.", + type: 'string' + }, + optinValue: { + label: 'Optin Value', + description: + "Value of incoming opt-in status data that represents an opt-in status. For example, 'I' may represent an opt-in status.", + type: 'string' + } + }, + testAuthentication: (_, { settings }) => { + if (settings.baseUrl.startsWith('https://'.toLowerCase())) { + return Promise.resolve('Success') + } else { + throw new IntegrationError('Responsys endpoint URL must start with https://', 'INVALID_URL', 400) + } + }, + refreshAccessToken: async (request, { settings }) => { + const baseUrl = settings.baseUrl?.replace(/\/$/, '') + const endpoint = `${baseUrl}/rest/api/v1.3/auth/token` + + const res = await request(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: `user_name=${encodeURIComponent(settings.username)}&password=${encodeURIComponent( + settings.userPassword + )}&auth_type=password` + }) + return { accessToken: res.data.authToken } + } + }, + extendRequest({ auth }) { + return { + headers: { + 'Content-Type': 'application/json', + authorization: `${auth?.accessToken}` + } + } + }, + actions: { + sendAudience, + sendCustomTraits, + upsertListMember + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/responsys/sendAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/responsys/sendAudience/__tests__/index.test.ts new file mode 100644 index 0000000000..a6ed3af600 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/sendAudience/__tests__/index.test.ts @@ -0,0 +1,168 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { Settings } from '../../generated-types' + +const testDestination = createTestIntegration(Destination) +const actionSlug = 'sendAudience' +const testSettings: Settings = { + profileListName: 'ABCD', + profileExtensionTable: 'EFGH', + username: 'abcd', + userPassword: 'abcd', + baseUrl: 'https://njp1q7u-api.responsys.ocs.oraclecloud.com', + insertOnNoMatch: false, + matchColumnName1: 'EMAIL_ADDRESS_', + updateOnMatch: 'REPLACE_ALL', + defaultPermissionStatus: 'OPTOUT' +} +const AUDIENCE_ID = 'aud_12345' // References context.personas.computation_id +const AUDIENCE_KEY = 'test_key' // References context.personas.computation_key +describe('Responsys.sendAudience', () => { + const OLD_ENV = process.env + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + process.env = { ...OLD_ENV } // Make a copy + }) + + afterAll(() => { + process.env = OLD_ENV // Restore old environment + }) + it('should send audience data to Responsys with default mapping', async () => { + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post(`/rest/asyncApi/v1.3/lists/ABCD/listExtensions/EFGH/members`) + .reply(202) + + const event = createTestEvent({ + context: { + personas: { + computation_id: AUDIENCE_ID, + computation_key: AUDIENCE_KEY, + computation_class: 'audience' + } + }, + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false, + email: 'martin@martechawesome.biz' + }, + type: 'identify', + userId: '6789013' + }) + + const responses = await testDestination.testAction(actionSlug, { + event, + settings: testSettings, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(202) + expect(JSON.parse(responses[0]?.options?.body as string)).toMatchObject({ + insertOnNoMatch: false, + matchColumnName1: 'EMAIL_ADDRESS_', + matchColumnName2: '', + recordData: { + fieldNames: ['EMAIL_ADDRESS_', 'CUSTOMER_ID_', 'TEST_KEY'], + mapTemplateName: '', + records: [['martin@martechawesome.biz', '6789013', false]] + }, + updateOnMatch: 'REPLACE_ALL' + }) + }) + + describe('Failure cases', () => { + it('should throw an error if audience event missing mandatory "computation_class" field', async () => { + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post( + `/rest/asyncApi/v1.3/lists/${testSettings.profileListName}/listExtensions/${testSettings.profileExtensionTable}/members` + ) + .reply(400) + const bad_event = createTestEvent({ + context: { + personas: { + computation_id: AUDIENCE_ID, + computation_key: AUDIENCE_KEY + } + }, + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false, + email: 'martin@martechawesome.biz' + }, + type: 'identify', + userId: '6789013' + }) + await expect( + testDestination.testAction('sendAudience', { + event: bad_event, + useDefaultMappings: true + }) + ).rejects.toThrowError("The root value is missing the required field 'computation_class'") + }) + + it('should throw an error if audience key does not match traits object', async () => { + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post( + `/rest/asyncApi/v1.3/lists/${testSettings.profileListName}/listExtensions/${testSettings.profileExtensionTable}/members` + ) + .reply(400) + const bad_event = createTestEvent({ + context: { + personas: { + computation_id: AUDIENCE_ID, + computation_key: AUDIENCE_KEY, + computation_class: 'audience' + } + }, + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false, + email: 'martin@martechawesome.biz' + }, + type: 'identify', + userId: '6789013' + }) + await expect( + testDestination.testAction('sendAudience', { + event: bad_event, + useDefaultMappings: true + }) + ).rejects.toThrow() + }) + + it('should throw an error if event does not include email / riid / customer_id', async () => { + const errorMessage = 'At least one of the following fields is required: Email Address, RIID, or Customer ID' + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post( + `/rest/asyncApi/v1.3/lists/${testSettings.profileListName}/listExtensions/${testSettings.profileExtensionTable}/members` + ) + .replyWithError({ + message: errorMessage, + statusCode: 400 + }) + const bad_event = createTestEvent({ + context: { + personas: { + computation_id: AUDIENCE_ID, + computation_key: AUDIENCE_KEY, + computation_class: 'audience' + } + }, + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false + }, + type: 'identify' + }) + await expect( + testDestination.testAction('sendAudience', { + event: bad_event, + useDefaultMappings: true, + settings: testSettings + }) + ).rejects.toThrow(errorMessage) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/responsys/sendAudience/generated-types.ts b/packages/destination-actions/src/destinations/responsys/sendAudience/generated-types.ts new file mode 100644 index 0000000000..8559097f52 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/sendAudience/generated-types.ts @@ -0,0 +1,48 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Record data that represents field names and corresponding values for each profile. + */ + userData: { + /** + * The user's email address + */ + EMAIL_ADDRESS_?: string + /** + * Responsys Customer ID. + */ + CUSTOMER_ID_?: string + [k: string]: unknown + } + /** + * A unique identifier assigned to a specific audience in Segment. + */ + computation_key: string + /** + * Hidden field used to access traits or properties objects from Engage payloads. + */ + traits_or_props: { + [k: string]: unknown + } + /** + * Hidden field used to verify that the payload is generated by an Audience. Payloads not containing computation_class = 'audience' will be dropped before the perform() fuction call. + */ + computation_class: string + /** + * Once enabled, Segment will collect events into batches of 200 before sending to Responsys. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number + /** + * If true, all Recipient data will be converted to strings before being sent to Responsys. + */ + stringify: boolean + /** + * The timestamp of when the event occurred. + */ + timestamp: string | number +} diff --git a/packages/destination-actions/src/destinations/responsys/sendAudience/index.ts b/packages/destination-actions/src/destinations/responsys/sendAudience/index.ts new file mode 100644 index 0000000000..fe0207fc93 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/sendAudience/index.ts @@ -0,0 +1,119 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { enable_batching, batch_size } from '../shared_properties' +import { sendCustomTraits, getUserDataFieldNames, validateCustomTraits, validateListMemberPayload } from '../utils' +import { Data } from '../types' + +const action: ActionDefinition = { + title: 'Send Audience', + description: 'Send Engage Audience to a Profile Extension Table in Responsys', + defaultSubscription: 'type = "identify" or type = "track"', + fields: { + userData: { + label: 'Recipient Data', + description: 'Record data that represents field names and corresponding values for each profile.', + type: 'object', + defaultObjectUI: 'keyvalue', + required: true, + additionalProperties: true, + properties: { + EMAIL_ADDRESS_: { + label: 'Email address', + description: "The user's email address", + type: 'string', + format: 'email', + required: false + }, + CUSTOMER_ID_: { + label: 'Customer ID', + description: 'Responsys Customer ID.', + type: 'string', + required: false + } + }, + default: { + EMAIL_ADDRESS_: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.context.traits.email' } + } + }, + CUSTOMER_ID_: { '@path': '$.userId' } + } + }, + computation_key: { + label: 'Segment Audience Key', + description: 'A unique identifier assigned to a specific audience in Segment.', + type: 'string', + required: true, + unsafe_hidden: true, + default: { '@path': '$.context.personas.computation_key' } + }, + traits_or_props: { + label: 'Traits or Properties', + description: 'Hidden field used to access traits or properties objects from Engage payloads.', + type: 'object', + required: true, + unsafe_hidden: true, + default: { + '@if': { + exists: { '@path': '$.traits' }, + then: { '@path': '$.traits' }, + else: { '@path': '$.properties' } + } + } + }, + computation_class: { + label: 'Segment Audience Computation Class', + description: + "Hidden field used to verify that the payload is generated by an Audience. Payloads not containing computation_class = 'audience' will be dropped before the perform() fuction call.", + type: 'string', + required: true, + unsafe_hidden: true, + default: { '@path': '$.context.personas.computation_class' }, + choices: [{ label: 'Audience', value: 'audience' }] + }, + enable_batching: enable_batching, + batch_size: batch_size, + stringify: { + label: 'Stringify Recipient Data', + description: 'If true, all Recipient data will be converted to strings before being sent to Responsys.', + type: 'boolean', + required: true, + default: false + }, + timestamp: { + label: 'Timestamp', + description: 'The timestamp of when the event occurred.', + type: 'datetime', + required: true, + unsafe_hidden: true, + default: { + '@path': '$.timestamp' + } + } + }, + + perform: async (request, data) => { + const { payload, settings, statsContext } = data + + const userDataFieldNames: string[] = getUserDataFieldNames(data as unknown as Data) + validateCustomTraits({ profileExtensionTable: settings.profileExtensionTable, timestamp: payload.timestamp, statsContext: statsContext }) + validateListMemberPayload(payload.userData) + + return sendCustomTraits(request, [payload], data.settings, userDataFieldNames, true) + }, + + performBatch: async (request, data) => { + const { payload, settings, statsContext } = data + + const userDataFieldNames = getUserDataFieldNames(data as unknown as Data) + validateCustomTraits({ profileExtensionTable: settings.profileExtensionTable, timestamp: payload[0].timestamp, statsContext: statsContext }) + + return sendCustomTraits(request, data.payload, data.settings, userDataFieldNames, true) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/responsys/sendCustomTraits/__tests__/index.test.ts b/packages/destination-actions/src/destinations/responsys/sendCustomTraits/__tests__/index.test.ts new file mode 100644 index 0000000000..c32fad904d --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/sendCustomTraits/__tests__/index.test.ts @@ -0,0 +1,95 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { Settings } from '../../generated-types' + +const testDestination = createTestIntegration(Destination) +const actionSlug = 'sendCustomTraits' +const testSettings: Settings = { + profileListName: 'ABCD', + profileExtensionTable: 'EFGH', + username: 'abcd', + userPassword: 'abcd', + baseUrl: 'https://njp1q7u-api.responsys.ocs.oraclecloud.com', + insertOnNoMatch: false, + matchColumnName1: 'EMAIL_ADDRESS_', + updateOnMatch: 'REPLACE_ALL', + defaultPermissionStatus: 'OPTOUT' +} + +describe('Responsys.sendCustomTraits', () => { + const OLD_ENV = process.env + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + process.env = { ...OLD_ENV } // Make a copy + }) + + afterAll(() => { + process.env = OLD_ENV // Restore old environment + }) + it('should send traits data to Responsys with default mapping', async () => { + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post( + `/rest/asyncApi/v1.3/lists/${testSettings.profileListName}/listExtensions/${testSettings.profileExtensionTable}/members` + ) + .reply(202) + const event = createTestEvent({ + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false, + email: 'martin@martechawesome.biz' + }, + type: 'identify', + userId: '6789013' + }) + + const responses = await testDestination.testAction(actionSlug, { + event, + settings: testSettings, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(202) + expect(JSON.parse(responses[0]?.options?.body as string)).toMatchObject({ + insertOnNoMatch: false, + matchColumnName1: 'EMAIL_ADDRESS_', + matchColumnName2: '', + recordData: { + fieldNames: ['EMAIL_ADDRESS_', 'CUSTOMER_ID_'], + mapTemplateName: '', + records: [['martin@martechawesome.biz', '6789013']] + }, + updateOnMatch: 'REPLACE_ALL' + }) + }) + + describe('Failure cases', () => { + it('should throw an error if event does not include email / riid / customer_id', async () => { + const errorMessage = 'At least one of the following fields is required: Email Address, RIID, or Customer ID' + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post( + `/rest/asyncApi/v1.3/lists/${testSettings.profileListName}/listExtensions/${testSettings.profileExtensionTable}/members` + ) + .replyWithError({ + message: errorMessage, + statusCode: 400 + }) + const bad_event = createTestEvent({ + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false + }, + type: 'identify' + }) + await expect( + testDestination.testAction('sendCustomTraits', { + event: bad_event, + useDefaultMappings: true, + settings: testSettings + }) + ).rejects.toThrow(errorMessage) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/responsys/sendCustomTraits/generated-types.ts b/packages/destination-actions/src/destinations/responsys/sendCustomTraits/generated-types.ts new file mode 100644 index 0000000000..9d32eeb284 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/sendCustomTraits/generated-types.ts @@ -0,0 +1,34 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Record data that represents field names and corresponding values for each profile. + */ + userData: { + /** + * The user's email address + */ + EMAIL_ADDRESS_?: string + /** + * Responsys Customer ID. + */ + CUSTOMER_ID_?: string + [k: string]: unknown + } + /** + * Once enabled, Segment will collect events into batches of 200 before sending to Responsys. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number + /** + * If true, all Recipient data will be converted to strings before being sent to Responsys. + */ + stringify: boolean + /** + * The timestamp of when the event occurred. + */ + timestamp: string | number +} diff --git a/packages/destination-actions/src/destinations/responsys/sendCustomTraits/index.ts b/packages/destination-actions/src/destinations/responsys/sendCustomTraits/index.ts new file mode 100644 index 0000000000..a7190ea9ed --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/sendCustomTraits/index.ts @@ -0,0 +1,84 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { enable_batching, batch_size } from '../shared_properties' +import { sendCustomTraits, getUserDataFieldNames, validateCustomTraits, validateListMemberPayload } from '../utils' +import { Data } from '../types' + +const action: ActionDefinition = { + title: 'Send Custom Traits', + description: 'Send Custom Traits to a Profile Extension Table in Responsys', + defaultSubscription: 'type = "identify"', + fields: { + userData: { + label: 'Recipient Data', + description: 'Record data that represents field names and corresponding values for each profile.', + type: 'object', + defaultObjectUI: 'keyvalue', + required: true, + additionalProperties: true, + properties: { + EMAIL_ADDRESS_: { + label: 'Email address', + description: "The user's email address", + type: 'string', + format: 'email', + required: false + }, + CUSTOMER_ID_: { + label: 'Customer ID', + description: 'Responsys Customer ID.', + type: 'string', + required: false + } + }, + default: { + EMAIL_ADDRESS_: { '@path': '$.traits.email' }, + CUSTOMER_ID_: { '@path': '$.userId' } + } + }, + enable_batching: enable_batching, + batch_size: batch_size, + stringify: { + label: 'Stringify Recipient Data', + description: 'If true, all Recipient data will be converted to strings before being sent to Responsys.', + type: 'boolean', + required: true, + default: false + }, + timestamp: { + label: 'Timestamp', + description: 'The timestamp of when the event occurred.', + type: 'datetime', + required: true, + unsafe_hidden: true, + default: { + '@path': '$.timestamp' + } + } + }, + + perform: async (request, data) => { + const { payload, settings, statsContext } = data + + const userDataFieldNames = getUserDataFieldNames(data as unknown as Data) + + validateCustomTraits({ profileExtensionTable: settings.profileExtensionTable, timestamp: payload.timestamp, statsContext: statsContext }) + + validateListMemberPayload(payload.userData) + + return sendCustomTraits(request, [payload], settings, userDataFieldNames) + }, + + performBatch: async (request, data) => { + const { payload, settings, statsContext } = data + + const userDataFieldNames = getUserDataFieldNames(data as unknown as Data) + + validateCustomTraits({ profileExtensionTable: settings.profileExtensionTable, timestamp: payload[0].timestamp, statsContext: statsContext }) + + return sendCustomTraits(request, data.payload, data.settings, userDataFieldNames) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/responsys/shared_properties.ts b/packages/destination-actions/src/destinations/responsys/shared_properties.ts new file mode 100644 index 0000000000..cbfe4e64e5 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/shared_properties.ts @@ -0,0 +1,18 @@ +import { InputField } from '@segment/actions-core/destination-kit/types' + +export const enable_batching: InputField = { + label: 'Use Responsys Async API', + description: 'Once enabled, Segment will collect events into batches of 200 before sending to Responsys.', + type: 'boolean', + default: true, + unsafe_hidden: true +} + +export const batch_size: InputField = { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + required: false, + unsafe_hidden: true, + default: 200 +} diff --git a/packages/destination-actions/src/destinations/responsys/types.ts b/packages/destination-actions/src/destinations/responsys/types.ts new file mode 100644 index 0000000000..824f8dd910 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/types.ts @@ -0,0 +1,75 @@ +export interface Data { + rawMapping: { + userData: { + [k: string]: unknown + } + } +} + +export type MergeRule = { + /** + * Value of incoming preferred email format data. For example, 'H' may represent a preference for HTML formatted email. + */ + htmlValue?: string + /** + * Value of incoming opt-in status data that represents an opt-in status. For example, 'I' may represent an opt-in status. + */ + optinValue?: string + /** + * Value of incoming preferred email format data. For example, 'T' may represent a preference for Text formatted email. + */ + textValue?: string + /** + * Indicates what should be done for records where a match is not found. + */ + insertOnNoMatch?: boolean + /** + * Controls how the existing record should be updated. + */ + updateOnMatch?: string + /** + * First match column for determining whether an insert or update should occur. + */ + matchColumnName1?: string + /** + * Second match column for determining whether an insert or update should occur. + */ + matchColumnName2?: string + /** + * Operator to join match column names. + */ + matchOperator?: string + /** + * Value of incoming opt-out status data that represents an optout status. For example, '0' may represent an opt-out status. + */ + optoutValue?: string + /** + * String containing comma-separated channel codes that if specified will result in record rejection when the channel address field is null. Channel codes are 'E' (Email), 'M' (Mobile), 'P' (Postal Code). For example 'E,M' would indicate that a record that has a null for Email or Mobile Number value should be rejected. This parameter can also be set to null or to an empty string, which will cause the validation to not be performed for any channel, except if the matchColumnName1 parameter is set to EMAIL_ADDRESS_ or MOBILE_NUMBER_. When matchColumnName1 is set to EMAIL_ADDRESS_ or MOBILE_NUMBER_, then the null or empty string setting is effectively ignored for that channel. + */ + rejectRecordIfChannelEmpty?: string + /** + * This value must be specified as either OPTIN or OPTOUT and would be applied to all of the records contained in the API call. If this value is not explicitly specified, then it is set to OPTOUT. + */ + defaultPermissionStatus?: string +} + +export type RecordData = { + fieldNames: string[] + records: unknown[][] + mapTemplateName: string +} + +export type ListMemberRequestBody = { + recordData: RecordData +} & { + mergeRule: MergeRule +} + +export type CustomTraitsRequestBody = { + recordData: RecordData +} & { + insertOnNoMatch?: boolean + updateOnMatch?: string + matchColumnName1?: string + matchColumnName2?: string +} diff --git a/packages/destination-actions/src/destinations/responsys/upsertListMember/__tests__/index.test.ts b/packages/destination-actions/src/destinations/responsys/upsertListMember/__tests__/index.test.ts new file mode 100644 index 0000000000..bc8a2436ae --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/upsertListMember/__tests__/index.test.ts @@ -0,0 +1,94 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { Settings } from '../../generated-types' + +const testDestination = createTestIntegration(Destination) +const actionSlug = 'upsertListMember' +const testSettings: Settings = { + profileListName: 'ABCD', + profileExtensionTable: 'EFGH', + username: 'abcd', + userPassword: 'abcd', + baseUrl: 'https://njp1q7u-api.responsys.ocs.oraclecloud.com', + insertOnNoMatch: false, + matchColumnName1: 'EMAIL_ADDRESS_', + updateOnMatch: 'REPLACE_ALL', + defaultPermissionStatus: 'OPTOUT' +} + +describe('Responsys.upsertListMember', () => { + const OLD_ENV = process.env + + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + process.env = { ...OLD_ENV } // Make a copy + }) + + afterAll(() => { + process.env = OLD_ENV // Restore old environment + }) + it('should send traits data to Responsys with default mapping', async () => { + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post(`/rest/asyncApi/v1.3/lists/${testSettings.profileListName}/members`) + .reply(202) + const event = createTestEvent({ + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false, + email: 'martin@martechawesome.biz' + }, + type: 'identify', + userId: '6789013' + }) + + const responses = await testDestination.testAction(actionSlug, { + event, + settings: testSettings, + useDefaultMappings: true + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(202) + expect(JSON.parse(responses[0]?.options?.body as string)).toMatchObject({ + recordData: { + fieldNames: ['EMAIL_ADDRESS_', 'EMAIL_MD5_HASH_', 'EMAIL_SHA256_HASH_', 'CUSTOMER_ID_', 'MOBILE_NUMBER_'], + records: [['martin@martechawesome.biz', '', '', '6789013', '']], + mapTemplateName: '' + }, + mergeRule: { + insertOnNoMatch: false, + updateOnMatch: 'REPLACE_ALL', + matchColumnName1: 'EMAIL_ADDRESS__', + matchColumnName2: '', + defaultPermissionStatus: 'OPTOUT' + } + }) + }) + + describe('Failure cases', () => { + it('should throw an error if event does not include email / riid / customer_id', async () => { + const errorMessage = 'At least one of the following fields is required: Email Address, RIID, or Customer ID' + nock('https://njp1q7u-api.responsys.ocs.oraclecloud.com') + .post(`/rest/asyncApi/v1.3/lists/${testSettings.profileListName}/members`) + .replyWithError({ + message: errorMessage, + statusCode: 400 + }) + const bad_event = createTestEvent({ + timestamp: '2024-02-09T20:01:47.853Z', + traits: { + test_key: false + }, + type: 'identify' + }) + await expect( + testDestination.testAction('upsertListMember', { + event: bad_event, + useDefaultMappings: true, + settings: testSettings + }) + ).rejects.toThrow(errorMessage) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/responsys/upsertListMember/generated-types.ts b/packages/destination-actions/src/destinations/responsys/upsertListMember/generated-types.ts new file mode 100644 index 0000000000..6530dc34b9 --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/upsertListMember/generated-types.ts @@ -0,0 +1,46 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Record data that represents field names and corresponding values for each profile. + */ + userData: { + /** + * The user's email address. + */ + EMAIL_ADDRESS_?: string + /** + * An MD5 Hash of the user's email address. + */ + EMAIL_MD5_HASH_?: string + /** + * A SHA256 Hash of the user's email address. + */ + EMAIL_SHA256_HASH_?: string + /** + * Recipient ID (RIID). RIID is required if Email Address is empty. + */ + RIID_?: string + /** + * Responsys Customer ID. + */ + CUSTOMER_ID_?: string + /** + * The user's Mobile Phone Number. + */ + MOBILE_NUMBER_?: string + [k: string]: unknown + } + /** + * If true, all Recipient data will be converted to strings before being sent to Responsys. + */ + stringify: boolean + /** + * Once enabled, Segment will collect events into batches of 200 before sending to Responsys. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number +} diff --git a/packages/destination-actions/src/destinations/responsys/upsertListMember/index.ts b/packages/destination-actions/src/destinations/responsys/upsertListMember/index.ts new file mode 100644 index 0000000000..6dfbf528eb --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/upsertListMember/index.ts @@ -0,0 +1,93 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { enable_batching, batch_size } from '../shared_properties' +import { upsertListMembers, getUserDataFieldNames, validateListMemberPayload } from '../utils' +import { Data } from '../types' + +const action: ActionDefinition = { + title: 'Upsert Profile List Member', + description: 'Create or update a Profile List Member in Responsys', + defaultSubscription: 'type = "identify"', + fields: { + userData: { + label: 'Recipient Data', + description: 'Record data that represents field names and corresponding values for each profile.', + type: 'object', + defaultObjectUI: 'keyvalue', + required: true, + additionalProperties: true, + properties: { + EMAIL_ADDRESS_: { + label: 'Email Address', + description: "The user's email address.", + type: 'string', + format: 'email', + required: false + }, + EMAIL_MD5_HASH_: { + label: 'Email Address MD5 Hash', + description: "An MD5 Hash of the user's email address.", + type: 'string', + required: false + }, + EMAIL_SHA256_HASH_: { + label: 'Email Address SHA256 Hash', + description: "A SHA256 Hash of the user's email address.", + type: 'string', + required: false + }, + RIID_: { + label: 'Recipient ID', + description: 'Recipient ID (RIID). RIID is required if Email Address is empty.', + type: 'string', + required: false + }, + CUSTOMER_ID_: { + label: 'Customer ID', + description: 'Responsys Customer ID.', + type: 'string', + required: false + }, + MOBILE_NUMBER_: { + label: 'Mobile Number', + description: "The user's Mobile Phone Number.", + type: 'string', + required: false + } + }, + default: { + EMAIL_ADDRESS_: { '@path': '$.traits.email' }, + EMAIL_MD5_HASH_: { '@path': '$.traits.email_md5_hash_' }, + EMAIL_SHA256_HASH_: { '@path': '$.traits.email_sha256_hash' }, + CUSTOMER_ID_: { '@path': '$.userId' }, + MOBILE_NUMBER_: { '@path': '$.traits.phone' } + } + }, + stringify: { + label: 'Stringify Recipient Data', + description: 'If true, all Recipient data will be converted to strings before being sent to Responsys.', + type: 'boolean', + required: true, + default: false + }, + enable_batching: enable_batching, + batch_size: batch_size + }, + + perform: async (request, data) => { + const userDataFieldNames = getUserDataFieldNames(data as unknown as Data) + // const transformedSettings = transformDataFieldValues(data.settings) + validateListMemberPayload(data.payload.userData) + + return upsertListMembers(request, [data.payload], data.settings, userDataFieldNames) + }, + + performBatch: async (request, data) => { + const userDataFieldNames = getUserDataFieldNames(data as unknown as Data) + + return upsertListMembers(request, data.payload, data.settings, userDataFieldNames) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/responsys/utils.ts b/packages/destination-actions/src/destinations/responsys/utils.ts new file mode 100644 index 0000000000..f61f94a55c --- /dev/null +++ b/packages/destination-actions/src/destinations/responsys/utils.ts @@ -0,0 +1,240 @@ +import { Payload as CustomTraitsPayload } from './sendCustomTraits/generated-types' +import { Payload as AudiencePayload } from './sendAudience/generated-types' +import { Payload as ListMemberPayload } from './upsertListMember/generated-types' +import { RecordData, CustomTraitsRequestBody, MergeRule, ListMemberRequestBody, Data } from './types' +import { RequestClient, IntegrationError, PayloadValidationError, RetryableError, StatsContext } from '@segment/actions-core' +import type { Settings } from './generated-types' + +export const validateCustomTraits = ({ + profileExtensionTable, + timestamp, + statsContext +}: { + profileExtensionTable?: string + timestamp: string | number, + statsContext: StatsContext | undefined +}): void => { + const statsClient = statsContext?.statsClient + const statsTag = statsContext?.tags + if (shouldRetry(timestamp)) { + if (statsClient && statsTag) { + statsClient?.incr('responsysShouldRetryTRUE', 1, statsTag) + } + throw new RetryableError('Event timestamp is within the retry window. Artificial delay to retry this event.', 429) + } else { + if (statsClient && statsTag) { + statsClient?.incr('responsysShouldRetryFALSE', 1, statsTag) + } + } + if ( + !( + typeof profileExtensionTable !== 'undefined' && + profileExtensionTable !== null && + profileExtensionTable.trim().length > 0 + ) + ) { + throw new IntegrationError( + 'Send Custom Traits Action requires "PET Name" setting field to be populated', + 'PET_NAME_SETTING_MISSING', + 400 + ) + } +} + +const RETRY_MINUTES = 2 + +export const shouldRetry = (timestamp: string | number): boolean => { + return (new Date().getTime() - new Date(timestamp).getTime()) / (1000 * 60) < RETRY_MINUTES +} + +export const validateListMemberPayload = ({ + EMAIL_ADDRESS_, + RIID_, + CUSTOMER_ID_ +}: { + EMAIL_ADDRESS_?: string + RIID_?: string + CUSTOMER_ID_?: string +}): void => { + if (!EMAIL_ADDRESS_ && !RIID_ && !CUSTOMER_ID_) { + throw new PayloadValidationError( + 'At least one of the following fields is required: Email Address, RIID, or Customer ID' + ) + } +} + +export const getUserDataFieldNames = (data: Data): string[] => { + return Object.keys((data as unknown as Data).rawMapping.userData) +} + +const stringifyObject = (obj: Record): Record => { + const stringifiedObj: Record = {} + for (const key in obj) { + stringifiedObj[key] = typeof obj[key] !== 'string' ? JSON.stringify(obj[key]) : (obj[key] as string) + } + return stringifiedObj +} + +export const sendCustomTraits = async ( + request: RequestClient, + payload: CustomTraitsPayload[] | AudiencePayload[], + settings: Settings, + userDataFieldNames: string[], + isAudience?: boolean +) => { + let userDataArray: unknown[] + if (isAudience) { + const audiencePayloads = payload as unknown[] as AudiencePayload[] + userDataArray = audiencePayloads.map((obj) => { + const traitValue = obj.computation_key + ? { [obj.computation_key.toUpperCase() as unknown as string]: obj.traits_or_props[obj.computation_key] } + : {} + + userDataFieldNames.push(obj.computation_key.toUpperCase() as unknown as string) + + return { + ...(obj.stringify ? stringifyObject(obj.userData) : obj.userData), + ...(obj.stringify ? stringifyObject(traitValue) : traitValue) + } + }) + } else { + const customTraitsPayloads = payload as unknown[] as CustomTraitsPayload[] + userDataArray = customTraitsPayloads.map((obj) => (obj.stringify ? stringifyObject(obj.userData) : obj.userData)) + } + + const records: unknown[][] = userDataArray.map((userData) => { + return userDataFieldNames.map((fieldName) => { + return (userData as Record) && fieldName in (userData as Record) + ? (userData as Record)[fieldName] + : '' + }) + }) + + const recordData: RecordData = { + fieldNames: userDataFieldNames.map((field) => field.toUpperCase()), + records, + mapTemplateName: '' + } + + const requestBody: CustomTraitsRequestBody = { + recordData, + insertOnNoMatch: settings.insertOnNoMatch, + updateOnMatch: settings.updateOnMatch, + matchColumnName1: settings.matchColumnName1, + matchColumnName2: settings.matchColumnName2 || '' + } + + const path = `/rest/asyncApi/v1.3/lists/${settings.profileListName}/listExtensions/${settings.profileExtensionTable}/members` + + const endpoint = new URL(path, settings.baseUrl) + + const response = await request(endpoint.href, { + method: 'POST', + body: JSON.stringify(requestBody) + }) + + if (settings.segmentWriteKey && settings.segmentWriteKeyRegion) { + try { + const body = response.data + await request( + settings.segmentWriteKeyRegion === 'EU' + ? 'events.eu1.segmentapis.com/v1/track' + : 'https://api.segment.io/v1/track', + { + method: 'POST', + headers: { + Authorization: 'Basic ' + Buffer.from(settings.segmentWriteKey + ': ').toString('base64'), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'track', + event: 'Responsys Response Message Received', + properties: body, + anonymousId: '__responsys__API__response__' + }) + } + ) + } catch (error) { + // do nothing + } + } + return response +} + +export const upsertListMembers = async ( + request: RequestClient, + payload: ListMemberPayload[], + settings: Settings, + userDataFieldNames: string[] +) => { + const userDataArray = payload.map((obj) => (obj.stringify ? stringifyObject(obj.userData) : obj.userData)) + + const records: unknown[][] = userDataArray.map((userData) => { + return userDataFieldNames.map((fieldName) => { + return (userData as Record) && fieldName in (userData as Record) + ? (userData as Record)[fieldName] + : '' + }) + }) + + const recordData: RecordData = { + fieldNames: userDataFieldNames, + records, + mapTemplateName: '' + } + + const mergeRule: MergeRule = { + htmlValue: settings.htmlValue, + optinValue: settings.optinValue, + textValue: settings.textValue, + insertOnNoMatch: settings.insertOnNoMatch, + updateOnMatch: settings.updateOnMatch, + matchColumnName1: settings.matchColumnName1 + '_', + matchColumnName2: settings.matchColumnName2 ? settings.matchColumnName2 + '_' : '', + matchOperator: settings.matchOperator, + optoutValue: settings.optoutValue, + rejectRecordIfChannelEmpty: settings.rejectRecordIfChannelEmpty, + defaultPermissionStatus: settings.defaultPermissionStatus + } + + const requestBody: ListMemberRequestBody = { + recordData, + mergeRule + } + + const path = `/rest/asyncApi/v1.3/lists/${settings.profileListName}/members` + + const endpoint = new URL(path, settings.baseUrl) + + const response = await request(endpoint.href, { + method: 'POST', + body: JSON.stringify(requestBody) + }) + + if (settings.segmentWriteKey && settings.segmentWriteKeyRegion) { + try { + const body = response.data + await request( + settings.segmentWriteKeyRegion === 'EU' + ? 'events.eu1.segmentapis.com/v1/track' + : 'https://api.segment.io/v1/track', + { + method: 'POST', + headers: { + Authorization: 'Basic ' + Buffer.from(settings.segmentWriteKey + ': ').toString('base64'), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type: 'track', + event: 'Responsys Response Message Received', + properties: body, + anonymousId: '__responsys__API__response__' + }) + } + ) + } catch (error) { + // do nothing + } + } + return response +} diff --git a/packages/destination-actions/src/destinations/ripe/group/__tests__/index.test.ts b/packages/destination-actions/src/destinations/ripe/group/__tests__/index.test.ts index 5419d44acf..13c49a0a92 100644 --- a/packages/destination-actions/src/destinations/ripe/group/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/ripe/group/__tests__/index.test.ts @@ -16,11 +16,11 @@ describe('Ripe', () => { }) it('should work', async () => { - nock('https://api.getripe.com/core-backend').post('/group').reply(200, {}) + nock('https://api.getripe.com/event').post('/group').reply(200, {}) const responses = await testDestination.testAction('group', { mapping: { anonymousId: 'my-anonymous-id', groupId: 'my-group-id' }, - settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/core-backend' } + settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/event' } }) expect(responses.length).toBe(1) diff --git a/packages/destination-actions/src/destinations/ripe/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/ripe/identify/__tests__/index.test.ts index 82a5f5b84c..c629a41a35 100644 --- a/packages/destination-actions/src/destinations/ripe/identify/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/ripe/identify/__tests__/index.test.ts @@ -17,11 +17,11 @@ describe('Ripe', () => { }) it('should work', async () => { - nock('https://api.getripe.com/core-backend').post('/identify').reply(200, {}) + nock('https://api.getripe.com/event').post('/identify').reply(200, {}) const responses = await testDestination.testAction('identify', { mapping: { anonymousId: 'my-id', traits: {} }, - settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/core-backend' } + settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/event' } }) expect(responses.length).toBe(1) diff --git a/packages/destination-actions/src/destinations/ripe/index.ts b/packages/destination-actions/src/destinations/ripe/index.ts index bcb7746eca..c85f86916d 100644 --- a/packages/destination-actions/src/destinations/ripe/index.ts +++ b/packages/destination-actions/src/destinations/ripe/index.ts @@ -29,7 +29,7 @@ const destination: DestinationDefinition = { description: `The Ripe API endpoint (do not change this unless you know what you're doing)`, type: 'string', format: 'uri', - default: 'https://api.getripe.com/core-backend' + default: 'https://api.getripe.com/event' } }, diff --git a/packages/destination-actions/src/destinations/ripe/page/__tests__/index.test.ts b/packages/destination-actions/src/destinations/ripe/page/__tests__/index.test.ts index 93f26a8239..741d2360cc 100644 --- a/packages/destination-actions/src/destinations/ripe/page/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/ripe/page/__tests__/index.test.ts @@ -7,10 +7,10 @@ const testDestination = createTestIntegration(Ripe) describe('Ripe', () => { describe('page', () => { it('should validate action fields', async () => { - nock('https://api.getripe.com/core-backend').post('/page').reply(200, {}) + nock('https://api.getripe.com/event').post('/page').reply(200, {}) try { await testDestination.testAction('page', { - settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/core-backend' } + settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/event' } }) } catch (err) { expect(err.message).toContain("missing the required field 'properties'.") @@ -19,11 +19,11 @@ describe('Ripe', () => { }) it('should work', async () => { - nock('https://api.getripe.com/core-backend').post('/page').reply(200, {}) + nock('https://api.getripe.com/event').post('/page').reply(200, {}) const responses = await testDestination.testAction('page', { mapping: { anonymousId: 'my-id', properties: {}, name: 'page-name' }, - settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/core-backend' } + settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/event' } }) expect(responses.length).toBe(1) diff --git a/packages/destination-actions/src/destinations/ripe/track/__tests__/index.test.ts b/packages/destination-actions/src/destinations/ripe/track/__tests__/index.test.ts index 32dafe3916..8262ad64d4 100644 --- a/packages/destination-actions/src/destinations/ripe/track/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/ripe/track/__tests__/index.test.ts @@ -17,11 +17,11 @@ describe('Ripe', () => { }) it('should work', async () => { - nock('https://api.getripe.com/core-backend').post('/track').reply(200, {}) + nock('https://api.getripe.com/event').post('/track').reply(200, {}) const responses = await testDestination.testAction('track', { mapping: { anonymousId: 'my-id', event: 'event-name' }, - settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/core-backend' } + settings: { apiKey: 'api-key', endpoint: 'https://api.getripe.com/event' } }) expect(responses.length).toBe(1) diff --git a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts index 84a785996a..bb7e0115cb 100644 --- a/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts +++ b/packages/destination-actions/src/destinations/salesforce-marketing-cloud/sfmc-properties.ts @@ -87,5 +87,10 @@ export const batch_size: InputField = { type: 'number', required: false, unsafe_hidden: true, - default: 5000 + /** + * SFMC has very low limits on maximum batch size. + * See: https://developer.salesforce.com/docs/marketing/marketing-cloud/guide/postDataExtensionRowsetByKey.html + * And: inc-sev3-6609-sfmc-timeouts-in-bulk-batching-2023-10-23 + * */ + default: 50 } diff --git a/packages/destination-actions/src/destinations/salesforce/account/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/account/generated-types.ts index 45cb6216e6..60f50a701f 100644 --- a/packages/destination-actions/src/destinations/salesforce/account/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce/account/generated-types.ts @@ -6,7 +6,7 @@ export interface Payload { */ operation: string /** - * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 1000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. + * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. */ enable_batching?: boolean /** diff --git a/packages/destination-actions/src/destinations/salesforce/cases/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/cases/generated-types.ts index 97428a1732..dea001797e 100644 --- a/packages/destination-actions/src/destinations/salesforce/cases/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce/cases/generated-types.ts @@ -10,7 +10,7 @@ export interface Payload { */ recordMatcherOperator?: string /** - * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 1000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. + * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. */ enable_batching?: boolean /** diff --git a/packages/destination-actions/src/destinations/salesforce/contact/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/contact/generated-types.ts index d4bfc97a97..79c8e140f7 100644 --- a/packages/destination-actions/src/destinations/salesforce/contact/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce/contact/generated-types.ts @@ -10,7 +10,7 @@ export interface Payload { */ recordMatcherOperator?: string /** - * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 1000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. + * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. */ enable_batching?: boolean /** diff --git a/packages/destination-actions/src/destinations/salesforce/customObject/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/customObject/generated-types.ts index 460d500e61..53b60ed677 100644 --- a/packages/destination-actions/src/destinations/salesforce/customObject/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce/customObject/generated-types.ts @@ -10,7 +10,7 @@ export interface Payload { */ recordMatcherOperator?: string /** - * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 1000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. + * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. */ enable_batching?: boolean /** diff --git a/packages/destination-actions/src/destinations/salesforce/lead/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/lead/generated-types.ts index 9387e54c75..746f92b3ad 100644 --- a/packages/destination-actions/src/destinations/salesforce/lead/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce/lead/generated-types.ts @@ -10,7 +10,7 @@ export interface Payload { */ recordMatcherOperator?: string /** - * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 1000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. + * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. */ enable_batching?: boolean /** diff --git a/packages/destination-actions/src/destinations/salesforce/opportunity/generated-types.ts b/packages/destination-actions/src/destinations/salesforce/opportunity/generated-types.ts index 207b4ef9f4..ba337d4ff9 100644 --- a/packages/destination-actions/src/destinations/salesforce/opportunity/generated-types.ts +++ b/packages/destination-actions/src/destinations/salesforce/opportunity/generated-types.ts @@ -10,7 +10,7 @@ export interface Payload { */ recordMatcherOperator?: string /** - * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 1000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. + * If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*. */ enable_batching?: boolean /** diff --git a/packages/destination-actions/src/destinations/salesforce/sf-properties.ts b/packages/destination-actions/src/destinations/salesforce/sf-properties.ts index aaed224ce2..442615b3cb 100644 --- a/packages/destination-actions/src/destinations/salesforce/sf-properties.ts +++ b/packages/destination-actions/src/destinations/salesforce/sf-properties.ts @@ -1,5 +1,4 @@ -import { InputField } from '@segment/actions-core/destination-kit/types' -import { IntegrationError } from '@segment/actions-core' +import { IntegrationError, InputField } from '@segment/actions-core' export const operation: InputField = { label: 'Operation', @@ -18,7 +17,7 @@ export const operation: InputField = { export const enable_batching: InputField = { label: 'Use Salesforce Bulk API', description: - 'If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 1000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*.', + 'If true, events are sent to [Salesforce’s Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_asynch.meta/api_asynch/asynch_api_intro.htm) rather than their streaming REST API. Once enabled, Segment will collect events into batches of 5000 before sending to Salesforce. *Enabling Bulk API is not compatible with the `create` operation*.', type: 'boolean', default: false } @@ -49,13 +48,43 @@ export const bulkUpsertExternalId: InputField = { description: 'The external id field value to use for bulk upsert.', type: 'string' } + }, + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'operation', + operator: 'is', + value: 'upsert' + }, + { + fieldKey: 'enable_batching', + operator: 'is', + value: true + } + ] } } export const bulkUpdateRecordId: InputField = { label: 'Bulk Update Record Id', description: 'The record id value to use for bulk update.', - type: 'string' + type: 'string', + depends_on: { + match: 'all', + conditions: [ + { + fieldKey: 'operation', + operator: 'is', + value: 'update' + }, + { + fieldKey: 'enable_batching', + operator: 'is', + value: true + } + ] + } } // Any actions configured before this field was added will have an undefined value for this field. @@ -69,7 +98,16 @@ export const recordMatcherOperator: InputField = { { label: 'OR', value: 'OR' }, { label: 'AND', value: 'AND' } ], - default: 'OR' + default: 'OR', + depends_on: { + conditions: [ + { + fieldKey: 'operation', + operator: 'is', + value: ['update', 'upsert', 'delete'] + } + ] + } } export const traits: InputField = { @@ -84,7 +122,16 @@ export const traits: InputField = { `, type: 'object', - defaultObjectUI: 'keyvalue:only' + defaultObjectUI: 'keyvalue:only', + depends_on: { + conditions: [ + { + fieldKey: 'operation', + operator: 'is', + value: ['update', 'upsert', 'delete'] + } + ] + } } export const customFields: InputField = { diff --git a/packages/destination-actions/src/destinations/schematic/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/schematic/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..14fb8bcc91 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-schematic destination: identifyUser action - all fields 1`] = ` +Object { + "api_key": "G10lVP", + "body": Object { + "company": Object { + "keys": Object { + "testType": "G10lVP", + }, + "name": "G10lVP", + "traits": Object { + "testType": "G10lVP", + }, + }, + "keys": Object { + "user_id": "G10lVP", + }, + "name": "G10lVP", + "traits": Object { + "testType": "G10lVP", + }, + }, + "sent_at": "2023-01-01T00:00:00.000Z", + "type": "identify", +} +`; + +exports[`Testing snapshot for actions-schematic destination: identifyUser action - required fields 1`] = ` +Object { + "api_key": "G10lVP", + "body": Object { + "company": Object { + "keys": Object { + "testType": "G10lVP", + }, + }, + "keys": Object {}, + }, + "sent_at": "2023-01-01T00:00:00.000Z", + "type": "identify", +} +`; + +exports[`Testing snapshot for actions-schematic destination: trackEvent action - all fields 1`] = ` +Object { + "api_key": "uvfeUI#M", + "body": Object { + "company": Object { + "testType": "uvfeUI#M", + }, + "event": "uvfeui#m", + "traits": Object { + "raw_event_name": "uvfeUI#M", + }, + "user": Object { + "user_id": "uvfeUI#M", + }, + }, + "sent_at": "2023-01-01T00:00:00.000Z", + "type": "track", +} +`; + +exports[`Testing snapshot for actions-schematic destination: trackEvent action - required fields 1`] = ` +Object { + "api_key": "uvfeUI#M", + "body": Object { + "event": "uvfeui#m", + }, + "sent_at": "2023-01-01T00:00:00.000Z", + "type": "track", +} +`; diff --git a/packages/destination-actions/src/destinations/schematic/__tests__/index.test.ts b/packages/destination-actions/src/destinations/schematic/__tests__/index.test.ts new file mode 100644 index 0000000000..bae0923f1a --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/__tests__/index.test.ts @@ -0,0 +1,87 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +const SCHEMATIC_API_KEY = 'test' + +const ts = '2023-01-01T00:00:00.000Z' + +const track_mapping = { + event_name: 'test', + timestamp: { '@path': '$.timestamp' } +} + +const identify_mapping = { + user_keys: { email: 'test@test.com' }, + company_keys: { org_id: '1234' }, + timestamp: { '@path': '$.timestamp' } +} + +const auth = { + refreshToken: 'xyz321', + accessToken: 'abc123', + apiKey: SCHEMATIC_API_KEY +} + +const settings = { + instanceUrl: 'https://c.schematichq.com', + apiKey: SCHEMATIC_API_KEY +} + +describe('POST events', () => { + it('should create an event', async () => { + nock(`${settings.instanceUrl}`).post('/e').reply(200, { + ok: true + }) + + const event = createTestEvent({ + type: 'track', + timestamp: new Date(ts).toISOString(), + event: 'Segment Test Event Name', + properties: { + email: 'silkpants@richer.com', + last_name: 'silkpants' + } + }) + + const responses = await testDestination.testAction('trackEvent', { + event, + settings, + auth, + mapping: track_mapping + }) + + console.log(responses[0].status) + + expect(responses[0].status).toBe(200) + }) + + it('should update a user', async () => { + nock(`${settings.instanceUrl}`).post('/e').reply(200, { + ok: true + }) + + const event = createTestEvent({ + type: 'identify', + timestamp: new Date(ts).toISOString(), + traits: { + name: 'simpson', + age: 42, + source: 'facebook' + } + }) + + const responses = await testDestination.testAction('identifyUser', { + event, + settings, + auth, + mapping: identify_mapping + }) + + console.log(responses[0].status) + + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/schematic/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/schematic/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..c1f9d0a41b --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-schematic' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: { ...eventData, timestamp: '2023-01-01T00:00:00.000Z' } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: { ...eventData, timestamp: '2023-01-01T00:00:00.000Z' } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/schematic/generated-types.ts b/packages/destination-actions/src/destinations/schematic/generated-types.ts new file mode 100644 index 0000000000..36dc0818d7 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Found on your settings page. + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..c2aa56558a --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Schematic's identifyUser destination action: all fields 1`] = ` +Object { + "api_key": "A7WJL)2NKFy&pmtr0", + "body": Object { + "company": Object { + "keys": Object { + "testType": "A7WJL)2NKFy&pmtr0", + }, + "name": "A7WJL)2NKFy&pmtr0", + "traits": Object { + "testType": "A7WJL)2NKFy&pmtr0", + }, + }, + "keys": Object { + "user_id": "A7WJL)2NKFy&pmtr0", + }, + "name": "A7WJL)2NKFy&pmtr0", + "traits": Object { + "testType": "A7WJL)2NKFy&pmtr0", + }, + }, + "sent_at": "2021-02-01T00:00:00.000Z", + "type": "identify", +} +`; + +exports[`Testing snapshot for Schematic's identifyUser destination action: required fields 1`] = ` +Object { + "api_key": "A7WJL)2NKFy&pmtr0", + "body": Object { + "company": Object { + "keys": Object { + "testType": "A7WJL)2NKFy&pmtr0", + }, + }, + "keys": Object {}, + }, + "sent_at": "2021-02-01T00:00:00.000Z", + "type": "identify", +} +`; diff --git a/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/index.test.ts new file mode 100644 index 0000000000..f9eaba2240 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/index.test.ts @@ -0,0 +1,55 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const SCHEMATIC_API_KEY = 'test' + +const mapping = { + user_keys: { email: 'test@test.com' }, + company_keys: { org_id: '1234' }, + timestamp: { '@path': '$.timestamp' } +} + +const ts = '2023-01-01T00:00:00.000Z' + +const auth = { + refreshToken: 'xyz321', + accessToken: 'abc123', + apiKey: SCHEMATIC_API_KEY +} + +const settings = { + instanceUrl: 'https://c.schematichq.com', + apiKey: SCHEMATIC_API_KEY +} + +describe('POST identify call', () => { + beforeEach(() => { + nock(`${settings.instanceUrl}`).post('/e').reply(200, { + ok: true + }) + }) + + it('should update a user', async () => { + const event = createTestEvent({ + type: 'identify', + timestamp: new Date(ts).toISOString(), + traits: { + name: 'simpson', + age: 42, + source: 'facebook' + } + }) + + const responses = await testDestination.testAction('identifyUser', { + event, + settings, + auth, + mapping + }) + + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..9cf0f76ad6 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/identifyUser/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'identifyUser' +const destinationSlug = 'Schematic' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/schematic/identifyUser/generated-types.ts b/packages/destination-actions/src/destinations/schematic/identifyUser/generated-types.ts new file mode 100644 index 0000000000..c19891ce88 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/identifyUser/generated-types.ts @@ -0,0 +1,44 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Key-value pairs associated with a company (e.g. organization_id: 123456) + */ + company_keys: { + [k: string]: unknown + } + /** + * Name of company + */ + company_name?: string + /** + * Properties associated with company + */ + company_traits?: { + [k: string]: unknown + } + /** + * Time the event took place + */ + timestamp: string | number + /** + * Key-value pairs associated with a user (e.g. email: example@example.com) + */ + user_keys: { + /** + * Your unique ID for your user + */ + user_id?: string + [k: string]: unknown + } + /** + * User's full name + */ + user_name?: string + /** + * Properties associated with user + */ + user_traits?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/schematic/identifyUser/index.ts b/packages/destination-actions/src/destinations/schematic/identifyUser/index.ts new file mode 100644 index 0000000000..54b48ea49d --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/identifyUser/index.ts @@ -0,0 +1,97 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Identify User', + description: 'Send identify events to Schematic', + defaultSubscription: 'type = "identify"', + fields: { + company_keys: { + label: 'Company keys', + description: 'Key-value pairs associated with a company (e.g. organization_id: 123456)', + type: 'object', + required: true, + defaultObjectUI: 'keyvalue', + additionalProperties: true + }, + company_name: { + label: 'Company name', + description: 'Name of company', + type: 'string', + required: false, + default: { '@path': '$.traits.company_name' } + }, + company_traits: { + label: 'Company traits', + description: 'Properties associated with company', + type: 'object', + defaultObjectUI: 'keyvalue', + required: false + }, + timestamp: { + label: 'Timestamp', + description: 'Time the event took place', + type: 'datetime', + required: true, + default: { '@path': '$.timestamp' } + }, + user_keys: { + label: 'User keys', + description: 'Key-value pairs associated with a user (e.g. email: example@example.com)', + type: 'object', + defaultObjectUI: 'keyvalue', + required: true, + additionalProperties: true, + properties: { + user_id: { + label: 'User ID', + description: 'Your unique ID for your user', + type: 'string', + required: false + } + }, + default: { + user_id: { '@path': '$.userId' } + } + }, + user_name: { + label: 'User name', + description: "User's full name", + type: 'string', + required: false, + default: { '@path': '$.traits.name' } + }, + user_traits: { + label: 'User traits', + description: 'Properties associated with user', + type: 'object', + defaultObjectUI: 'keyvalue', + required: false + } + }, + + perform: (request, { settings, payload }) => { + return request('https://c.schematichq.com/e', { + method: 'post', + headers: { 'Content-Type': 'application/json;charset=UTF-8' }, + json: { + api_key: `${settings.apiKey}`, + type: 'identify', + sent_at: new Date(payload.timestamp).toISOString(), + body: { + keys: payload.user_keys, + name: payload.user_name, + traits: payload.user_traits, + company: { + keys: payload.company_keys, + name: payload.company_name, + traits: payload.company_traits + } + } + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/schematic/index.ts b/packages/destination-actions/src/destinations/schematic/index.ts new file mode 100644 index 0000000000..38c3a69d22 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/index.ts @@ -0,0 +1,59 @@ +import { defaultValues, DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import trackEvent from './trackEvent' + +import identifyUser from './identifyUser' + +const destination: DestinationDefinition = { + name: 'Schematic', + slug: 'actions-schematic', + mode: 'cloud', + description: 'Send Segment events to Schematic to enrich/update user and company profiles.', + + authentication: { + scheme: 'custom', + fields: { + apiKey: { + type: 'string', + label: 'API Key', + description: 'Found on your settings page.', + required: true + } + }, + testAuthentication: (request, { settings }) => { + return request(`https://api.schematichq.com/companies`, { + method: 'GET', + headers: { 'X-Schematic-Api-Key': `${settings.apiKey}` } + }) + } + }, + + actions: { + trackEvent, + identifyUser + }, + presets: [ + { + name: 'Track Event', + subscribe: 'type = "track"', + partnerAction: 'trackEvent', + mapping: defaultValues(trackEvent.fields), + type: 'automatic' + }, + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identifyUser', + mapping: defaultValues(identifyUser.fields), + type: 'automatic' + } + ], + extendRequest: ({ settings }) => { + return { + headers: { Authorization: `Bearer ${settings.apiKey}` } + } + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..934f3b9808 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Schematic's trackEvent destination action: all fields 1`] = ` +Object { + "api_key": "H%fspr!Jez(TWP", + "body": Object { + "company": Object { + "testType": "H%fspr!Jez(TWP", + }, + "event": "h%fspr!jez(twp", + "traits": Object { + "raw_event_name": "H%fspr!Jez(TWP", + }, + "user": Object { + "user_id": "H%fspr!Jez(TWP", + }, + }, + "sent_at": "2021-02-01T00:00:00.000Z", + "type": "track", +} +`; + +exports[`Testing snapshot for Schematic's trackEvent destination action: required fields 1`] = ` +Object { + "api_key": "H%fspr!Jez(TWP", + "body": Object { + "event": "h%fspr!jez(twp", + }, + "sent_at": "2021-02-01T00:00:00.000Z", + "type": "track", +} +`; diff --git a/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/index.test.ts b/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/index.test.ts new file mode 100644 index 0000000000..fa5273b58b --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/index.test.ts @@ -0,0 +1,54 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const SCHEMATIC_API_KEY = 'test' + +const mapping = { + event_name: 'test', + timestamp: { '@path': '$.timestamp' } +} + +const ts = '2023-01-01T00:00:00.000Z' + +const auth = { + refreshToken: 'xyz321', + accessToken: 'abc123', + apiKey: SCHEMATIC_API_KEY +} + +const settings = { + instanceUrl: 'https://c.schematichq.com', + apiKey: SCHEMATIC_API_KEY +} + +describe('POST track event', () => { + beforeEach(() => { + nock(`${settings.instanceUrl}`).post('/e').reply(200, { + ok: true + }) + }) + + it('should create an event', async () => { + const event = createTestEvent({ + type: 'track', + timestamp: new Date(ts).toISOString(), + event: 'Segment Test Event Name', + properties: { + email: 'silkpants@richer.com', + last_name: 'silkpants' + } + }) + + const responses = await testDestination.testAction('trackEvent', { + event, + settings, + auth, + mapping + }) + + expect(responses[0].status).toBe(200) + }) +}) diff --git a/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..04c9d1be33 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/trackEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'trackEvent' +const destinationSlug = 'Schematic' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/schematic/trackEvent/generated-types.ts b/packages/destination-actions/src/destinations/schematic/trackEvent/generated-types.ts new file mode 100644 index 0000000000..066b3a9247 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/trackEvent/generated-types.ts @@ -0,0 +1,38 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Name of event (this will be snake cased in request) + */ + event_name: string + /** + * Key-value pairs associated with a company (e.g. organization_id: 123456) + */ + company_keys?: { + [k: string]: unknown + } + /** + * Time the event took place + */ + timestamp: string | number + /** + * Key-value pairs associated with a user (e.g. email: example@example.com) + */ + user_keys?: { + /** + * Your unique ID for your user + */ + user_id?: string + [k: string]: unknown + } + /** + * Additional properties to send with event + */ + traits?: { + /** + * Event name + */ + raw_event_name?: string + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/schematic/trackEvent/index.ts b/packages/destination-actions/src/destinations/schematic/trackEvent/index.ts new file mode 100644 index 0000000000..df8c794051 --- /dev/null +++ b/packages/destination-actions/src/destinations/schematic/trackEvent/index.ts @@ -0,0 +1,129 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +function snakeCase(str: string) { + const result = str.replace(/([A-Z])/g, '$1') + return result.split(' ').join('_').toLowerCase() +} + +/*function handleEvent(str: EventType, object: EventBody, str: apiKey) { + const event: Event = { + api_key: apiKey, + body: eventBody, + type: eventType, + } + + sendEvent(event); +} + +function sendEvent(event: Event) { + const captureUrl = `https://c.schematichq.com/e`; + const payload = JSON.stringify(event); + + fetch(captureUrl, { + method: "POST", + headers: { + "Content-Type": "application/json;charset=UTF-8", + }, + body: payload, + }) + .then((response) => { + if (!response.ok) { + throw new Error( + `Network response was not ok: ${response.statusText}`, + ) + } + }) + .catch((error) => { + console.error("There was a problem with the fetch operation:", error); + }) + }*/ + +const action: ActionDefinition = { + title: 'Track Event', + description: 'Send track events to Schematic', + defaultSubscription: 'type = "track"', + fields: { + event_name: { + label: 'Event name', + description: 'Name of event (this will be snake cased in request)', + type: 'string', + required: true, + default: { '@path': '$.event' } + }, + company_keys: { + label: 'Company keys', + description: 'Key-value pairs associated with a company (e.g. organization_id: 123456)', + type: 'object', + defaultObjectUI: 'keyvalue', + additionalProperties: true, + required: false + }, + timestamp: { + label: 'Timestamp', + description: 'Time the event took place', + type: 'datetime', + required: true, + default: { '@path': '$.timestamp' } + }, + user_keys: { + label: 'User keys', + description: 'Key-value pairs associated with a user (e.g. email: example@example.com)', + type: 'object', + required: false, + defaultObjectUI: 'keyvalue', + additionalProperties: true, + properties: { + user_id: { + label: 'User ID', + description: 'Your unique ID for your user', + type: 'string', + required: false + } + }, + default: { + user_id: { '@path': '$.userId' } + } + }, + traits: { + label: 'Traits', + description: 'Additional properties to send with event', + type: 'object', + defaultObjectUI: 'keyvalue', + required: false, + additionalProperties: true, + properties: { + raw_event_name: { + label: 'Raw Event Name', + description: 'Event name', + type: 'string', + required: false + } + }, + default: { + raw_event_name: { '@path': '$.event' } + } + } + }, + + perform: (request, { settings, payload }) => { + return request('https://c.schematichq.com/e', { + method: 'post', + headers: { 'Content-Type': 'application/json;charset=UTF-8' }, + json: { + api_key: `${settings.apiKey}`, + type: 'track', + sent_at: new Date(payload.timestamp).toISOString(), + body: { + traits: payload.traits, + company: payload.company_keys, + user: payload.user_keys, + event: snakeCase(payload.event_name) + } + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/segment-profiles/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/__tests__/__snapshots__/snapshot.test.ts.snap index 0536863506..ccd67bee21 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment-profiles/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,130 +2,230 @@ exports[`Testing snapshot for actions-segment-profiles destination: sendGroup action - all fields 1`] = ` Object { - "anonymousId": "cZE8HyAL0!BF#)WQb^", - "groupId": "cZE8HyAL0!BF#)WQb^", - "integrations": Object { - "All": false, - }, - "traits": Object { - "testType": "cZE8HyAL0!BF#)WQb^", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "cZE8HyAL0!BF#)WQb^", + "groupId": "cZE8HyAL0!BF#)WQb^", + "integrations": Object { + "All": false, + }, + "timestamp": "2021-02-01T00:00:00.000Z", + "traits": Object { + "testType": "cZE8HyAL0!BF#)WQb^", + }, + "type": "group", + "userId": "cZE8HyAL0!BF#)WQb^", + }, + ], }, - "userId": "cZE8HyAL0!BF#)WQb^", + "output": "Action Executed", } `; exports[`Testing snapshot for actions-segment-profiles destination: sendGroup action - required fields 1`] = ` Object { - "anonymousId": "cZE8HyAL0!BF#)WQb^", - "groupId": "cZE8HyAL0!BF#)WQb^", - "integrations": Object { - "All": false, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "cZE8HyAL0!BF#)WQb^", + "groupId": "cZE8HyAL0!BF#)WQb^", + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "group", + "userId": "cZE8HyAL0!BF#)WQb^", + }, + ], }, - "traits": Object {}, - "userId": "cZE8HyAL0!BF#)WQb^", + "output": "Action Executed", } `; exports[`Testing snapshot for actions-segment-profiles destination: sendIdentify action - all fields 1`] = ` Object { - "anonymousId": "hIC1OAmWa[Q!&d%o", - "groupId": "hIC1OAmWa[Q!&d%o", - "integrations": Object { - "All": false, - }, - "traits": Object { - "testType": "hIC1OAmWa[Q!&d%o", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "hIC1OAmWa[Q!&d%o", + "groupId": "hIC1OAmWa[Q!&d%o", + "integrations": Object { + "All": false, + }, + "timestamp": "2021-02-01T00:00:00.000Z", + "traits": Object { + "testType": "hIC1OAmWa[Q!&d%o", + }, + "type": "identify", + "userId": "hIC1OAmWa[Q!&d%o", + }, + ], }, - "userId": "hIC1OAmWa[Q!&d%o", + "output": "Action Executed", } `; exports[`Testing snapshot for actions-segment-profiles destination: sendIdentify action - required fields 1`] = ` Object { - "anonymousId": "hIC1OAmWa[Q!&d%o", - "groupId": "hIC1OAmWa[Q!&d%o", - "integrations": Object { - "All": false, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "hIC1OAmWa[Q!&d%o", + "groupId": "hIC1OAmWa[Q!&d%o", + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": "hIC1OAmWa[Q!&d%o", + }, + ], }, - "traits": Object {}, - "userId": "hIC1OAmWa[Q!&d%o", + "output": "Action Executed", } `; exports[`Testing snapshot for actions-segment-profiles destination: sendSubscription action - all fields 1`] = ` Object { - "context": Object { - "externalIds": Array [ - Object { - "collection": "users", - "encoding": "none", - "id": "tester11@seg.com", - "type": "email", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "+12135618345", - "type": "phone", - }, - ], - "messaging_subscriptions": Array [ - Object { - "key": "tester11@seg.com", - "status": "SUBSCRIBED", - "type": "EMAIL", - }, + "data": Object { + "batch": Array [ Object { - "key": "+12135618345", - "status": "SUBSCRIBED", - "type": "SMS", + "anonymousId": undefined, + "context": Object { + "externalIds": Array [ + Object { + "collection": "users", + "encoding": "none", + "id": "tester11@seg.com", + "type": "email", + }, + Object { + "collection": "users", + "encoding": "none", + "id": "+12135618345", + "type": "phone", + }, + ], + "messaging_subscriptions": Array [ + Object { + "key": "tester11@seg.com", + "status": "SUBSCRIBED", + "type": "EMAIL", + }, + Object { + "key": "+12135618345", + "status": "SUBSCRIBED", + "type": "SMS", + }, + ], + "messaging_subscriptions_retl": true, + }, + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": "user12", }, ], - "messaging_subscriptions_retl": true, }, - "integrations": Object { - "All": false, - }, - "traits": Object {}, - "userId": "user12", + "output": "Action Executed", } `; exports[`Testing snapshot for actions-segment-profiles destination: sendSubscription action - required fields 1`] = ` Object { - "context": Object { - "externalIds": Array [ + "data": Object { + "batch": Array [ Object { - "collection": "users", - "encoding": "none", - "id": "tester11@seg.com", - "type": "email", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "+12135618345", - "type": "phone", + "anonymousId": undefined, + "context": Object { + "externalIds": Array [ + Object { + "collection": "users", + "encoding": "none", + "id": "tester11@seg.com", + "type": "email", + }, + Object { + "collection": "users", + "encoding": "none", + "id": "+12135618345", + "type": "phone", + }, + ], + "messaging_subscriptions": Array [ + Object { + "key": "tester11@seg.com", + "status": "SUBSCRIBED", + "type": "EMAIL", + }, + Object { + "key": "+12135618345", + "status": "SUBSCRIBED", + "type": "SMS", + }, + ], + "messaging_subscriptions_retl": true, + }, + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": "user12", }, ], - "messaging_subscriptions": Array [ + }, + "output": "Action Executed", +} +`; + +exports[`Testing snapshot for actions-segment-profiles destination: sendTrack action - all fields 1`] = ` +Object { + "data": Object { + "batch": Array [ Object { - "key": "tester11@seg.com", - "status": "SUBSCRIBED", - "type": "EMAIL", + "anonymousId": "[2uovRzxThJj", + "event": "[2uovRzxThJj", + "integrations": Object { + "All": false, + }, + "properties": Object { + "testType": "[2uovRzxThJj", + }, + "timestamp": "2021-02-01T00:00:00.000Z", + "type": "track", + "userId": "[2uovRzxThJj", }, + ], + }, + "output": "Action Executed", +} +`; + +exports[`Testing snapshot for actions-segment-profiles destination: sendTrack action - required fields 1`] = ` +Object { + "data": Object { + "batch": Array [ Object { - "key": "+12135618345", - "status": "SUBSCRIBED", - "type": "SMS", + "anonymousId": "[2uovRzxThJj", + "event": "[2uovRzxThJj", + "integrations": Object { + "All": false, + }, + "properties": Object {}, + "timestamp": undefined, + "type": "track", + "userId": "[2uovRzxThJj", }, ], - "messaging_subscriptions_retl": true, - }, - "integrations": Object { - "All": false, }, - "traits": Object {}, - "userId": "user12", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment-profiles/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment-profiles/__tests__/snapshot.test.ts index 2002beb82a..f805302f80 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/__tests__/snapshot.test.ts @@ -1,7 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../lib/test-data' import destination from '../index' -import { DEFAULT_SEGMENT_ENDPOINT } from '../properties' import nock from 'nock' const testDestination = createTestIntegration(destination) @@ -14,9 +13,6 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) let event if (actionSlug === 'sendSubscription') { event = createTestEvent({ @@ -35,25 +31,15 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { }) } - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it(`${actionSlug} action - all fields`, async () => { @@ -83,23 +69,15 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { }) } - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) } }) diff --git a/packages/destination-actions/src/destinations/segment-profiles/generated-types.ts b/packages/destination-actions/src/destinations/segment-profiles/generated-types.ts index 7835f66644..4ab2786ec6 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/generated-types.ts @@ -1,8 +1,3 @@ // Generated file. DO NOT MODIFY IT BY HAND. -export interface Settings { - /** - * The region to send your data. - */ - endpoint: string -} +export interface Settings {} diff --git a/packages/destination-actions/src/destinations/segment-profiles/index.ts b/packages/destination-actions/src/destinations/segment-profiles/index.ts index 55c1edec1b..69b6e94159 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/index.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/index.ts @@ -1,38 +1,20 @@ import type { DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' -import { DEFAULT_SEGMENT_ENDPOINT, SEGMENT_ENDPOINTS } from './properties' import sendGroup from './sendGroup' - import sendIdentify from './sendIdentify' - import sendSubscription from './sendSubscription' +import sendTrack from './sendTrack' const destination: DestinationDefinition = { name: 'Segment Profiles', slug: 'actions-segment-profiles', mode: 'cloud', - authentication: { - scheme: 'custom', - fields: { - endpoint: { - label: 'Endpoint Region', - description: 'The region to send your data.', - type: 'string', - format: 'text', - choices: Object.keys(SEGMENT_ENDPOINTS).map((key) => ({ - label: SEGMENT_ENDPOINTS[key].label, - value: key - })), - default: DEFAULT_SEGMENT_ENDPOINT, - required: true - } - } - }, actions: { sendGroup, sendIdentify, - sendSubscription + sendSubscription, + sendTrack } } diff --git a/packages/destination-actions/src/destinations/segment-profiles/segment-properties.ts b/packages/destination-actions/src/destinations/segment-profiles/segment-properties.ts index eef1f1587c..11632fd992 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/segment-properties.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/segment-properties.ts @@ -35,3 +35,27 @@ export const engage_space: InputField = { required: true, dynamic: true } + +export const timestamp: InputField = { + label: 'Timestamp', + description: 'The timestamp of the event.', + type: 'datetime', + default: { + '@path': '$.timestamp' + } +} + +export const event_name: InputField = { + label: 'Event Name', + description: 'Name of the action that a user has performed.', + type: 'string', + required: true +} + +export const properties: InputField = { + label: 'Properties', + description: 'Free-form dictionary of properties that describe the event.', + type: 'object', + defaultObjectUI: 'keyvalue', + additionalProperties: true +} diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/index.test.ts.snap index a29b2058a8..7a04055910 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/index.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SegmentProfiles.sendGroup Should not send event if actions-segment-profiles-tapi-internal-enabled flag is enabled 1`] = ` +exports[`SegmentProfiles.sendGroup Should return transformed event 1`] = ` Object { "batch": Array [ Object { @@ -9,6 +9,7 @@ Object { "integrations": Object { "All": false, }, + "timestamp": "2023-09-26T09:46:28.290Z", "traits": Object { "industry": "Technology", "name": "Example Corp", @@ -19,31 +20,3 @@ Object { ], } `; - -exports[`SegmentProfiles.sendGroup Should send an group event to Segment 1`] = ` -Headers { - Symbol(map): Object { - "authorization": Array [ - "Basic ZW5nYWdlLXNwYWNlLXdyaXRla2V5Og==", - ], - "user-agent": Array [ - "Segment (Actions)", - ], - }, -} -`; - -exports[`SegmentProfiles.sendGroup Should send an group event to Segment 2`] = ` -Object { - "anonymousId": "arky4h2sh7k", - "groupId": "test-group-ks2i7e", - "integrations": Object { - "All": false, - }, - "traits": Object { - "industry": "Technology", - "name": "Example Corp", - }, - "userId": "test-user-ufi5bgkko5", -} -`; diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap index 093f9ffd0b..7a6eb8d43d 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,26 +2,44 @@ exports[`Testing snapshot for SegmentProfiles's sendGroup destination action: all fields 1`] = ` Object { - "anonymousId": "tKaa(2A", - "groupId": "tKaa(2A", - "integrations": Object { - "All": false, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "tKaa(2A", + "groupId": "tKaa(2A", + "integrations": Object { + "All": false, + }, + "timestamp": "2021-02-01T00:00:00.000Z", + "traits": Object { + "testType": "tKaa(2A", + }, + "type": "group", + "userId": "tKaa(2A", + }, + ], }, - "traits": Object { - "testType": "tKaa(2A", - }, - "userId": "tKaa(2A", + "output": "Action Executed", } `; exports[`Testing snapshot for SegmentProfiles's sendGroup destination action: required fields 1`] = ` Object { - "anonymousId": "tKaa(2A", - "groupId": "tKaa(2A", - "integrations": Object { - "All": false, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "tKaa(2A", + "groupId": "tKaa(2A", + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "group", + "userId": "tKaa(2A", + }, + ], }, - "traits": Object {}, - "userId": "tKaa(2A", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/index.test.ts index 887a001ccd..9efa42d3b8 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/index.test.ts @@ -1,8 +1,8 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../../errors' -import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../../properties' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' +import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' const testDestination = createTestIntegration(Destination) @@ -22,7 +22,10 @@ const defaultGroupMapping = { traits: { '@path': '$.traits' }, - engage_space: 'engage-space-writekey' + engage_space: 'engage-space-writekey', + timestamp: { + '@path': '$.timestamp' + } } describe('SegmentProfiles.sendGroup', () => { @@ -47,33 +50,8 @@ describe('SegmentProfiles.sendGroup', () => { }) ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { - const event = createTestEvent({ - traits: { - name: 'Example Corp', - industry: 'Technology' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k', - groupId: 'test-group-ks2i7e' - }) - - await expect( - testDestination.testAction('sendGroup', { - event, - mapping: defaultGroupMapping, - settings: { - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - - test('Should send an group event to Segment', async () => { - // Mock: Segment Group Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/group').reply(200, { success: true }) + test('Should return transformed event', async () => { const event = createTestEvent({ traits: { name: 'Example Corp', @@ -81,7 +59,8 @@ describe('SegmentProfiles.sendGroup', () => { }, userId: 'test-user-ufi5bgkko5', anonymousId: 'arky4h2sh7k', - groupId: 'test-group-ks2i7e' + groupId: 'test-group-ks2i7e', + timestamp: '2023-09-26T09:46:28.290Z' }) const responses = await testDestination.testAction('sendGroup', { @@ -92,34 +71,6 @@ describe('SegmentProfiles.sendGroup', () => { } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.headers).toMatchSnapshot() - expect(responses[0].options.json).toMatchSnapshot() - }) - - test('Should not send event if actions-segment-profiles-tapi-internal-enabled flag is enabled', async () => { - const event = createTestEvent({ - traits: { - name: 'Example Corp', - industry: 'Technology' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k', - groupId: 'test-group-ks2i7e' - }) - - const responses = await testDestination.testAction('sendGroup', { - event, - mapping: defaultGroupMapping, - settings: { - endpoint: DEFAULT_SEGMENT_ENDPOINT - }, - features: { - 'actions-segment-profiles-tapi-internal-enabled': true - } - }) - const results = testDestination.results expect(responses.length).toBe(0) diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/snapshot.test.ts index 95f1fcc631..a177c15d3f 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/__tests__/snapshot.test.ts @@ -1,8 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' -import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import nock from 'nock' const testDestination = createTestIntegration(destination) const actionSlug = 'sendGroup' @@ -14,63 +12,37 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/generated-types.ts b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/generated-types.ts index b16e6a4c9f..9b08b43059 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/generated-types.ts @@ -23,4 +23,8 @@ export interface Payload { traits?: { [k: string]: unknown } + /** + * The timestamp of the event. + */ + timestamp?: string | number } diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/index.ts b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/index.ts index aecdafc7cd..923684aa88 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendGroup/index.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendGroup/index.ts @@ -1,10 +1,8 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_id, anonymous_id, group_id, traits, engage_space } from '../segment-properties' -import { generateSegmentAPIAuthHeaders } from '../helperFunctions' -import { SEGMENT_ENDPOINTS } from '../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../errors' +import { user_id, anonymous_id, group_id, traits, engage_space, timestamp } from '../segment-properties' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' const action: ActionDefinition = { title: 'Send Group', @@ -15,9 +13,10 @@ const action: ActionDefinition = { user_id, anonymous_id, group_id: { ...group_id, required: true }, - traits + traits, + timestamp }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { if (!payload.anonymous_id && !payload.user_id) { throw MissingUserOrAnonymousIdThrowableError } @@ -28,32 +27,17 @@ const action: ActionDefinition = { traits: { ...payload?.traits }, + timestamp: payload?.timestamp, integrations: { // Setting 'integrations.All' to false will ensure that we don't send events // to any destinations which is connected to the Segment Profiles space. All: false - } - } - - // Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - throw InvalidEndpointSelectedThrowableError - } - - if (features && features['actions-segment-profiles-tapi-internal-enabled']) { - statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, `action:sendGroup`]) - const payload = { ...groupPayload, type: 'group' } - return { batch: [payload] } + }, + type: 'group' } - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url - return request(`${selectedSegmentEndpoint}/group`, { - method: 'POST', - json: groupPayload, - headers: { - authorization: generateSegmentAPIAuthHeaders(payload.engage_space) - } - }) + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, `action:sendGroup`]) + return { batch: [groupPayload] } } } diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/index.test.ts.snap index 5fea1f8ff3..4384d14897 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/index.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Segment.sendIdentify Should not send event if actions-segment-profiles-tapi-internal-enabled flag is enabled 1`] = ` +exports[`Segment.sendIdentify Should return transformed event 1`] = ` Object { "batch": Array [ Object { @@ -9,6 +9,7 @@ Object { "integrations": Object { "All": false, }, + "timestamp": "2023-09-26T09:46:28.290Z", "traits": Object { "email": "test-user@test-company.com", "name": "Test User", @@ -19,31 +20,3 @@ Object { ], } `; - -exports[`Segment.sendIdentify Should send an identify event to Segment 1`] = ` -Headers { - Symbol(map): Object { - "authorization": Array [ - "Basic ZW5nYWdlLXNwYWNlLXdyaXRla2V5Og==", - ], - "user-agent": Array [ - "Segment (Actions)", - ], - }, -} -`; - -exports[`Segment.sendIdentify Should send an identify event to Segment 2`] = ` -Object { - "anonymousId": "arky4h2sh7k", - "groupId": undefined, - "integrations": Object { - "All": false, - }, - "traits": Object { - "email": "test-user@test-company.com", - "name": "Test User", - }, - "userId": "test-user-ufi5bgkko5", -} -`; diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap index d0706d1f1e..ef15b82879 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,26 +2,44 @@ exports[`Testing snapshot for SegmentProfiles's sendIdentify destination action: all fields 1`] = ` Object { - "anonymousId": "mV[ZQcEVgZO$MX", - "groupId": "mV[ZQcEVgZO$MX", - "integrations": Object { - "All": false, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "mV[ZQcEVgZO$MX", + "groupId": "mV[ZQcEVgZO$MX", + "integrations": Object { + "All": false, + }, + "timestamp": "2021-02-01T00:00:00.000Z", + "traits": Object { + "testType": "mV[ZQcEVgZO$MX", + }, + "type": "identify", + "userId": "mV[ZQcEVgZO$MX", + }, + ], }, - "traits": Object { - "testType": "mV[ZQcEVgZO$MX", - }, - "userId": "mV[ZQcEVgZO$MX", + "output": "Action Executed", } `; exports[`Testing snapshot for SegmentProfiles's sendIdentify destination action: required fields 1`] = ` Object { - "anonymousId": "mV[ZQcEVgZO$MX", - "groupId": "mV[ZQcEVgZO$MX", - "integrations": Object { - "All": false, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "mV[ZQcEVgZO$MX", + "groupId": "mV[ZQcEVgZO$MX", + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": "mV[ZQcEVgZO$MX", + }, + ], }, - "traits": Object {}, - "userId": "mV[ZQcEVgZO$MX", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/index.test.ts index 97786f8f97..3737d741bd 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/index.test.ts @@ -1,8 +1,8 @@ import nock from 'nock' import Destination from '../..' import { createTestEvent, createTestIntegration } from '@segment/actions-core' -import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../../errors' +import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' const testDestination = createTestIntegration(Destination) @@ -19,7 +19,10 @@ const defaultIdentifyMapping = { traits: { '@path': '$.traits' }, - engage_space: 'engage-space-writekey' + engage_space: 'engage-space-writekey', + timestamp: { + '@path': '$.timestamp' + } } describe('Segment.sendIdentify', () => { @@ -42,7 +45,7 @@ describe('Segment.sendIdentify', () => { ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { + test('Should return transformed event', async () => { const event = createTestEvent({ type: 'identify', traits: { @@ -50,33 +53,8 @@ describe('Segment.sendIdentify', () => { email: 'test-user@test-company.com' }, userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) - - await expect( - testDestination.testAction('sendIdentify', { - event, - mapping: defaultIdentifyMapping, - settings: { - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - - test('Should send an identify event to Segment', async () => { - // Mock: Segment Identify Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/identify').reply(200, { success: true }) - - const event = createTestEvent({ - type: 'identify', - traits: { - name: 'Test User', - email: 'test-user@test-company.com' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' + anonymousId: 'arky4h2sh7k', + timestamp: '2023-09-26T09:46:28.290Z' }) const responses = await testDestination.testAction('sendIdentify', { @@ -86,34 +64,6 @@ describe('Segment.sendIdentify', () => { endpoint: DEFAULT_SEGMENT_ENDPOINT } }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.headers).toMatchSnapshot() - expect(responses[0].options.json).toMatchSnapshot() - }) - - test('Should not send event if actions-segment-profiles-tapi-internal-enabled flag is enabled', async () => { - const event = createTestEvent({ - type: 'identify', - traits: { - name: 'Test User', - email: 'test-user@test-company.com' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) - - const responses = await testDestination.testAction('sendIdentify', { - event, - mapping: defaultIdentifyMapping, - settings: { - endpoint: DEFAULT_SEGMENT_ENDPOINT - }, - features: { - 'actions-segment-profiles-tapi-internal-enabled': true - } - }) const results = testDestination.results expect(responses.length).toBe(0) diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/snapshot.test.ts index 655e9bc7e2..eff7647297 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/__tests__/snapshot.test.ts @@ -1,8 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' -import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import nock from 'nock' const testDestination = createTestIntegration(destination) const actionSlug = 'sendIdentify' @@ -14,63 +12,37 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [eventData, settingsData] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/generated-types.ts b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/generated-types.ts index 5afd99d1f6..c8c5e30af9 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/generated-types.ts @@ -23,4 +23,8 @@ export interface Payload { traits?: { [k: string]: unknown } + /** + * The timestamp of the event. + */ + timestamp?: string | number } diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/index.ts b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/index.ts index 9b8bc56578..5f91cd3047 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/index.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendIdentify/index.ts @@ -1,10 +1,8 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { user_id, anonymous_id, group_id, traits, engage_space } from '../segment-properties' -import { generateSegmentAPIAuthHeaders } from '../helperFunctions' -import { SEGMENT_ENDPOINTS } from '../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../errors' +import { user_id, anonymous_id, group_id, traits, engage_space, timestamp } from '../segment-properties' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' const action: ActionDefinition = { title: 'Send Identify', @@ -16,9 +14,10 @@ const action: ActionDefinition = { user_id, anonymous_id, group_id, - traits + traits, + timestamp }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { if (!payload.anonymous_id && !payload.user_id) { throw MissingUserOrAnonymousIdThrowableError } @@ -29,32 +28,17 @@ const action: ActionDefinition = { traits: { ...payload?.traits }, + timestamp: payload?.timestamp, integrations: { // Setting 'integrations.All' to false will ensure that we don't send events // to any destinations which is connected to the Segment Profiles space. All: false - } - } - - // Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - throw InvalidEndpointSelectedThrowableError - } - - if (features && features['actions-segment-profiles-tapi-internal-enabled']) { - statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, `action:sendIdentify`]) - const payload = { ...identityPayload, type: 'identify' } - return { batch: [payload] } + }, + type: 'identify' } - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url - return request(`${selectedSegmentEndpoint}/identify`, { - method: 'POST', - json: identityPayload, - headers: { - authorization: generateSegmentAPIAuthHeaders(payload.engage_space) - } - }) + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, `action:sendIdentify`]) + return { batch: [identityPayload] } } } diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/index.test.ts.snap index 3e11f8a54a..7ebee29295 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/index.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`SegmentProfiles.sendSubscription Should not send event if actions-segment-profiles-tapi-internal-enabled flag is enabled 1`] = ` +exports[`SegmentProfiles.sendSubscription Should return transformed event 1`] = ` Object { "batch": Array [ Object { @@ -64,6 +64,7 @@ Object { "integrations": Object { "All": false, }, + "timestamp": "2023-10-10T07:24:07.036Z", "traits": Object { "email": "test-user@test-company.com", "name": "Test User", @@ -75,141 +76,51 @@ Object { } `; -exports[`SegmentProfiles.sendSubscription Should send a subscription event to Segment 1`] = ` -Headers { - Symbol(map): Object { - "authorization": Array [ - "Basic ZW5nYWdlLXNwYWNlLXdyaXRla2V5Og==", - ], - "user-agent": Array [ - "Segment (Actions)", - ], - }, -} -`; - -exports[`SegmentProfiles.sendSubscription Should send a subscription event to Segment 2`] = ` -Object { - "anonymousId": "anonId1234", - "context": Object { - "externalIds": Array [ - Object { - "collection": "users", - "encoding": "none", - "id": "tester11@seg.com", - "type": "email", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "+12135618345", - "type": "phone", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "abcd12bbfygdbvbvvvv", - "type": "android.push_token", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "abcd12bbfjfsykdbvbvvvvvv", - "type": "ios.push_token", - }, - ], - "messaging_subscriptions": Array [ - Object { - "key": "tester11@seg.com", - "status": "SUBSCRIBED", - "type": "EMAIL", - }, - Object { - "key": "+12135618345", - "status": "SUBSCRIBED", - "type": "SMS", - }, - Object { - "key": "+12135618345", - "status": "SUBSCRIBED", - "type": "WHATSAPP", - }, - Object { - "key": "abcd12bbfygdbvbvvvv", - "status": "UNSUBSCRIBED", - "type": "ANDROID_PUSH", - }, - Object { - "key": "abcd12bbfjfsykdbvbvvvvvv", - "status": "SUBSCRIBED", - "type": "IOS_PUSH", - }, - ], - "messaging_subscriptions_retl": true, - }, - "integrations": Object { - "All": false, - }, - "traits": Object { - "email": "test-user@test-company.com", - "name": "Test User", - }, - "userId": "user1234", -} -`; - -exports[`SegmentProfiles.sendSubscription Should send a subscription event to Segment when subscription groups are defined 1`] = ` -Headers { - Symbol(map): Object { - "authorization": Array [ - "Basic ZW5nYWdlLXNwYWNlLXdyaXRla2V5Og==", - ], - "user-agent": Array [ - "Segment (Actions)", - ], - }, -} -`; - -exports[`SegmentProfiles.sendSubscription Should send a subscription event to Segment when subscription groups are defined 2`] = ` +exports[`SegmentProfiles.sendSubscription Should return transformed event when subscription groups are defined 1`] = ` Object { - "anonymousId": "anonId1234", - "context": Object { - "externalIds": Array [ - Object { - "collection": "users", - "encoding": "none", - "id": "tester11@seg.com", - "type": "email", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "abcd12bbfjfsykdbvbvvvvvv", - "type": "ios.push_token", + "batch": Array [ + Object { + "anonymousId": "anonId1234", + "context": Object { + "externalIds": Array [ + Object { + "collection": "users", + "encoding": "none", + "id": "tester11@seg.com", + "type": "email", + }, + Object { + "collection": "users", + "encoding": "none", + "id": "abcd12bbfjfsykdbvbvvvvvv", + "type": "ios.push_token", + }, + ], + "messaging_subscriptions": Array [ + Object { + "key": "tester11@seg.com", + "status": "SUBSCRIBED", + "type": "EMAIL", + }, + Object { + "key": "abcd12bbfjfsykdbvbvvvvvv", + "status": "SUBSCRIBED", + "type": "IOS_PUSH", + }, + ], + "messaging_subscriptions_retl": true, }, - ], - "messaging_subscriptions": Array [ - Object { - "key": "tester11@seg.com", - "status": "SUBSCRIBED", - "type": "EMAIL", + "integrations": Object { + "All": false, }, - Object { - "key": "abcd12bbfjfsykdbvbvvvvvv", - "status": "SUBSCRIBED", - "type": "IOS_PUSH", + "timestamp": "2023-10-10T07:24:07.036Z", + "traits": Object { + "email": "test-user@test-company.com", + "name": "Test User", }, - ], - "messaging_subscriptions_retl": true, - }, - "integrations": Object { - "All": false, - }, - "traits": Object { - "email": "test-user@test-company.com", - "name": "Test User", - }, - "userId": "user1234", + "type": "identify", + "userId": "user1234", + }, + ], } `; diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/snapshot.test.ts.snap index 77eb347c5d..99e96c77ca 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,118 +2,139 @@ exports[`Testing snapshot for SegmentProfiles's sendSubscription destination action: all fields 1`] = ` Object { - "context": Object { - "externalIds": Array [ + "data": Object { + "batch": Array [ Object { - "collection": "users", - "encoding": "none", - "id": "tester11@seg.com", - "type": "email", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "+12135618345", - "type": "phone", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "abcd12bbfygdbvbvvvv", - "type": "android.push_token", - }, - Object { - "collection": "users", - "encoding": "none", - "id": "abcd12bbfjfsykdbvbvvvvvv", - "type": "ios.push_token", - }, - ], - "messaging_subscriptions": Array [ - Object { - "groups": Array [ - Object { - "name": "marketing", - "status": "SUBSCRIBED", - }, - Object { - "name": "ProductUpdates", - }, - Object { - "name": "newsletter", - "status": "UNSUBSCRIBED", - }, - ], - "key": "tester11@seg.com", - "status": "SUBSCRIBED", - "type": "EMAIL", - }, - Object { - "key": "+12135618345", - "status": "SUBSCRIBED", - "type": "SMS", - }, - Object { - "key": "+12135618345", - "status": "SUBSCRIBED", - "type": "WHATSAPP", - }, - Object { - "key": "abcd12bbfygdbvbvvvv", - "status": "UNSUBSCRIBED", - "type": "ANDROID_PUSH", - }, - Object { - "key": "abcd12bbfjfsykdbvbvvvvvv", - "status": "SUBSCRIBED", - "type": "IOS_PUSH", + "anonymousId": undefined, + "context": Object { + "externalIds": Array [ + Object { + "collection": "users", + "encoding": "none", + "id": "tester11@seg.com", + "type": "email", + }, + Object { + "collection": "users", + "encoding": "none", + "id": "+12135618345", + "type": "phone", + }, + Object { + "collection": "users", + "encoding": "none", + "id": "abcd12bbfygdbvbvvvv", + "type": "android.push_token", + }, + Object { + "collection": "users", + "encoding": "none", + "id": "abcd12bbfjfsykdbvbvvvvvv", + "type": "ios.push_token", + }, + ], + "messaging_subscriptions": Array [ + Object { + "groups": Array [ + Object { + "name": "marketing", + "status": "SUBSCRIBED", + }, + Object { + "name": "ProductUpdates", + "status": undefined, + }, + Object { + "name": "newsletter", + "status": "UNSUBSCRIBED", + }, + ], + "key": "tester11@seg.com", + "status": "SUBSCRIBED", + "type": "EMAIL", + }, + Object { + "key": "+12135618345", + "status": "SUBSCRIBED", + "type": "SMS", + }, + Object { + "key": "+12135618345", + "status": "SUBSCRIBED", + "type": "WHATSAPP", + }, + Object { + "key": "abcd12bbfygdbvbvvvv", + "status": "UNSUBSCRIBED", + "type": "ANDROID_PUSH", + }, + Object { + "key": "abcd12bbfjfsykdbvbvvvvvv", + "status": "SUBSCRIBED", + "type": "IOS_PUSH", + }, + ], + "messaging_subscriptions_retl": true, + }, + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": "user1234", }, ], - "messaging_subscriptions_retl": true, }, - "integrations": Object { - "All": false, - }, - "traits": Object {}, - "userId": "user1234", + "output": "Action Executed", } `; exports[`Testing snapshot for SegmentProfiles's sendSubscription destination action: required fields 1`] = ` Object { - "context": Object { - "externalIds": Array [ - Object { - "collection": "users", - "encoding": "none", - "id": "tester11@seg.com", - "type": "email", - }, + "data": Object { + "batch": Array [ Object { - "collection": "users", - "encoding": "none", - "id": "+12135618345", - "type": "phone", + "anonymousId": undefined, + "context": Object { + "externalIds": Array [ + Object { + "collection": "users", + "encoding": "none", + "id": "tester11@seg.com", + "type": "email", + }, + Object { + "collection": "users", + "encoding": "none", + "id": "+12135618345", + "type": "phone", + }, + ], + "messaging_subscriptions": Array [ + Object { + "key": "tester11@seg.com", + "status": "SUBSCRIBED", + "type": "EMAIL", + }, + Object { + "key": "+12135618345", + "status": "SUBSCRIBED", + "type": "SMS", + }, + ], + "messaging_subscriptions_retl": true, + }, + "integrations": Object { + "All": false, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": "user12", }, ], - "messaging_subscriptions": Array [ - Object { - "key": "tester11@seg.com", - "status": "SUBSCRIBED", - "type": "EMAIL", - }, - Object { - "key": "+12135618345", - "status": "SUBSCRIBED", - "type": "SMS", - }, - ], - "messaging_subscriptions_retl": true, - }, - "integrations": Object { - "All": false, }, - "traits": Object {}, - "userId": "user12", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/index.test.ts index d2a1873847..e20d6df278 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/index.test.ts @@ -2,12 +2,11 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Destination from '../../index' import { - InvalidEndpointSelectedThrowableError, MissingExternalIdsError, MissingIosPushTokenIfIosPushSubscriptionIsPresentError, MissingSubscriptionStatusesError } from '../../errors' -import { DEFAULT_SEGMENT_ENDPOINT, SEGMENT_ENDPOINTS } from '../../properties' +import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' const testDestination = createTestIntegration(Destination) @@ -54,7 +53,10 @@ export const defaultSubscriptionMapping = { android_push_subscription_status: { '@path': '$.properties.android_push_subscription_status' }, - engage_space: 'engage-space-writekey' + engage_space: 'engage-space-writekey', + timestamp: { + '@path': '$.timestamp' + } } describe('SegmentProfiles.sendSubscription', () => { test('Should throw an error if `userId` or `anonymousId` is not defined', async () => { @@ -83,30 +85,6 @@ describe('SegmentProfiles.sendSubscription', () => { ).rejects.toThrowError(MissingExternalIdsError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { - const event = createTestEvent({ - type: 'identify', - traits: { - name: 'Test User', - email: 'test-user@test-company.com' - }, - properties: { - email: 'tester11@seg.com', - email_subscription_status: 'unsubscribed' - } - }) - - await expect( - testDestination.testAction('sendSubscription', { - event, - mapping: defaultSubscriptionMapping, - settings: { - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - test('Should throw an error if `email` or `phone` or `Android_Push_Token` or `Ios_Push_Token` is not defined', async () => { const event = createTestEvent({ traits: { @@ -175,15 +153,13 @@ describe('SegmentProfiles.sendSubscription', () => { ).rejects.toThrowError(MissingIosPushTokenIfIosPushSubscriptionIsPresentError) }) - test('Should send a subscription event to Segment when subscription groups are defined', async () => { - // Mock: Segment Identify Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/identify').reply(200, { success: true }) + test('Should return transformed event when subscription groups are defined', async () => { const event = createTestEvent({ traits: { name: 'Test User', email: 'test-user@test-company.com' }, + timestamp: '2023-10-10T07:24:07.036Z', properties: { email: 'tester11@seg.com', email_subscription_status: 'true', @@ -205,60 +181,20 @@ describe('SegmentProfiles.sendSubscription', () => { } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.headers).toMatchSnapshot() - expect(responses[0].options.json).toMatchSnapshot() - }) - - test('Should send a subscription event to Segment', async () => { - // Mock: Segment Identify Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/identify').reply(200, { success: true }) - - const event = createTestEvent({ - traits: { - name: 'Test User', - email: 'test-user@test-company.com' - }, - properties: { - email: 'tester11@seg.com', - email_subscription_status: 'true', - phone: '+12135618345', - sms_subscription_status: 'true', - whatsapp_subscription_status: 'true', - subscription_groups: { - marketing: 'true', - ProductUpdates: '', - newsletter: 'false' - }, - android_push_token: 'abcd12bbfygdbvbvvvv', - android_push_subscription_status: 'false', - ios_push_token: 'abcd12bbfjfsykdbvbvvvvvv', - ios_push_subscription_status: 'true' - } - }) - - const responses = await testDestination.testAction('sendSubscription', { - event, - mapping: defaultSubscriptionMapping, - settings: { - endpoint: DEFAULT_SEGMENT_ENDPOINT - } - }) + const results = testDestination.results - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.headers).toMatchSnapshot() - expect(responses[0].options.json).toMatchSnapshot() + expect(responses.length).toBe(0) + expect(results.length).toBe(3) + expect(results[2].data).toMatchSnapshot() }) - test('Should not send event if actions-segment-profiles-tapi-internal-enabled flag is enabled', async () => { + test('Should return transformed event', async () => { const event = createTestEvent({ traits: { name: 'Test User', email: 'test-user@test-company.com' }, + timestamp: '2023-10-10T07:24:07.036Z', properties: { email: 'tester11@seg.com', email_subscription_status: 'true', diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/snapshot.test.ts index 9bec66ecb7..33ec2519b7 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/__tests__/snapshot.test.ts @@ -1,8 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' -import nock from 'nock' -import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' const testDestination = createTestIntegration(destination) const actionSlug = 'sendSubscription' @@ -14,10 +12,6 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [settingsData] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: { email: 'tester11@seg.com', @@ -28,35 +22,22 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac user_id: 'user12' } }) - const responses = await testDestination.testAction(actionSlug, { + + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [settingsData] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: { email: 'tester11@seg.com', @@ -78,22 +59,14 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac } }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, - settings: { ...settingsData, endpoint: DEFAULT_SEGMENT_ENDPOINT }, + settings: { ...settingsData }, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/generated-types.ts b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/generated-types.ts index 42a6ef59da..0e89615675 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/generated-types.ts @@ -18,11 +18,11 @@ export interface Payload { */ email?: string /** - * Global status of the email subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe. + * Global status of the email subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe. */ email_subscription_status?: string | null /** - * Subscription status for the groups. Object containing group names as keys and statuses as values. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe. + * Group Subscription statuses are supported for the email channel. This object contains group names as keys and statuses as values. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe. */ subscription_groups?: { [k: string]: unknown @@ -32,11 +32,11 @@ export interface Payload { */ phone?: string /** - * Global status of the SMS subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe. + * Global status of the SMS subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe. */ sms_subscription_status?: string | null /** - * Global status of the WhatsApp subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe. + * Global status of the WhatsApp subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe. */ whatsapp_subscription_status?: string | null /** @@ -44,7 +44,7 @@ export interface Payload { */ android_push_token?: string /** - * Global status of the android push subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe. + * Global status of the android push subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe. */ android_push_subscription_status?: string | null /** @@ -52,7 +52,7 @@ export interface Payload { */ ios_push_token?: string /** - * Global status of the ios push subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe. + * Global status of the ios push subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe. */ ios_push_subscription_status?: string | null /** @@ -61,4 +61,8 @@ export interface Payload { traits?: { [k: string]: unknown } + /** + * The timestamp of the event. + */ + timestamp?: string | number } diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/index.ts b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/index.ts index cd91d4b1a1..2031b51103 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/index.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/index.ts @@ -18,7 +18,6 @@ import { ios_push_token } from './subscription-properties' import { - InvalidEndpointSelectedThrowableError, InvalidSubscriptionStatusError, MissingExternalIdsError, MissingSubscriptionStatusesError, @@ -28,8 +27,7 @@ import { MissingIosPushTokenIfIosPushSubscriptionIsPresentError, MissingPhoneIfSmsOrWhatsappSubscriptionIsPresentError } from '../errors' -import { generateSegmentAPIAuthHeaders } from '../helperFunctions' -import { SEGMENT_ENDPOINTS } from '../properties' +import { timestamp } from '../segment-properties' import { StatsClient } from '@segment/actions-core/destination-kit' interface SubscriptionStatusConfig { @@ -40,7 +38,7 @@ interface SubscriptionStatusConfig { const subscriptionStatusConfig: SubscriptionStatusConfig[] = [ { status: 'SUBSCRIBED', matchingStatuses: ['true', 'subscribed'] }, { status: 'UNSUBSCRIBED', matchingStatuses: ['false', 'unsubscribed'] }, - { status: 'DID-NOT-SUBSCRIBE', matchingStatuses: ['did-not-subscribe', 'did_not_subscribe'] } + { status: 'DID_NOT_SUBSCRIBE', matchingStatuses: ['did-not-subscribe', 'did_not_subscribe'] } ] interface SupportedChannelsConfig { @@ -279,17 +277,14 @@ const action: ActionDefinition = { android_push_subscription_status, ios_push_token, ios_push_subscription_status, - traits + traits, + timestamp }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { const statsClient = statsContext?.statsClient const tags = statsContext?.tags tags?.push(`action:sendSubscription`) - //Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - statsClient?.incr('invalid_endpoint', 1, tags) - throw InvalidEndpointSelectedThrowableError - } + // Before sending subscription data to Segment, a series of validations are done. validateSubscriptions(payload, statsClient, tags) // Enriches ExternalId's @@ -307,29 +302,18 @@ const action: ActionDefinition = { externalIds, messaging_subscriptions_retl: true }, + timestamp: payload?.timestamp, integrations: { // Setting 'integrations.All' to false will ensure that we don't send events // to any destinations which is connected to the Segment Profiles space All: false - } + }, + type: 'identify' } statsClient?.incr('success', 1, tags) - if (features && features['actions-segment-profiles-tapi-internal-enabled']) { - statsClient?.incr('tapi_internal', 1, tags) - const payload = { ...subscriptionPayload, type: 'identify' } - return { batch: [payload] } - } - - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url - - return request(`${selectedSegmentEndpoint}/identify`, { - method: 'POST', - json: subscriptionPayload, - headers: { - authorization: generateSegmentAPIAuthHeaders(payload.engage_space) - } - }) + statsClient?.incr('tapi_internal', 1, tags) + return { batch: [subscriptionPayload] } } } diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/subscription-properties.ts b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/subscription-properties.ts index 72aa998649..75d1acec07 100644 --- a/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/subscription-properties.ts +++ b/packages/destination-actions/src/destinations/segment-profiles/sendSubscription/subscription-properties.ts @@ -57,7 +57,7 @@ export const ios_push_token: InputField = { export const email_subscription_status: InputField = { label: 'Email Subscription Status', description: - 'Global status of the email subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe.', + 'Global status of the email subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe.', type: 'string', allowNull: true } @@ -65,7 +65,7 @@ export const email_subscription_status: InputField = { export const sms_subscription_status: InputField = { label: 'SMS Subscription Status', description: - 'Global status of the SMS subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe.', + 'Global status of the SMS subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe.', type: 'string', allowNull: true } @@ -73,7 +73,7 @@ export const sms_subscription_status: InputField = { export const whatsapp_subscription_status: InputField = { label: 'WhatsApp Subscription Status', description: - 'Global status of the WhatsApp subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe.', + 'Global status of the WhatsApp subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe.', type: 'string', allowNull: true } @@ -81,7 +81,7 @@ export const whatsapp_subscription_status: InputField = { export const android_push_subscription_status: InputField = { label: 'Android Push Subscription Status', description: - 'Global status of the android push subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe.', + 'Global status of the android push subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe.', type: 'string', allowNull: true } @@ -89,15 +89,15 @@ export const android_push_subscription_status: InputField = { export const ios_push_subscription_status: InputField = { label: 'Ios Push Subscription Status', description: - 'Global status of the ios push subscription. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe.', + 'Global status of the ios push subscription. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe.', type: 'string', allowNull: true } export const subscription_groups: InputField = { - label: 'Subscription Groups', + label: 'Email Subscription Groups', description: - 'Subscription status for the groups. Object containing group names as keys and statuses as values. True is subscribed, false is unsubscribed and did-not-subscribe is did-not-subscribe.', + 'Group Subscription statuses are supported for the email channel. This object contains group names as keys and statuses as values. True is subscribed, false is unsubscribed, and did_not_subscribe is did_not_subscribe.', type: 'object', additionalProperties: true, defaultObjectUI: 'keyvalue' diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendTrack/__tests__/__snapshots__/index.test.ts.snap b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/__tests__/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000..63c3a34adc --- /dev/null +++ b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SegmentProfiles.sendTrack Should return transformed segment track event 1`] = ` +Object { + "batch": Array [ + Object { + "anonymousId": "arky4h2sh7k", + "event": "Test Event", + "integrations": Object { + "All": false, + }, + "properties": Object { + "plan": "Business", + }, + "timestamp": undefined, + "type": "track", + "userId": "test-user-ufi5bgkko5", + }, + ], +} +`; diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendTrack/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/__tests__/index.test.ts new file mode 100644 index 0000000000..0647aeea18 --- /dev/null +++ b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/__tests__/index.test.ts @@ -0,0 +1,77 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' +import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' + +const testDestination = createTestIntegration(Destination) + +beforeEach(() => nock.cleanAll()) + +// Default Track Mapping +const defaultTrackMapping = { + engage_space: 'space-write-key', + event_name: { + '@path': '$.event' + }, + user_id: { + '@path': '$.userId' + }, + anonymous_id: { + '@path': '$.anonymousId' + }, + properties: { + '@path': '$.properties' + }, + timstamp: { + '@path': '$.timestamp' + } +} + +describe('SegmentProfiles.sendTrack', () => { + test('Should throw an error if `userId or` `anonymousId` is not defined', async () => { + const event = createTestEvent({ + properties: { + plan: 'Business' + }, + event: 'Test Event' + }) + + await expect( + testDestination.testAction('sendTrack', { + event, + mapping: { + event_name: { + '@path': '$.event' + }, + engage_space: 'space-write-key' + } + }) + ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) + }) + + test('Should return transformed segment track event', async () => { + const event = createTestEvent({ + properties: { + plan: 'Business' + }, + userId: 'test-user-ufi5bgkko5', + anonymousId: 'arky4h2sh7k', + timestamp: '2023-09-26T09:46:28.290Z', + event: 'Test Event' + }) + + const responses = await testDestination.testAction('sendTrack', { + event, + mapping: defaultTrackMapping, + settings: { + endpoint: DEFAULT_SEGMENT_ENDPOINT + } + }) + + const results = testDestination.results + expect(responses.length).toBe(0) + expect(results.length).toBe(3) + expect(results[2].data).toMatchSnapshot() + }) +}) diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendTrack/generated-types.ts b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/generated-types.ts new file mode 100644 index 0000000000..39a9c24f7c --- /dev/null +++ b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/generated-types.ts @@ -0,0 +1,34 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The Profile Space to use for creating a record. *Note: This field shows list of internal sources associated with the Profile Space. Changes made to the Profile Space name in **Settings** will not reflect in this list unless the source associated with the Profile Space is renamed explicitly.* + */ + engage_space: string + /** + * Unique identifier for the user in your database. A userId or an anonymousId is required. + */ + user_id?: string + /** + * A pseudo-unique substitute for a User ID, for cases when you don’t have an absolutely unique identifier. A userId or an anonymousId is required. + */ + anonymous_id?: string + /** + * The timestamp of the event. + */ + timestamp?: string | number + /** + * Name of the action that a user has performed. + */ + event_name: string + /** + * The group or account ID a user is associated with. + */ + group_id?: string + /** + * Free-form dictionary of properties that describe the event. + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/segment-profiles/sendTrack/index.ts b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/index.ts new file mode 100644 index 0000000000..b3bd775897 --- /dev/null +++ b/packages/destination-actions/src/destinations/segment-profiles/sendTrack/index.ts @@ -0,0 +1,47 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { user_id, anonymous_id, timestamp, event_name, group_id, properties, engage_space } from '../segment-properties' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' + +const action: ActionDefinition = { + title: 'Send Track', + description: 'Send a track call to Segment’s tracking API. This is used to record actions your users perform.', + fields: { + engage_space, + user_id, + anonymous_id, + timestamp, + event_name, + group_id, + properties + }, + perform: (_, { payload, statsContext }) => { + if (!payload.anonymous_id && !payload.user_id) { + throw MissingUserOrAnonymousIdThrowableError + } + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, `action:sendTrack`]) + + return { + batch: [ + { + userId: payload?.user_id, + anonymousId: payload?.anonymous_id, + timestamp: payload?.timestamp, + event: payload?.event_name, + integrations: { + // Setting 'integrations.All' to false will ensure that we don't send events + // to any destinations which is connected to the Segment Profiles space. + All: false + }, + properties: { + ...payload?.properties + }, + type: 'track' + } + ] + } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/segment/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment/__tests__/__snapshots__/snapshot.test.ts.snap index d6ee6817a1..6fe240024b 100644 --- a/packages/destination-actions/src/destinations/segment/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,419 +2,576 @@ exports[`Testing snapshot for segment destination: sendGroup action - all fields 1`] = ` Object { - "anonymousId": "(m6Ifxh1N4", - "context": Object { - "app": Object { - "build": "(m6Ifxh1N4", - "name": "(m6Ifxh1N4", - "namespace": "(m6Ifxh1N4", - "version": "(m6Ifxh1N4", - }, - "campaign": Object { - "content": "(m6Ifxh1N4", - "medium": "(m6Ifxh1N4", - "name": "(m6Ifxh1N4", - "source": "(m6Ifxh1N4", - "term": "(m6Ifxh1N4", - }, - "device": Object { - "adTracking_Enabled": true, - "advertising_id": "(m6Ifxh1N4", - "id": "(m6Ifxh1N4", - "manufacturer": "(m6Ifxh1N4", - "model": "(m6Ifxh1N4", - "name": "(m6Ifxh1N4", - "token": "(m6Ifxh1N4", - "type": "(m6Ifxh1N4", - }, - "ip": "(m6Ifxh1N4", - "locale": "(m6Ifxh1N4", - "location": Object { - "city": "(m6Ifxh1N4", - "country": "(m6Ifxh1N4", - "latitude": -31928132986470.4, - "longitude": -31928132986470.4, - "speed": -31928132986470.4, - }, - "network": Object { - "bluetooth": true, - "carrier": "(m6Ifxh1N4", - "cellular": true, - "wifi": true, - }, - "os": Object { - "name": "(m6Ifxh1N4", - "version": "(m6Ifxh1N4", - }, - "page": Object { - "path": "(m6Ifxh1N4", - "referrer": "(m6Ifxh1N4", - "search": "(m6Ifxh1N4", - "title": "(m6Ifxh1N4", - "url": "(m6Ifxh1N4", - }, - "screen": Object { - "density": -31928132986470.4, - "height": -31928132986470.4, - "width": -31928132986470.4, - }, - "timezone": "(m6Ifxh1N4", - "userAgent": "(m6Ifxh1N4", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "(m6Ifxh1N4", + "context": Object { + "app": Object { + "build": "(m6Ifxh1N4", + "name": "(m6Ifxh1N4", + "namespace": "(m6Ifxh1N4", + "version": "(m6Ifxh1N4", + }, + "campaign": Object { + "content": "(m6Ifxh1N4", + "medium": "(m6Ifxh1N4", + "name": "(m6Ifxh1N4", + "source": "(m6Ifxh1N4", + "term": "(m6Ifxh1N4", + }, + "device": Object { + "adTracking_Enabled": true, + "advertising_id": "(m6Ifxh1N4", + "id": "(m6Ifxh1N4", + "manufacturer": "(m6Ifxh1N4", + "model": "(m6Ifxh1N4", + "name": "(m6Ifxh1N4", + "token": "(m6Ifxh1N4", + "type": "(m6Ifxh1N4", + }, + "ip": "(m6Ifxh1N4", + "locale": "(m6Ifxh1N4", + "location": Object { + "city": "(m6Ifxh1N4", + "country": "(m6Ifxh1N4", + "latitude": -31928132986470.4, + "longitude": -31928132986470.4, + "speed": -31928132986470.4, + }, + "network": Object { + "bluetooth": true, + "carrier": "(m6Ifxh1N4", + "cellular": true, + "wifi": true, + }, + "os": Object { + "name": "(m6Ifxh1N4", + "version": "(m6Ifxh1N4", + }, + "page": Object { + "path": "(m6Ifxh1N4", + "referrer": "(m6Ifxh1N4", + "search": "(m6Ifxh1N4", + "title": "(m6Ifxh1N4", + "url": "(m6Ifxh1N4", + }, + "screen": Object { + "density": -31928132986470.4, + "height": -31928132986470.4, + "width": -31928132986470.4, + }, + "timezone": "(m6Ifxh1N4", + "userAgent": "(m6Ifxh1N4", + }, + "groupId": "(m6Ifxh1N4", + "timestamp": "(m6Ifxh1N4", + "traits": Object { + "testType": "(m6Ifxh1N4", + }, + "type": "group", + "userId": "(m6Ifxh1N4", + }, + ], }, - "groupId": "(m6Ifxh1N4", - "timestamp": "(m6Ifxh1N4", - "traits": Object { - "testType": "(m6Ifxh1N4", - }, - "userId": "(m6Ifxh1N4", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendGroup action - required fields 1`] = ` Object { - "anonymousId": "(m6Ifxh1N4", - "context": Object {}, - "groupId": "(m6Ifxh1N4", - "traits": Object {}, - "userId": "(m6Ifxh1N4", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "(m6Ifxh1N4", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "userAgent": undefined, + }, + "groupId": "(m6Ifxh1N4", + "timestamp": undefined, + "traits": Object {}, + "type": "group", + "userId": "(m6Ifxh1N4", + }, + ], + }, + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendIdentify action - all fields 1`] = ` Object { - "anonymousId": ")#1JCeQHYVLgzRan", - "context": Object { - "app": Object { - "build": ")#1JCeQHYVLgzRan", - "name": ")#1JCeQHYVLgzRan", - "namespace": ")#1JCeQHYVLgzRan", - "version": ")#1JCeQHYVLgzRan", - }, - "campaign": Object { - "content": ")#1JCeQHYVLgzRan", - "medium": ")#1JCeQHYVLgzRan", - "name": ")#1JCeQHYVLgzRan", - "source": ")#1JCeQHYVLgzRan", - "term": ")#1JCeQHYVLgzRan", - }, - "device": Object { - "adTracking_Enabled": false, - "advertising_id": ")#1JCeQHYVLgzRan", - "id": ")#1JCeQHYVLgzRan", - "manufacturer": ")#1JCeQHYVLgzRan", - "model": ")#1JCeQHYVLgzRan", - "name": ")#1JCeQHYVLgzRan", - "token": ")#1JCeQHYVLgzRan", - "type": ")#1JCeQHYVLgzRan", - }, - "groupId": ")#1JCeQHYVLgzRan", - "ip": ")#1JCeQHYVLgzRan", - "locale": ")#1JCeQHYVLgzRan", - "location": Object { - "city": ")#1JCeQHYVLgzRan", - "country": ")#1JCeQHYVLgzRan", - "latitude": 39096203094261.76, - "longitude": 39096203094261.76, - "speed": 39096203094261.76, - }, - "network": Object { - "bluetooth": false, - "carrier": ")#1JCeQHYVLgzRan", - "cellular": false, - "wifi": false, - }, - "os": Object { - "name": ")#1JCeQHYVLgzRan", - "version": ")#1JCeQHYVLgzRan", - }, - "page": Object { - "path": ")#1JCeQHYVLgzRan", - "referrer": ")#1JCeQHYVLgzRan", - "search": ")#1JCeQHYVLgzRan", - "title": ")#1JCeQHYVLgzRan", - "url": ")#1JCeQHYVLgzRan", - }, - "screen": Object { - "density": 39096203094261.76, - "height": 39096203094261.76, - "width": 39096203094261.76, - }, - "timezone": ")#1JCeQHYVLgzRan", - "userAgent": ")#1JCeQHYVLgzRan", - }, - "timestamp": ")#1JCeQHYVLgzRan", - "traits": Object { - "testType": ")#1JCeQHYVLgzRan", + "data": Object { + "batch": Array [ + Object { + "anonymousId": ")#1JCeQHYVLgzRan", + "context": Object { + "app": Object { + "build": ")#1JCeQHYVLgzRan", + "name": ")#1JCeQHYVLgzRan", + "namespace": ")#1JCeQHYVLgzRan", + "version": ")#1JCeQHYVLgzRan", + }, + "campaign": Object { + "content": ")#1JCeQHYVLgzRan", + "medium": ")#1JCeQHYVLgzRan", + "name": ")#1JCeQHYVLgzRan", + "source": ")#1JCeQHYVLgzRan", + "term": ")#1JCeQHYVLgzRan", + }, + "device": Object { + "adTracking_Enabled": false, + "advertising_id": ")#1JCeQHYVLgzRan", + "id": ")#1JCeQHYVLgzRan", + "manufacturer": ")#1JCeQHYVLgzRan", + "model": ")#1JCeQHYVLgzRan", + "name": ")#1JCeQHYVLgzRan", + "token": ")#1JCeQHYVLgzRan", + "type": ")#1JCeQHYVLgzRan", + }, + "groupId": ")#1JCeQHYVLgzRan", + "ip": ")#1JCeQHYVLgzRan", + "locale": ")#1JCeQHYVLgzRan", + "location": Object { + "city": ")#1JCeQHYVLgzRan", + "country": ")#1JCeQHYVLgzRan", + "latitude": 39096203094261.76, + "longitude": 39096203094261.76, + "speed": 39096203094261.76, + }, + "network": Object { + "bluetooth": false, + "carrier": ")#1JCeQHYVLgzRan", + "cellular": false, + "wifi": false, + }, + "os": Object { + "name": ")#1JCeQHYVLgzRan", + "version": ")#1JCeQHYVLgzRan", + }, + "page": Object { + "path": ")#1JCeQHYVLgzRan", + "referrer": ")#1JCeQHYVLgzRan", + "search": ")#1JCeQHYVLgzRan", + "title": ")#1JCeQHYVLgzRan", + "url": ")#1JCeQHYVLgzRan", + }, + "screen": Object { + "density": 39096203094261.76, + "height": 39096203094261.76, + "width": 39096203094261.76, + }, + "timezone": ")#1JCeQHYVLgzRan", + "userAgent": ")#1JCeQHYVLgzRan", + }, + "timestamp": ")#1JCeQHYVLgzRan", + "traits": Object { + "testType": ")#1JCeQHYVLgzRan", + }, + "type": "identify", + "userId": ")#1JCeQHYVLgzRan", + }, + ], }, - "userId": ")#1JCeQHYVLgzRan", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendIdentify action - required fields 1`] = ` Object { - "anonymousId": ")#1JCeQHYVLgzRan", - "context": Object { - "groupId": ")#1JCeQHYVLgzRan", + "data": Object { + "batch": Array [ + Object { + "anonymousId": ")#1JCeQHYVLgzRan", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": ")#1JCeQHYVLgzRan", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "userAgent": undefined, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": ")#1JCeQHYVLgzRan", + }, + ], }, - "traits": Object {}, - "userId": ")#1JCeQHYVLgzRan", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendPage action - all fields 1`] = ` Object { - "anonymousId": "qB3uqzs44u!@O", - "context": Object { - "app": Object { - "build": "qB3uqzs44u!@O", - "name": "qB3uqzs44u!@O", - "namespace": "qB3uqzs44u!@O", - "version": "qB3uqzs44u!@O", - }, - "campaign": Object { - "content": "qB3uqzs44u!@O", - "medium": "qB3uqzs44u!@O", - "name": "qB3uqzs44u!@O", - "source": "qB3uqzs44u!@O", - "term": "qB3uqzs44u!@O", - }, - "device": Object { - "adTracking_Enabled": false, - "advertising_id": "qB3uqzs44u!@O", - "id": "qB3uqzs44u!@O", - "manufacturer": "qB3uqzs44u!@O", - "model": "qB3uqzs44u!@O", - "name": "qB3uqzs44u!@O", - "token": "qB3uqzs44u!@O", - "type": "qB3uqzs44u!@O", - }, - "groupId": "qB3uqzs44u!@O", - "ip": "qB3uqzs44u!@O", - "locale": "qB3uqzs44u!@O", - "location": Object { - "city": "qB3uqzs44u!@O", - "country": "qB3uqzs44u!@O", - "latitude": 8593345651671.04, - "longitude": 8593345651671.04, - "speed": 8593345651671.04, - }, - "network": Object { - "bluetooth": false, - "carrier": "qB3uqzs44u!@O", - "cellular": false, - "wifi": false, - }, - "os": Object { - "name": "qB3uqzs44u!@O", - "version": "qB3uqzs44u!@O", - }, - "page": Object { - "path": "qB3uqzs44u!@O", - "referrer": "qB3uqzs44u!@O", - "search": "qB3uqzs44u!@O", - "title": "qB3uqzs44u!@O", - "url": "qB3uqzs44u!@O", - }, - "screen": Object { - "density": 8593345651671.04, - "height": 8593345651671.04, - "width": 8593345651671.04, - }, - "timezone": "qB3uqzs44u!@O", - "userAgent": "qB3uqzs44u!@O", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "qB3uqzs44u!@O", + "context": Object { + "app": Object { + "build": "qB3uqzs44u!@O", + "name": "qB3uqzs44u!@O", + "namespace": "qB3uqzs44u!@O", + "version": "qB3uqzs44u!@O", + }, + "campaign": Object { + "content": "qB3uqzs44u!@O", + "medium": "qB3uqzs44u!@O", + "name": "qB3uqzs44u!@O", + "source": "qB3uqzs44u!@O", + "term": "qB3uqzs44u!@O", + }, + "device": Object { + "adTracking_Enabled": false, + "advertising_id": "qB3uqzs44u!@O", + "id": "qB3uqzs44u!@O", + "manufacturer": "qB3uqzs44u!@O", + "model": "qB3uqzs44u!@O", + "name": "qB3uqzs44u!@O", + "token": "qB3uqzs44u!@O", + "type": "qB3uqzs44u!@O", + }, + "groupId": "qB3uqzs44u!@O", + "ip": "qB3uqzs44u!@O", + "locale": "qB3uqzs44u!@O", + "location": Object { + "city": "qB3uqzs44u!@O", + "country": "qB3uqzs44u!@O", + "latitude": 8593345651671.04, + "longitude": 8593345651671.04, + "speed": 8593345651671.04, + }, + "network": Object { + "bluetooth": false, + "carrier": "qB3uqzs44u!@O", + "cellular": false, + "wifi": false, + }, + "os": Object { + "name": "qB3uqzs44u!@O", + "version": "qB3uqzs44u!@O", + }, + "page": Object { + "path": "qB3uqzs44u!@O", + "referrer": "qB3uqzs44u!@O", + "search": "qB3uqzs44u!@O", + "title": "qB3uqzs44u!@O", + "url": "qB3uqzs44u!@O", + }, + "screen": Object { + "density": 8593345651671.04, + "height": 8593345651671.04, + "width": 8593345651671.04, + }, + "timezone": "qB3uqzs44u!@O", + "userAgent": "qB3uqzs44u!@O", + }, + "name": "qB3uqzs44u!@O", + "properties": Object { + "category": "qB3uqzs44u!@O", + "name": "qB3uqzs44u!@O", + "path": "qB3uqzs44u!@O", + "referrer": "qB3uqzs44u!@O", + "search": "qB3uqzs44u!@O", + "testType": "qB3uqzs44u!@O", + "title": "qB3uqzs44u!@O", + "url": "qB3uqzs44u!@O", + }, + "timestamp": "qB3uqzs44u!@O", + "type": "page", + "userId": "qB3uqzs44u!@O", + }, + ], }, - "name": "qB3uqzs44u!@O", - "properties": Object { - "category": "qB3uqzs44u!@O", - "name": "qB3uqzs44u!@O", - "path": "qB3uqzs44u!@O", - "referrer": "qB3uqzs44u!@O", - "search": "qB3uqzs44u!@O", - "testType": "qB3uqzs44u!@O", - "title": "qB3uqzs44u!@O", - "url": "qB3uqzs44u!@O", - }, - "timestamp": "qB3uqzs44u!@O", - "userId": "qB3uqzs44u!@O", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendPage action - required fields 1`] = ` Object { - "anonymousId": "qB3uqzs44u!@O", - "context": Object { - "groupId": "qB3uqzs44u!@O", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "qB3uqzs44u!@O", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": "qB3uqzs44u!@O", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "userAgent": undefined, + }, + "name": undefined, + "properties": Object { + "category": undefined, + "name": undefined, + "path": undefined, + "referrer": undefined, + "search": undefined, + "title": undefined, + "url": undefined, + }, + "timestamp": undefined, + "type": "page", + "userId": "qB3uqzs44u!@O", + }, + ], }, - "properties": Object {}, - "userId": "qB3uqzs44u!@O", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendScreen action - all fields 1`] = ` Object { - "anonymousId": "s62Sv8d1C1w", - "context": Object { - "app": Object { - "build": "s62Sv8d1C1w", - "name": "s62Sv8d1C1w", - "namespace": "s62Sv8d1C1w", - "version": "s62Sv8d1C1w", - }, - "campaign": Object { - "content": "s62Sv8d1C1w", - "medium": "s62Sv8d1C1w", - "name": "s62Sv8d1C1w", - "source": "s62Sv8d1C1w", - "term": "s62Sv8d1C1w", - }, - "device": Object { - "adTracking_Enabled": true, - "advertising_id": "s62Sv8d1C1w", - "id": "s62Sv8d1C1w", - "manufacturer": "s62Sv8d1C1w", - "model": "s62Sv8d1C1w", - "name": "s62Sv8d1C1w", - "token": "s62Sv8d1C1w", - "type": "s62Sv8d1C1w", - }, - "groupId": "s62Sv8d1C1w", - "ip": "s62Sv8d1C1w", - "locale": "s62Sv8d1C1w", - "location": Object { - "city": "s62Sv8d1C1w", - "country": "s62Sv8d1C1w", - "latitude": -18209183933399.04, - "longitude": -18209183933399.04, - "speed": -18209183933399.04, - }, - "network": Object { - "bluetooth": true, - "carrier": "s62Sv8d1C1w", - "cellular": true, - "wifi": true, - }, - "os": Object { - "name": "s62Sv8d1C1w", - "version": "s62Sv8d1C1w", - }, - "page": Object { - "path": "s62Sv8d1C1w", - "referrer": "s62Sv8d1C1w", - "search": "s62Sv8d1C1w", - "title": "s62Sv8d1C1w", - "url": "s62Sv8d1C1w", - }, - "screen": Object { - "density": -18209183933399.04, - "height": -18209183933399.04, - "width": -18209183933399.04, - }, - "userAgent": "s62Sv8d1C1w", - }, - "name": "s62Sv8d1C1w", - "properties": Object { - "name": "s62Sv8d1C1w", - "testType": "s62Sv8d1C1w", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "s62Sv8d1C1w", + "context": Object { + "app": Object { + "build": "s62Sv8d1C1w", + "name": "s62Sv8d1C1w", + "namespace": "s62Sv8d1C1w", + "version": "s62Sv8d1C1w", + }, + "campaign": Object { + "content": "s62Sv8d1C1w", + "medium": "s62Sv8d1C1w", + "name": "s62Sv8d1C1w", + "source": "s62Sv8d1C1w", + "term": "s62Sv8d1C1w", + }, + "device": Object { + "adTracking_Enabled": true, + "advertising_id": "s62Sv8d1C1w", + "id": "s62Sv8d1C1w", + "manufacturer": "s62Sv8d1C1w", + "model": "s62Sv8d1C1w", + "name": "s62Sv8d1C1w", + "token": "s62Sv8d1C1w", + "type": "s62Sv8d1C1w", + }, + "groupId": "s62Sv8d1C1w", + "ip": "s62Sv8d1C1w", + "locale": "s62Sv8d1C1w", + "location": Object { + "city": "s62Sv8d1C1w", + "country": "s62Sv8d1C1w", + "latitude": -18209183933399.04, + "longitude": -18209183933399.04, + "speed": -18209183933399.04, + }, + "network": Object { + "bluetooth": true, + "carrier": "s62Sv8d1C1w", + "cellular": true, + "wifi": true, + }, + "os": Object { + "name": "s62Sv8d1C1w", + "version": "s62Sv8d1C1w", + }, + "page": Object { + "path": "s62Sv8d1C1w", + "referrer": "s62Sv8d1C1w", + "search": "s62Sv8d1C1w", + "title": "s62Sv8d1C1w", + "url": "s62Sv8d1C1w", + }, + "screen": Object { + "density": -18209183933399.04, + "height": -18209183933399.04, + "width": -18209183933399.04, + }, + "userAgent": "s62Sv8d1C1w", + }, + "name": "s62Sv8d1C1w", + "properties": Object { + "name": "s62Sv8d1C1w", + "testType": "s62Sv8d1C1w", + }, + "timestamp": "s62Sv8d1C1w", + "type": "screen", + "userId": "s62Sv8d1C1w", + }, + ], }, - "timestamp": "s62Sv8d1C1w", - "userId": "s62Sv8d1C1w", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendScreen action - required fields 1`] = ` Object { - "anonymousId": "s62Sv8d1C1w", - "context": Object { - "groupId": "s62Sv8d1C1w", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "s62Sv8d1C1w", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": "s62Sv8d1C1w", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "userAgent": undefined, + }, + "name": undefined, + "properties": Object { + "name": undefined, + }, + "timestamp": undefined, + "type": "screen", + "userId": "s62Sv8d1C1w", + }, + ], }, - "properties": Object {}, - "userId": "s62Sv8d1C1w", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendTrack action - all fields 1`] = ` Object { - "anonymousId": "CYyxkIddLM", - "context": Object { - "app": Object { - "build": "CYyxkIddLM", - "name": "CYyxkIddLM", - "namespace": "CYyxkIddLM", - "version": "CYyxkIddLM", - }, - "campaign": Object { - "content": "CYyxkIddLM", - "medium": "CYyxkIddLM", - "name": "CYyxkIddLM", - "source": "CYyxkIddLM", - "term": "CYyxkIddLM", - }, - "device": Object { - "adTracking_Enabled": true, - "advertising_id": "CYyxkIddLM", - "id": "CYyxkIddLM", - "manufacturer": "CYyxkIddLM", - "model": "CYyxkIddLM", - "name": "CYyxkIddLM", - "token": "CYyxkIddLM", - "type": "CYyxkIddLM", - }, - "groupId": "CYyxkIddLM", - "ip": "CYyxkIddLM", - "locale": "CYyxkIddLM", - "location": Object { - "city": "CYyxkIddLM", - "country": "CYyxkIddLM", - "latitude": -29887526831390.72, - "longitude": -29887526831390.72, - "speed": -29887526831390.72, - }, - "network": Object { - "bluetooth": true, - "carrier": "CYyxkIddLM", - "cellular": true, - "wifi": true, - }, - "os": Object { - "name": "CYyxkIddLM", - "version": "CYyxkIddLM", - }, - "page": Object { - "path": "CYyxkIddLM", - "referrer": "CYyxkIddLM", - "search": "CYyxkIddLM", - "title": "CYyxkIddLM", - "url": "CYyxkIddLM", - }, - "screen": Object { - "density": -29887526831390.72, - "height": -29887526831390.72, - "width": -29887526831390.72, - }, - "timezone": "CYyxkIddLM", - "traits": Object { - "testType": "CYyxkIddLM", - }, - "userAgent": "CYyxkIddLM", - }, - "event": "CYyxkIddLM", - "properties": Object { - "testType": "CYyxkIddLM", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "CYyxkIddLM", + "context": Object { + "app": Object { + "build": "CYyxkIddLM", + "name": "CYyxkIddLM", + "namespace": "CYyxkIddLM", + "version": "CYyxkIddLM", + }, + "campaign": Object { + "content": "CYyxkIddLM", + "medium": "CYyxkIddLM", + "name": "CYyxkIddLM", + "source": "CYyxkIddLM", + "term": "CYyxkIddLM", + }, + "device": Object { + "adTracking_Enabled": true, + "advertising_id": "CYyxkIddLM", + "id": "CYyxkIddLM", + "manufacturer": "CYyxkIddLM", + "model": "CYyxkIddLM", + "name": "CYyxkIddLM", + "token": "CYyxkIddLM", + "type": "CYyxkIddLM", + }, + "groupId": "CYyxkIddLM", + "ip": "CYyxkIddLM", + "locale": "CYyxkIddLM", + "location": Object { + "city": "CYyxkIddLM", + "country": "CYyxkIddLM", + "latitude": -29887526831390.72, + "longitude": -29887526831390.72, + "speed": -29887526831390.72, + }, + "network": Object { + "bluetooth": true, + "carrier": "CYyxkIddLM", + "cellular": true, + "wifi": true, + }, + "os": Object { + "name": "CYyxkIddLM", + "version": "CYyxkIddLM", + }, + "page": Object { + "path": "CYyxkIddLM", + "referrer": "CYyxkIddLM", + "search": "CYyxkIddLM", + "title": "CYyxkIddLM", + "url": "CYyxkIddLM", + }, + "screen": Object { + "density": -29887526831390.72, + "height": -29887526831390.72, + "width": -29887526831390.72, + }, + "timezone": "CYyxkIddLM", + "traits": Object { + "testType": "CYyxkIddLM", + }, + "userAgent": "CYyxkIddLM", + }, + "event": "CYyxkIddLM", + "properties": Object { + "testType": "CYyxkIddLM", + }, + "timestamp": "CYyxkIddLM", + "type": "track", + "userId": "CYyxkIddLM", + }, + ], }, - "timestamp": "CYyxkIddLM", - "userId": "CYyxkIddLM", + "output": "Action Executed", } `; exports[`Testing snapshot for segment destination: sendTrack action - required fields 1`] = ` Object { - "anonymousId": "CYyxkIddLM", - "context": Object { - "groupId": "CYyxkIddLM", - "traits": Object {}, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "CYyxkIddLM", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": "CYyxkIddLM", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "traits": Object {}, + "userAgent": undefined, + }, + "event": "CYyxkIddLM", + "properties": Object {}, + "timestamp": undefined, + "type": "track", + "userId": "CYyxkIddLM", + }, + ], }, - "event": "CYyxkIddLM", - "properties": Object {}, - "userId": "CYyxkIddLM", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment/__tests__/index.test.ts index 33387fc7c8..216a2b9206 100644 --- a/packages/destination-actions/src/destinations/segment/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment/__tests__/index.test.ts @@ -6,8 +6,7 @@ import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../properties' const testDestination = createTestIntegration(Definition) const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].cdn const authData = { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } describe('Segment', () => { diff --git a/packages/destination-actions/src/destinations/segment/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment/__tests__/snapshot.test.ts index 127465151f..4586fce5cd 100644 --- a/packages/destination-actions/src/destinations/segment/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment/__tests__/snapshot.test.ts @@ -1,16 +1,13 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../lib/test-data' import destination from '../index' -import nock from 'nock' import { TransactionContext } from '@segment/actions-core/destination-kit' -import { DEFAULT_SEGMENT_ENDPOINT } from '../properties' const testDestination = createTestIntegration(destination) const destinationSlug = 'segment' const settingsData = { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } describe(`Testing snapshot for ${destinationSlug} destination:`, () => { @@ -20,19 +17,6 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(201) - nock(/.*/) - .persist() - .patch(/.*/) - .reply(200, { - id: '801', - properties: { - lifecyclestage: eventData.lifecyclestage - } - }) - nock(/.*/).persist().put(/.*/).reply(200) - const transactionContext: TransactionContext = { transaction: {}, setTransaction: (key, value) => ({ [key]: value }) @@ -42,30 +26,16 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { properties: eventData }) - try { - const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: event.properties, - settings: settingsData, - auth: undefined, - transactionContext - }) - - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined, + transactionContext + }) - expect(request.headers).toMatchSnapshot() - } catch (e) { - expect(e).toMatchSnapshot() - } + const testDestinationResults = testDestination.results + expect(testDestinationResults[testDestinationResults.length - 1]).toMatchSnapshot() }) it(`${actionSlug} action - all fields`, async () => { @@ -78,45 +48,20 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { setTransaction: (key, value) => ({ [key]: value }) } - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(201) - nock(/.*/) - .persist() - .patch(/.*/) - .reply(200, { - id: '801', - properties: { - lifecyclestage: eventData.lifecyclestage - } - }) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - try { - const responses = await testDestination.testAction(actionSlug, { - event: event, - mapping: event.properties, - settings: settingsData, - auth: undefined, - transactionContext - }) - - const request = responses[0].request - const rawBody = await request.text() + await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined, + transactionContext + }) - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - } catch (e) { - expect(e).toMatchSnapshot() - } + const testDestinationResults = testDestination.results + expect(testDestinationResults[testDestinationResults.length - 1]).toMatchSnapshot() }) } }) diff --git a/packages/destination-actions/src/destinations/segment/generated-types.ts b/packages/destination-actions/src/destinations/segment/generated-types.ts index 39cc661d8d..4901886257 100644 --- a/packages/destination-actions/src/destinations/segment/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment/generated-types.ts @@ -5,8 +5,4 @@ export interface Settings { * The **Write Key** of a Segment source. */ source_write_key: string - /** - * The region to send your data. - */ - endpoint?: string } diff --git a/packages/destination-actions/src/destinations/segment/index.ts b/packages/destination-actions/src/destinations/segment/index.ts index ff4004632d..81c604648c 100644 --- a/packages/destination-actions/src/destinations/segment/index.ts +++ b/packages/destination-actions/src/destinations/segment/index.ts @@ -23,25 +23,12 @@ const destination: DestinationDefinition = { description: 'The **Write Key** of a Segment source.', type: 'string', required: true - }, - endpoint: { - label: 'Endpoint Region', - description: 'The region to send your data.', - type: 'string', - format: 'text', - choices: Object.keys(SEGMENT_ENDPOINTS).map((key) => ({ - label: SEGMENT_ENDPOINTS[key].label, - value: key - })), - default: DEFAULT_SEGMENT_ENDPOINT } }, testAuthentication: async (request, { settings }) => { - const { source_write_key, endpoint } = settings - - return request( - `${SEGMENT_ENDPOINTS[endpoint || DEFAULT_SEGMENT_ENDPOINT].cdn}/projects/${source_write_key}/settings` - ) + const { source_write_key } = settings + const AWS_REGION = process.env['AWS_REGION'] || DEFAULT_SEGMENT_ENDPOINT + return request(`${SEGMENT_ENDPOINTS[AWS_REGION].cdn}/projects/${source_write_key}/settings`) } }, extendRequest({ settings }) { diff --git a/packages/destination-actions/src/destinations/segment/properties.ts b/packages/destination-actions/src/destinations/segment/properties.ts index beb15bf101..679139fd2c 100644 --- a/packages/destination-actions/src/destinations/segment/properties.ts +++ b/packages/destination-actions/src/destinations/segment/properties.ts @@ -5,16 +5,16 @@ interface SegmentEndpoint { } export const SEGMENT_ENDPOINTS: { [key: string]: SegmentEndpoint } = { - north_america: { + 'us-west-2': { label: 'North America', url: 'https://api.segment.io/v1', cdn: 'https://cdn.segment.com/v1' }, - europe: { + 'eu-west-1': { label: 'Europe', url: 'https://events.eu1.segmentapis.com/v1', cdn: 'https://cdn.segment.com/v1' } } -export const DEFAULT_SEGMENT_ENDPOINT = 'north_america' +export const DEFAULT_SEGMENT_ENDPOINT = 'us-west-2' diff --git a/packages/destination-actions/src/destinations/segment/segment-properties.ts b/packages/destination-actions/src/destinations/segment/segment-properties.ts index d58ec67436..58578c5fb1 100644 --- a/packages/destination-actions/src/destinations/segment/segment-properties.ts +++ b/packages/destination-actions/src/destinations/segment/segment-properties.ts @@ -343,3 +343,12 @@ export const traits: InputField = { defaultObjectUI: 'keyvalue', additionalProperties: true } + +export const enable_batching: InputField = { + type: 'boolean', + label: 'Batch Data to segment', + description: + 'This is always disabled pending a full removal. When enabled, the action will send batch data. Segment accepts batches of up to 225 events.', + default: false, + unsafe_hidden: true +} diff --git a/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap index 96cf4f61f7..b065cb4a2e 100644 --- a/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,81 +1,119 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Testing snapshot for Segment's sendGroup destination action: all fields 1`] = ` -Object { - "anonymousId": "92N!&JfP", - "context": Object { - "app": Object { - "build": "92N!&JfP", - "name": "92N!&JfP", - "namespace": "92N!&JfP", - "version": "92N!&JfP", - }, - "campaign": Object { - "content": "92N!&JfP", - "medium": "92N!&JfP", - "name": "92N!&JfP", - "source": "92N!&JfP", - "term": "92N!&JfP", - }, - "device": Object { - "adTracking_Enabled": true, - "advertising_id": "92N!&JfP", - "id": "92N!&JfP", - "manufacturer": "92N!&JfP", - "model": "92N!&JfP", - "name": "92N!&JfP", - "token": "92N!&JfP", - "type": "92N!&JfP", - }, - "ip": "92N!&JfP", - "locale": "92N!&JfP", - "location": Object { - "city": "92N!&JfP", - "country": "92N!&JfP", - "latitude": -53044051338854.4, - "longitude": -53044051338854.4, - "speed": -53044051338854.4, - }, - "network": Object { - "bluetooth": true, - "carrier": "92N!&JfP", - "cellular": true, - "wifi": true, - }, - "os": Object { - "name": "92N!&JfP", - "version": "92N!&JfP", - }, - "page": Object { - "path": "92N!&JfP", - "referrer": "92N!&JfP", - "search": "92N!&JfP", - "title": "92N!&JfP", - "url": "92N!&JfP", - }, - "screen": Object { - "density": -53044051338854.4, - "height": -53044051338854.4, - "width": -53044051338854.4, - }, - "timezone": "92N!&JfP", - "userAgent": "92N!&JfP", +Array [ + Object { + "output": "Mappings resolved", }, - "groupId": "92N!&JfP", - "timestamp": "92N!&JfP", - "traits": Object { - "testType": "92N!&JfP", + Object { + "output": "Payload validated", }, - "userId": "92N!&JfP", -} + Object { + "data": Object { + "batch": Array [ + Object { + "anonymousId": "92N!&JfP", + "context": Object { + "app": Object { + "build": "92N!&JfP", + "name": "92N!&JfP", + "namespace": "92N!&JfP", + "version": "92N!&JfP", + }, + "campaign": Object { + "content": "92N!&JfP", + "medium": "92N!&JfP", + "name": "92N!&JfP", + "source": "92N!&JfP", + "term": "92N!&JfP", + }, + "device": Object { + "adTracking_Enabled": true, + "advertising_id": "92N!&JfP", + "id": "92N!&JfP", + "manufacturer": "92N!&JfP", + "model": "92N!&JfP", + "name": "92N!&JfP", + "token": "92N!&JfP", + "type": "92N!&JfP", + }, + "ip": "92N!&JfP", + "locale": "92N!&JfP", + "location": Object { + "city": "92N!&JfP", + "country": "92N!&JfP", + "latitude": -53044051338854.4, + "longitude": -53044051338854.4, + "speed": -53044051338854.4, + }, + "network": Object { + "bluetooth": true, + "carrier": "92N!&JfP", + "cellular": true, + "wifi": true, + }, + "os": Object { + "name": "92N!&JfP", + "version": "92N!&JfP", + }, + "page": Object { + "path": "92N!&JfP", + "referrer": "92N!&JfP", + "search": "92N!&JfP", + "title": "92N!&JfP", + "url": "92N!&JfP", + }, + "screen": Object { + "density": -53044051338854.4, + "height": -53044051338854.4, + "width": -53044051338854.4, + }, + "timezone": "92N!&JfP", + "userAgent": "92N!&JfP", + }, + "groupId": "92N!&JfP", + "timestamp": "92N!&JfP", + "traits": Object { + "testType": "92N!&JfP", + }, + "type": "group", + "userId": "92N!&JfP", + }, + ], + }, + "output": "Action Executed", + }, +] `; exports[`Testing snapshot for Segment's sendGroup destination action: required fields 1`] = ` Object { - "anonymousId": "92N!&JfP", - "context": Object {}, - "groupId": "92N!&JfP", - "traits": Object {}, - "userId": "92N!&JfP", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "92N!&JfP", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "userAgent": undefined, + }, + "groupId": "92N!&JfP", + "timestamp": undefined, + "traits": Object {}, + "type": "group", + "userId": "92N!&JfP", + }, + ], + }, + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/index.test.ts index c5f5029749..6b635b36c4 100644 --- a/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/index.test.ts @@ -1,8 +1,7 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' -import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' const testDestination = createTestIntegration(Destination) @@ -46,34 +45,7 @@ describe('Segment.sendGroup', () => { ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { - const event = createTestEvent({ - traits: { - name: 'Example Corp', - industry: 'Technology' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k', - groupId: 'test-group-ks2i7e' - }) - - await expect( - testDestination.testAction('sendGroup', { - event, - mapping: defaultGroupMapping, - settings: { - source_write_key: 'test-source-write-key', - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - - test('Should send an group event to Segment', async () => { - // Mock: Segment Group Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/group').reply(200, { success: true }) - + test('Should return transformed event', async () => { const event = createTestEvent({ traits: { name: 'Example Corp', @@ -88,58 +60,76 @@ describe('Segment.sendGroup', () => { event, mapping: defaultGroupMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.json).toMatchObject({ - userId: event.userId, - anonymousId: event.anonymousId, - groupId: event.groupId, - traits: { - ...event.traits - }, - context: {} + const results = testDestination.results + expect(responses.length).toBe(0) + expect(results.length).toBe(3) + expect(results[2].data).toMatchObject({ + batch: [ + { + userId: event.userId, + anonymousId: event.anonymousId, + groupId: event.groupId, + traits: { + ...event.traits + }, + context: {} + } + ] }) }) - test('Should not send event if actions-segment-tapi-internal-enabled flag is enabled', async () => { - const event = createTestEvent({ - traits: { - name: 'Example Corp', - industry: 'Technology' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k', - groupId: 'test-group-ks2i7e' - }) + it('should work with batch events', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + traits: { + name: 'Example Corp', + industry: 'Technology' + }, + userId: 'test-user-ufi5bgkko5', + anonymousId: 'arky4h2sh7k', + groupId: 'test-group-ks2i7e' + }), + createTestEvent({ + traits: { + name: 'Example Corp', + industry: 'Technology' + }, + userId: 'test-user-ufi5bgkko5', + groupId: 'test-group-ks2i7e' + }) + ] - const responses = await testDestination.testAction('sendGroup', { - event, + const responses = await testDestination.testBatchAction('sendGroup', { + events, mapping: defaultGroupMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT - }, - features: { - 'actions-segment-tapi-internal-enabled': true + source_write_key: 'test-source-write-key' } }) const results = testDestination.results expect(responses.length).toBe(0) - expect(results.length).toBe(3) - expect(results[2].data).toMatchObject({ + expect(results.length).toBe(1) + expect(results[0].data).toMatchObject({ batch: [ { - userId: event.userId, - anonymousId: event.anonymousId, - groupId: event.groupId, + userId: events[0].userId, + anonymousId: events[0].anonymousId, + groupId: events[0].groupId, traits: { - ...event.traits + ...events[0].traits + }, + context: {} + }, + { + userId: events[1].userId, + groupId: events[0].groupId, + traits: { + ...events[0].traits }, context: {} } diff --git a/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/snapshot.test.ts index 311730ab11..7cf69e9247 100644 --- a/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendGroup/__tests__/snapshot.test.ts @@ -1,8 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' -import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import nock from 'nock' const testDestination = createTestIntegration(destination) const actionSlug = 'sendGroup' @@ -10,8 +8,7 @@ const destinationSlug = 'Segment' const seedName = `${destinationSlug}#${actionSlug}` const settingsData = { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { @@ -19,63 +16,35 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + expect(testDestination.results).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment/sendGroup/generated-types.ts b/packages/destination-actions/src/destinations/segment/sendGroup/generated-types.ts index 14b57fec0c..ed22b4d6b4 100644 --- a/packages/destination-actions/src/destinations/segment/sendGroup/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment/sendGroup/generated-types.ts @@ -223,4 +223,8 @@ export interface Payload { traits?: { [k: string]: unknown } + /** + * This is always disabled pending a full removal. When enabled, the action will send batch data. Segment accepts batches of up to 225 events. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/segment/sendGroup/index.ts b/packages/destination-actions/src/destinations/segment/sendGroup/index.ts index eee8c65d19..c46f09af80 100644 --- a/packages/destination-actions/src/destinations/segment/sendGroup/index.ts +++ b/packages/destination-actions/src/destinations/segment/sendGroup/index.ts @@ -18,10 +18,10 @@ import { screen, user_agent, timezone, - traits + traits, + enable_batching } from '../segment-properties' -import { SEGMENT_ENDPOINTS } from '../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' const action: ActionDefinition = { title: 'Send Group', @@ -44,54 +44,58 @@ const action: ActionDefinition = { screen, user_agent, timezone, - traits + traits, + enable_batching }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { if (!payload.anonymous_id && !payload.user_id) { throw MissingUserOrAnonymousIdThrowableError } - const groupPayload: Object = { - userId: payload?.user_id, - anonymousId: payload?.anonymous_id, - groupId: payload?.group_id, - timestamp: payload?.timestamp, - context: { - app: payload?.application, - campaign: payload?.campaign_parameters, - device: payload?.device, - ip: payload?.ip_address, - locale: payload?.locale, - location: payload?.location, - network: payload?.network, - os: payload?.operating_system, - page: payload?.page, - screen: payload?.screen, - userAgent: payload?.user_agent, - timezone: payload?.timezone - }, - traits: { - ...payload?.traits - } - } + const groupPayload: Object = convertPayload(payload) - // Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - throw InvalidEndpointSelectedThrowableError - } + // Returns transformed payload without snding it to TAPI endpoint. + // The payload will be sent to Segment's tracking API internally. + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendGroup']) + return { batch: [groupPayload] } + }, + performBatch: (_request, { payload, statsContext }) => { + const groupPayload = payload.map((data) => { + if (!data.anonymous_id && !data.user_id) { + throw MissingUserOrAnonymousIdThrowableError + } + return convertPayload(data) + }) - // Return transformed payload without snding it to TAPI endpoint - if (features && features['actions-segment-tapi-internal-enabled']) { - statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendGroup']) - const payload = { ...groupPayload, type: 'group' } - return { batch: [payload] } - } + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendBatchGroup']) + return { batch: groupPayload } + } +} - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url - return request(`${selectedSegmentEndpoint}/group`, { - method: 'POST', - json: groupPayload - }) +function convertPayload(data: Payload) { + return { + userId: data?.user_id, + anonymousId: data?.anonymous_id, + groupId: data?.group_id, + timestamp: data?.timestamp, + context: { + app: data?.application, + campaign: data?.campaign_parameters, + device: data?.device, + ip: data?.ip_address, + locale: data?.locale, + location: data?.location, + network: data?.network, + os: data?.operating_system, + page: data?.page, + screen: data?.screen, + userAgent: data?.user_agent, + timezone: data?.timezone + }, + traits: { + ...data?.traits + }, + type: 'group' } } diff --git a/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap index f613f2eb1f..88d1258155 100644 --- a/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,81 +2,110 @@ exports[`Testing snapshot for Segment's sendIdentify destination action: all fields 1`] = ` Object { - "anonymousId": "l1ibvt$6IJOK*&*ZgReE", - "context": Object { - "app": Object { - "build": "l1ibvt$6IJOK*&*ZgReE", - "name": "l1ibvt$6IJOK*&*ZgReE", - "namespace": "l1ibvt$6IJOK*&*ZgReE", - "version": "l1ibvt$6IJOK*&*ZgReE", - }, - "campaign": Object { - "content": "l1ibvt$6IJOK*&*ZgReE", - "medium": "l1ibvt$6IJOK*&*ZgReE", - "name": "l1ibvt$6IJOK*&*ZgReE", - "source": "l1ibvt$6IJOK*&*ZgReE", - "term": "l1ibvt$6IJOK*&*ZgReE", - }, - "device": Object { - "adTracking_Enabled": false, - "advertising_id": "l1ibvt$6IJOK*&*ZgReE", - "id": "l1ibvt$6IJOK*&*ZgReE", - "manufacturer": "l1ibvt$6IJOK*&*ZgReE", - "model": "l1ibvt$6IJOK*&*ZgReE", - "name": "l1ibvt$6IJOK*&*ZgReE", - "token": "l1ibvt$6IJOK*&*ZgReE", - "type": "l1ibvt$6IJOK*&*ZgReE", - }, - "groupId": "l1ibvt$6IJOK*&*ZgReE", - "ip": "l1ibvt$6IJOK*&*ZgReE", - "locale": "l1ibvt$6IJOK*&*ZgReE", - "location": Object { - "city": "l1ibvt$6IJOK*&*ZgReE", - "country": "l1ibvt$6IJOK*&*ZgReE", - "latitude": 80689445061263.36, - "longitude": 80689445061263.36, - "speed": 80689445061263.36, - }, - "network": Object { - "bluetooth": false, - "carrier": "l1ibvt$6IJOK*&*ZgReE", - "cellular": false, - "wifi": false, - }, - "os": Object { - "name": "l1ibvt$6IJOK*&*ZgReE", - "version": "l1ibvt$6IJOK*&*ZgReE", - }, - "page": Object { - "path": "l1ibvt$6IJOK*&*ZgReE", - "referrer": "l1ibvt$6IJOK*&*ZgReE", - "search": "l1ibvt$6IJOK*&*ZgReE", - "title": "l1ibvt$6IJOK*&*ZgReE", - "url": "l1ibvt$6IJOK*&*ZgReE", - }, - "screen": Object { - "density": 80689445061263.36, - "height": 80689445061263.36, - "width": 80689445061263.36, - }, - "timezone": "l1ibvt$6IJOK*&*ZgReE", - "userAgent": "l1ibvt$6IJOK*&*ZgReE", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "l1ibvt$6IJOK*&*ZgReE", + "context": Object { + "app": Object { + "build": "l1ibvt$6IJOK*&*ZgReE", + "name": "l1ibvt$6IJOK*&*ZgReE", + "namespace": "l1ibvt$6IJOK*&*ZgReE", + "version": "l1ibvt$6IJOK*&*ZgReE", + }, + "campaign": Object { + "content": "l1ibvt$6IJOK*&*ZgReE", + "medium": "l1ibvt$6IJOK*&*ZgReE", + "name": "l1ibvt$6IJOK*&*ZgReE", + "source": "l1ibvt$6IJOK*&*ZgReE", + "term": "l1ibvt$6IJOK*&*ZgReE", + }, + "device": Object { + "adTracking_Enabled": false, + "advertising_id": "l1ibvt$6IJOK*&*ZgReE", + "id": "l1ibvt$6IJOK*&*ZgReE", + "manufacturer": "l1ibvt$6IJOK*&*ZgReE", + "model": "l1ibvt$6IJOK*&*ZgReE", + "name": "l1ibvt$6IJOK*&*ZgReE", + "token": "l1ibvt$6IJOK*&*ZgReE", + "type": "l1ibvt$6IJOK*&*ZgReE", + }, + "groupId": "l1ibvt$6IJOK*&*ZgReE", + "ip": "l1ibvt$6IJOK*&*ZgReE", + "locale": "l1ibvt$6IJOK*&*ZgReE", + "location": Object { + "city": "l1ibvt$6IJOK*&*ZgReE", + "country": "l1ibvt$6IJOK*&*ZgReE", + "latitude": 80689445061263.36, + "longitude": 80689445061263.36, + "speed": 80689445061263.36, + }, + "network": Object { + "bluetooth": false, + "carrier": "l1ibvt$6IJOK*&*ZgReE", + "cellular": false, + "wifi": false, + }, + "os": Object { + "name": "l1ibvt$6IJOK*&*ZgReE", + "version": "l1ibvt$6IJOK*&*ZgReE", + }, + "page": Object { + "path": "l1ibvt$6IJOK*&*ZgReE", + "referrer": "l1ibvt$6IJOK*&*ZgReE", + "search": "l1ibvt$6IJOK*&*ZgReE", + "title": "l1ibvt$6IJOK*&*ZgReE", + "url": "l1ibvt$6IJOK*&*ZgReE", + }, + "screen": Object { + "density": 80689445061263.36, + "height": 80689445061263.36, + "width": 80689445061263.36, + }, + "timezone": "l1ibvt$6IJOK*&*ZgReE", + "userAgent": "l1ibvt$6IJOK*&*ZgReE", + }, + "timestamp": "l1ibvt$6IJOK*&*ZgReE", + "traits": Object { + "testType": "l1ibvt$6IJOK*&*ZgReE", + }, + "type": "identify", + "userId": "l1ibvt$6IJOK*&*ZgReE", + }, + ], }, - "timestamp": "l1ibvt$6IJOK*&*ZgReE", - "traits": Object { - "testType": "l1ibvt$6IJOK*&*ZgReE", - }, - "userId": "l1ibvt$6IJOK*&*ZgReE", + "output": "Action Executed", } `; exports[`Testing snapshot for Segment's sendIdentify destination action: required fields 1`] = ` Object { - "anonymousId": "l1ibvt$6IJOK*&*ZgReE", - "context": Object { - "groupId": "l1ibvt$6IJOK*&*ZgReE", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "l1ibvt$6IJOK*&*ZgReE", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": "l1ibvt$6IJOK*&*ZgReE", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "userAgent": undefined, + }, + "timestamp": undefined, + "traits": Object {}, + "type": "identify", + "userId": "l1ibvt$6IJOK*&*ZgReE", + }, + ], }, - "traits": Object {}, - "userId": "l1ibvt$6IJOK*&*ZgReE", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/index.test.ts index a123016af4..abbf73c81d 100644 --- a/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/index.test.ts @@ -1,8 +1,7 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' -import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' const testDestination = createTestIntegration(Destination) @@ -39,34 +38,7 @@ describe('Segment.sendIdentify', () => { ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { - const event = createTestEvent({ - type: 'identify', - traits: { - name: 'Test User', - email: 'test-user@test-company.com' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) - - await expect( - testDestination.testAction('sendIdentify', { - event, - mapping: defaultIdentifyMapping, - settings: { - source_write_key: 'test-source-write-key', - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - - test('Should send an identify event to Segment', async () => { - // Mock: Segment Identify Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/identify').reply(200, { success: true }) - + test('Should return transformed event', async () => { const event = createTestEvent({ type: 'identify', traits: { @@ -81,56 +53,73 @@ describe('Segment.sendIdentify', () => { event, mapping: defaultIdentifyMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.json).toMatchObject({ - userId: event.userId, - anonymousId: event.anonymousId, - traits: { - ...event.traits - }, - context: {} + const results = testDestination.results + expect(responses.length).toBe(0) + expect(results.length).toBe(3) + expect(results[2].data).toMatchObject({ + batch: [ + { + userId: event.userId, + anonymousId: event.anonymousId, + traits: { + ...event.traits + }, + context: {} + } + ] }) }) - test('Should not send event if actions-segment-tapi-internal-enabled flag is enabled', async () => { - const event = createTestEvent({ - type: 'identify', - traits: { - name: 'Test User', - email: 'test-user@test-company.com' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) + it('should work with batch events', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + type: 'identify', + traits: { + name: 'Test User', + email: 'test-user@test-company.com' + }, + userId: 'test-user-ufi5bgkko5', + anonymousId: 'arky4h2sh7k' + }), + createTestEvent({ + type: 'identify', + traits: { + name: 'Test User', + email: 'test-user@test-company.com' + }, + userId: 'test-user-ufi5bgkko5' + }) + ] - const responses = await testDestination.testAction('sendIdentify', { - event, + const responses = await testDestination.testBatchAction('sendIdentify', { + events, mapping: defaultIdentifyMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT - }, - features: { - 'actions-segment-tapi-internal-enabled': true + source_write_key: 'test-source-write-key' } }) const results = testDestination.results expect(responses.length).toBe(0) - expect(results.length).toBe(3) - expect(results[2].data).toMatchObject({ + expect(results.length).toBe(1) + expect(results[0].data).toMatchObject({ batch: [ { - userId: event.userId, - anonymousId: event.anonymousId, + userId: events[0].userId, + anonymousId: events[0].anonymousId, traits: { - ...event.traits + ...events[0].traits + }, + context: {} + }, + { + userId: events[1].userId, + traits: { + ...events[1].traits }, context: {} } diff --git a/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/snapshot.test.ts index 940eb87a1e..82527b5cc6 100644 --- a/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendIdentify/__tests__/snapshot.test.ts @@ -1,8 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' -import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import nock from 'nock' const testDestination = createTestIntegration(destination) const actionSlug = 'sendIdentify' @@ -10,8 +8,7 @@ const destinationSlug = 'Segment' const seedName = `${destinationSlug}#${actionSlug}` const settingsData = { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { @@ -19,63 +16,37 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment/sendIdentify/generated-types.ts b/packages/destination-actions/src/destinations/segment/sendIdentify/generated-types.ts index 3670c29ea3..67f95854ff 100644 --- a/packages/destination-actions/src/destinations/segment/sendIdentify/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment/sendIdentify/generated-types.ts @@ -223,4 +223,8 @@ export interface Payload { traits?: { [k: string]: unknown } + /** + * This is always disabled pending a full removal. When enabled, the action will send batch data. Segment accepts batches of up to 225 events. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/segment/sendIdentify/index.ts b/packages/destination-actions/src/destinations/segment/sendIdentify/index.ts index 3d931e176f..9f59bd0c82 100644 --- a/packages/destination-actions/src/destinations/segment/sendIdentify/index.ts +++ b/packages/destination-actions/src/destinations/segment/sendIdentify/index.ts @@ -18,10 +18,10 @@ import { screen, locale, location, - traits + traits, + enable_batching } from '../segment-properties' -import { SEGMENT_ENDPOINTS } from '../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' const action: ActionDefinition = { title: 'Send Identify', @@ -45,55 +45,55 @@ const action: ActionDefinition = { user_agent, timezone, group_id, - traits + traits, + enable_batching }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { if (!payload.anonymous_id && !payload.user_id) { throw MissingUserOrAnonymousIdThrowableError } - const identifyPayload = { - userId: payload?.user_id, - anonymousId: payload?.anonymous_id, - timestamp: payload?.timestamp, - context: { - app: payload?.application, - campaign: payload?.campaign_parameters, - device: payload?.device, - ip: payload?.ip_address, - locale: payload?.locale, - location: payload?.location, - network: payload?.network, - os: payload?.operating_system, - page: payload?.page, - screen: payload?.screen, - userAgent: payload?.user_agent, - timezone: payload?.timezone, - groupId: payload?.group_id - }, - traits: { - ...payload?.traits + const identifyPayload = convertPayload(payload) + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendIdentify']) + return { batch: [identifyPayload] } + }, + performBatch: (_request, { payload, statsContext }) => { + const identifyPayload = payload.map((data) => { + if (!data.anonymous_id && !data.user_id) { + throw MissingUserOrAnonymousIdThrowableError } - } - - // Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - throw InvalidEndpointSelectedThrowableError - } - - // Return transformed payload without sending it to TAPI endpoint - if (features && features['actions-segment-tapi-internal-enabled']) { - statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendIdentify']) - const payload = { ...identifyPayload, type: 'identify' } - return { batch: [payload] } - } + return convertPayload(data) + }) - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:identifyBatchPayload']) + return { batch: identifyPayload } + } +} - return request(`${selectedSegmentEndpoint}/identify`, { - method: 'POST', - json: identifyPayload - }) +function convertPayload(data: Payload) { + return { + userId: data?.user_id, + anonymousId: data?.anonymous_id, + timestamp: data?.timestamp, + context: { + app: data?.application, + campaign: data?.campaign_parameters, + device: data?.device, + ip: data?.ip_address, + locale: data?.locale, + location: data?.location, + network: data?.network, + os: data?.operating_system, + page: data?.page, + screen: data?.screen, + userAgent: data?.user_agent, + timezone: data?.timezone, + groupId: data?.group_id + }, + traits: { + ...data?.traits + }, + type: 'identify' } } diff --git a/packages/destination-actions/src/destinations/segment/sendPage/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment/sendPage/__tests__/__snapshots__/snapshot.test.ts.snap index eb32e6191a..a67203dc92 100644 --- a/packages/destination-actions/src/destinations/segment/sendPage/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment/sendPage/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,89 +2,127 @@ exports[`Testing snapshot for Segment's sendPage destination action: all fields 1`] = ` Object { - "anonymousId": "!jvXo22kjE9u2QlY4S", - "context": Object { - "app": Object { - "build": "!jvXo22kjE9u2QlY4S", - "name": "!jvXo22kjE9u2QlY4S", - "namespace": "!jvXo22kjE9u2QlY4S", - "version": "!jvXo22kjE9u2QlY4S", - }, - "campaign": Object { - "content": "!jvXo22kjE9u2QlY4S", - "medium": "!jvXo22kjE9u2QlY4S", - "name": "!jvXo22kjE9u2QlY4S", - "source": "!jvXo22kjE9u2QlY4S", - "term": "!jvXo22kjE9u2QlY4S", - }, - "device": Object { - "adTracking_Enabled": false, - "advertising_id": "!jvXo22kjE9u2QlY4S", - "id": "!jvXo22kjE9u2QlY4S", - "manufacturer": "!jvXo22kjE9u2QlY4S", - "model": "!jvXo22kjE9u2QlY4S", - "name": "!jvXo22kjE9u2QlY4S", - "token": "!jvXo22kjE9u2QlY4S", - "type": "!jvXo22kjE9u2QlY4S", - }, - "groupId": "!jvXo22kjE9u2QlY4S", - "ip": "!jvXo22kjE9u2QlY4S", - "locale": "!jvXo22kjE9u2QlY4S", - "location": Object { - "city": "!jvXo22kjE9u2QlY4S", - "country": "!jvXo22kjE9u2QlY4S", - "latitude": 64778559868108.8, - "longitude": 64778559868108.8, - "speed": 64778559868108.8, - }, - "network": Object { - "bluetooth": false, - "carrier": "!jvXo22kjE9u2QlY4S", - "cellular": false, - "wifi": false, - }, - "os": Object { - "name": "!jvXo22kjE9u2QlY4S", - "version": "!jvXo22kjE9u2QlY4S", - }, - "page": Object { - "path": "!jvXo22kjE9u2QlY4S", - "referrer": "!jvXo22kjE9u2QlY4S", - "search": "!jvXo22kjE9u2QlY4S", - "title": "!jvXo22kjE9u2QlY4S", - "url": "!jvXo22kjE9u2QlY4S", - }, - "screen": Object { - "density": 64778559868108.8, - "height": 64778559868108.8, - "width": 64778559868108.8, - }, - "timezone": "!jvXo22kjE9u2QlY4S", - "userAgent": "!jvXo22kjE9u2QlY4S", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "!jvXo22kjE9u2QlY4S", + "context": Object { + "app": Object { + "build": "!jvXo22kjE9u2QlY4S", + "name": "!jvXo22kjE9u2QlY4S", + "namespace": "!jvXo22kjE9u2QlY4S", + "version": "!jvXo22kjE9u2QlY4S", + }, + "campaign": Object { + "content": "!jvXo22kjE9u2QlY4S", + "medium": "!jvXo22kjE9u2QlY4S", + "name": "!jvXo22kjE9u2QlY4S", + "source": "!jvXo22kjE9u2QlY4S", + "term": "!jvXo22kjE9u2QlY4S", + }, + "device": Object { + "adTracking_Enabled": false, + "advertising_id": "!jvXo22kjE9u2QlY4S", + "id": "!jvXo22kjE9u2QlY4S", + "manufacturer": "!jvXo22kjE9u2QlY4S", + "model": "!jvXo22kjE9u2QlY4S", + "name": "!jvXo22kjE9u2QlY4S", + "token": "!jvXo22kjE9u2QlY4S", + "type": "!jvXo22kjE9u2QlY4S", + }, + "groupId": "!jvXo22kjE9u2QlY4S", + "ip": "!jvXo22kjE9u2QlY4S", + "locale": "!jvXo22kjE9u2QlY4S", + "location": Object { + "city": "!jvXo22kjE9u2QlY4S", + "country": "!jvXo22kjE9u2QlY4S", + "latitude": 64778559868108.8, + "longitude": 64778559868108.8, + "speed": 64778559868108.8, + }, + "network": Object { + "bluetooth": false, + "carrier": "!jvXo22kjE9u2QlY4S", + "cellular": false, + "wifi": false, + }, + "os": Object { + "name": "!jvXo22kjE9u2QlY4S", + "version": "!jvXo22kjE9u2QlY4S", + }, + "page": Object { + "path": "!jvXo22kjE9u2QlY4S", + "referrer": "!jvXo22kjE9u2QlY4S", + "search": "!jvXo22kjE9u2QlY4S", + "title": "!jvXo22kjE9u2QlY4S", + "url": "!jvXo22kjE9u2QlY4S", + }, + "screen": Object { + "density": 64778559868108.8, + "height": 64778559868108.8, + "width": 64778559868108.8, + }, + "timezone": "!jvXo22kjE9u2QlY4S", + "userAgent": "!jvXo22kjE9u2QlY4S", + }, + "name": "!jvXo22kjE9u2QlY4S", + "properties": Object { + "category": "!jvXo22kjE9u2QlY4S", + "name": "!jvXo22kjE9u2QlY4S", + "path": "!jvXo22kjE9u2QlY4S", + "referrer": "!jvXo22kjE9u2QlY4S", + "search": "!jvXo22kjE9u2QlY4S", + "testType": "!jvXo22kjE9u2QlY4S", + "title": "!jvXo22kjE9u2QlY4S", + "url": "!jvXo22kjE9u2QlY4S", + }, + "timestamp": "!jvXo22kjE9u2QlY4S", + "type": "page", + "userId": "!jvXo22kjE9u2QlY4S", + }, + ], }, - "name": "!jvXo22kjE9u2QlY4S", - "properties": Object { - "category": "!jvXo22kjE9u2QlY4S", - "name": "!jvXo22kjE9u2QlY4S", - "path": "!jvXo22kjE9u2QlY4S", - "referrer": "!jvXo22kjE9u2QlY4S", - "search": "!jvXo22kjE9u2QlY4S", - "testType": "!jvXo22kjE9u2QlY4S", - "title": "!jvXo22kjE9u2QlY4S", - "url": "!jvXo22kjE9u2QlY4S", - }, - "timestamp": "!jvXo22kjE9u2QlY4S", - "userId": "!jvXo22kjE9u2QlY4S", + "output": "Action Executed", } `; exports[`Testing snapshot for Segment's sendPage destination action: required fields 1`] = ` Object { - "anonymousId": "!jvXo22kjE9u2QlY4S", - "context": Object { - "groupId": "!jvXo22kjE9u2QlY4S", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "!jvXo22kjE9u2QlY4S", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": "!jvXo22kjE9u2QlY4S", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "userAgent": undefined, + }, + "name": undefined, + "properties": Object { + "category": undefined, + "name": undefined, + "path": undefined, + "referrer": undefined, + "search": undefined, + "title": undefined, + "url": undefined, + }, + "timestamp": undefined, + "type": "page", + "userId": "!jvXo22kjE9u2QlY4S", + }, + ], }, - "properties": Object {}, - "userId": "!jvXo22kjE9u2QlY4S", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment/sendPage/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment/sendPage/__tests__/index.test.ts index 76a42ea72f..e4413988c2 100644 --- a/packages/destination-actions/src/destinations/segment/sendPage/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendPage/__tests__/index.test.ts @@ -1,8 +1,7 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' -import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' const testDestination = createTestIntegration(Destination) beforeEach(() => nock.cleanAll()) @@ -41,34 +40,7 @@ describe('Segment.sendPage', () => { ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { - const event = createTestEvent({ - name: 'Home', - properties: { - title: 'Home | Example Company', - url: 'http://www.example.com' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) - - await expect( - testDestination.testAction('sendPage', { - event, - mapping: defaultPageMapping, - settings: { - source_write_key: 'test-source-write-key', - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - - test('Should send an page event to Segment', async () => { - // Mock: Segment Page Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/page').reply(200, { success: true }) - + test('Should return transformed event', async () => { const event = createTestEvent({ name: 'Home', properties: { @@ -83,41 +55,7 @@ describe('Segment.sendPage', () => { event, mapping: defaultPageMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT - } - }) - - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.json).toMatchObject({ - userId: event.userId, - anonymousId: event.anonymousId, - properties: { - name: event.name, - ...event.properties - }, - context: {} - }) - }) - - test('Should not send event if actions-segment-tapi-internal-enabled flag is enabled', async () => { - const event = createTestEvent({ - name: 'Home', - properties: { - title: 'Home | Example Company', - url: 'http://www.example.com' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) - - const responses = await testDestination.testAction('sendPage', { - event, - mapping: defaultPageMapping, - settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' }, features: { 'actions-segment-tapi-internal-enabled': true @@ -141,4 +79,57 @@ describe('Segment.sendPage', () => { ] }) }) + + it('should work with batch events', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + name: 'Home', + properties: { + title: 'Home | Example Company', + url: 'http://www.example.com' + }, + userId: 'test-user-ufi5bgkko5', + anonymousId: 'arky4h2sh7k' + }), + createTestEvent({ + name: 'Home', + properties: { + title: 'Home | Example Company', + url: 'http://www.example.com' + }, + userId: 'test-user-ufi5bgkko5' + }) + ] + + const responses = await testDestination.testBatchAction('sendPage', { + events, + mapping: defaultPageMapping, + settings: { + source_write_key: 'test-source-write-key' + } + }) + + const results = testDestination.results + expect(responses.length).toBe(0) + expect(results.length).toBe(1) + expect(results[0].data).toMatchObject({ + batch: [ + { + userId: events[0].userId, + anonymousId: events[0].anonymousId, + properties: { + ...events[0].properties + }, + context: {} + }, + { + userId: events[1].userId, + properties: { + ...events[1].properties + }, + context: {} + } + ] + }) + }) }) diff --git a/packages/destination-actions/src/destinations/segment/sendPage/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment/sendPage/__tests__/snapshot.test.ts index 018671d0eb..3b07974e14 100644 --- a/packages/destination-actions/src/destinations/segment/sendPage/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendPage/__tests__/snapshot.test.ts @@ -2,7 +2,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import nock from 'nock' const testDestination = createTestIntegration(destination) const actionSlug = 'sendPage' @@ -19,63 +18,37 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment/sendPage/generated-types.ts b/packages/destination-actions/src/destinations/segment/sendPage/generated-types.ts index 59b84b874e..d532313c91 100644 --- a/packages/destination-actions/src/destinations/segment/sendPage/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment/sendPage/generated-types.ts @@ -231,4 +231,8 @@ export interface Payload { properties?: { [k: string]: unknown } + /** + * This is always disabled pending a full removal. When enabled, the action will send batch data. Segment accepts batches of up to 225 events. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/segment/sendPage/index.ts b/packages/destination-actions/src/destinations/segment/sendPage/index.ts index c71f7d5d18..fd5830907c 100644 --- a/packages/destination-actions/src/destinations/segment/sendPage/index.ts +++ b/packages/destination-actions/src/destinations/segment/sendPage/index.ts @@ -20,10 +20,10 @@ import { user_agent, timezone, group_id, - properties + properties, + enable_batching } from '../segment-properties' -import { SEGMENT_ENDPOINTS } from '../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' const action: ActionDefinition = { title: 'Send Page', @@ -48,62 +48,64 @@ const action: ActionDefinition = { user_agent, timezone, group_id, - properties + properties, + enable_batching }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { if (!payload.anonymous_id && !payload.user_id) { throw MissingUserOrAnonymousIdThrowableError } - const pagePayload: Object = { - userId: payload?.user_id, - anonymousId: payload?.anonymous_id, - timestamp: payload?.timestamp, - name: payload?.page_name, - context: { - app: payload?.application, - campaign: payload?.campaign_parameters, - device: payload?.device, - ip: payload?.ip_address, - locale: payload?.locale, - location: payload?.location, - network: payload?.network, - os: payload?.operating_system, - page: payload?.page, - screen: payload?.screen, - userAgent: payload?.user_agent, - timezone: payload?.timezone, - groupId: payload?.group_id - }, - properties: { - name: payload?.page_name, - category: payload?.page_category, - path: payload?.page?.path, - referrer: payload?.page?.referrer, - search: payload?.page?.search, - title: payload?.page?.title, - url: payload?.page?.url, - ...payload?.properties - } - } + const pagePayload: Object = convertPayload(payload) - // Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - throw InvalidEndpointSelectedThrowableError - } + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendPage']) + return { batch: [pagePayload] } + }, + performBatch: (_request, { payload, statsContext }) => { + const pagePayload = payload.map((data) => { + if (!data.anonymous_id && !data.user_id) { + throw MissingUserOrAnonymousIdThrowableError + } + return convertPayload(data) + }) - // Return transformed payload without sending it to TAPI endpoint - if (features && features['actions-segment-tapi-internal-enabled']) { - statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendPage']) - const payload = { ...pagePayload, type: 'page' } - return { batch: [payload] } - } + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendBatchPage']) + return { batch: pagePayload } + } +} - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url - return request(`${selectedSegmentEndpoint}/page`, { - method: 'POST', - json: pagePayload - }) +function convertPayload(data: Payload) { + return { + userId: data?.user_id, + anonymousId: data?.anonymous_id, + timestamp: data?.timestamp, + name: data?.page_name, + context: { + app: data?.application, + campaign: data?.campaign_parameters, + device: data?.device, + ip: data?.ip_address, + locale: data?.locale, + location: data?.location, + network: data?.network, + os: data?.operating_system, + page: data?.page, + screen: data?.screen, + userAgent: data?.user_agent, + timezone: data?.timezone, + groupId: data?.group_id + }, + properties: { + name: data?.page_name, + category: data?.page_category, + path: data?.page?.path, + referrer: data?.page?.referrer, + search: data?.page?.search, + title: data?.page?.title, + url: data?.page?.url, + ...data?.properties + }, + type: 'page' } } diff --git a/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/__snapshots__/snapshot.test.ts.snap index 956e3c63c0..185a066d94 100644 --- a/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,82 +2,113 @@ exports[`Testing snapshot for Segment's sendScreen destination action: all fields 1`] = ` Object { - "anonymousId": "O3*7wUzuzQ", - "context": Object { - "app": Object { - "build": "O3*7wUzuzQ", - "name": "O3*7wUzuzQ", - "namespace": "O3*7wUzuzQ", - "version": "O3*7wUzuzQ", - }, - "campaign": Object { - "content": "O3*7wUzuzQ", - "medium": "O3*7wUzuzQ", - "name": "O3*7wUzuzQ", - "source": "O3*7wUzuzQ", - "term": "O3*7wUzuzQ", - }, - "device": Object { - "adTracking_Enabled": true, - "advertising_id": "O3*7wUzuzQ", - "id": "O3*7wUzuzQ", - "manufacturer": "O3*7wUzuzQ", - "model": "O3*7wUzuzQ", - "name": "O3*7wUzuzQ", - "token": "O3*7wUzuzQ", - "type": "O3*7wUzuzQ", - }, - "groupId": "O3*7wUzuzQ", - "ip": "O3*7wUzuzQ", - "locale": "O3*7wUzuzQ", - "location": Object { - "city": "O3*7wUzuzQ", - "country": "O3*7wUzuzQ", - "latitude": -28830627654533.12, - "longitude": -28830627654533.12, - "speed": -28830627654533.12, - }, - "network": Object { - "bluetooth": true, - "carrier": "O3*7wUzuzQ", - "cellular": true, - "wifi": true, - }, - "os": Object { - "name": "O3*7wUzuzQ", - "version": "O3*7wUzuzQ", - }, - "page": Object { - "path": "O3*7wUzuzQ", - "referrer": "O3*7wUzuzQ", - "search": "O3*7wUzuzQ", - "title": "O3*7wUzuzQ", - "url": "O3*7wUzuzQ", - }, - "screen": Object { - "density": -28830627654533.12, - "height": -28830627654533.12, - "width": -28830627654533.12, - }, - "userAgent": "O3*7wUzuzQ", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "O3*7wUzuzQ", + "context": Object { + "app": Object { + "build": "O3*7wUzuzQ", + "name": "O3*7wUzuzQ", + "namespace": "O3*7wUzuzQ", + "version": "O3*7wUzuzQ", + }, + "campaign": Object { + "content": "O3*7wUzuzQ", + "medium": "O3*7wUzuzQ", + "name": "O3*7wUzuzQ", + "source": "O3*7wUzuzQ", + "term": "O3*7wUzuzQ", + }, + "device": Object { + "adTracking_Enabled": true, + "advertising_id": "O3*7wUzuzQ", + "id": "O3*7wUzuzQ", + "manufacturer": "O3*7wUzuzQ", + "model": "O3*7wUzuzQ", + "name": "O3*7wUzuzQ", + "token": "O3*7wUzuzQ", + "type": "O3*7wUzuzQ", + }, + "groupId": "O3*7wUzuzQ", + "ip": "O3*7wUzuzQ", + "locale": "O3*7wUzuzQ", + "location": Object { + "city": "O3*7wUzuzQ", + "country": "O3*7wUzuzQ", + "latitude": -28830627654533.12, + "longitude": -28830627654533.12, + "speed": -28830627654533.12, + }, + "network": Object { + "bluetooth": true, + "carrier": "O3*7wUzuzQ", + "cellular": true, + "wifi": true, + }, + "os": Object { + "name": "O3*7wUzuzQ", + "version": "O3*7wUzuzQ", + }, + "page": Object { + "path": "O3*7wUzuzQ", + "referrer": "O3*7wUzuzQ", + "search": "O3*7wUzuzQ", + "title": "O3*7wUzuzQ", + "url": "O3*7wUzuzQ", + }, + "screen": Object { + "density": -28830627654533.12, + "height": -28830627654533.12, + "width": -28830627654533.12, + }, + "userAgent": "O3*7wUzuzQ", + }, + "name": "O3*7wUzuzQ", + "properties": Object { + "name": "O3*7wUzuzQ", + "testType": "O3*7wUzuzQ", + }, + "timestamp": "O3*7wUzuzQ", + "type": "screen", + "userId": "O3*7wUzuzQ", + }, + ], }, - "name": "O3*7wUzuzQ", - "properties": Object { - "name": "O3*7wUzuzQ", - "testType": "O3*7wUzuzQ", - }, - "timestamp": "O3*7wUzuzQ", - "userId": "O3*7wUzuzQ", + "output": "Action Executed", } `; exports[`Testing snapshot for Segment's sendScreen destination action: required fields 1`] = ` Object { - "anonymousId": "O3*7wUzuzQ", - "context": Object { - "groupId": "O3*7wUzuzQ", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "O3*7wUzuzQ", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": "O3*7wUzuzQ", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "userAgent": undefined, + }, + "name": undefined, + "properties": Object { + "name": undefined, + }, + "timestamp": undefined, + "type": "screen", + "userId": "O3*7wUzuzQ", + }, + ], }, - "properties": Object {}, - "userId": "O3*7wUzuzQ", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/index.test.ts index 2046b55f45..fc376f3ab6 100644 --- a/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/index.test.ts @@ -1,8 +1,7 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' -import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' const testDestination = createTestIntegration(Destination) @@ -38,33 +37,7 @@ describe('Segment.sendScreen', () => { ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { - const event = createTestEvent({ - name: 'Home', - properties: { - 'Feed Type': 'private' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) - - await expect( - testDestination.testAction('sendScreen', { - event, - mapping: defaultScreenMapping, - settings: { - source_write_key: 'test-source-write-key', - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - - test('Should send an screen event to Segment', async () => { - // Mock: Segment Screen Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/screen').reply(200, { success: true }) - + test('Should return transformed event', async () => { const event = createTestEvent({ name: 'Home', properties: { @@ -78,55 +51,73 @@ describe('Segment.sendScreen', () => { event, mapping: defaultScreenMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.json).toMatchObject({ - userId: event.userId, - anonymousId: event.anonymousId, - properties: { - ...event.properties - }, - context: {} + const results = testDestination.results + expect(responses.length).toBe(0) + expect(results.length).toBe(3) + expect(results[2].data).toMatchObject({ + batch: [ + { + userId: event.userId, + anonymousId: event.anonymousId, + properties: { + ...event.properties + }, + context: {} + } + ] }) }) - test('Should not send event if actions-segment-tapi-internal-enabled flag is enabled', async () => { - const event = createTestEvent({ - name: 'Home', - properties: { - 'Feed Type': 'private' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k' - }) + it('should work with batch events', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + name: 'Home', + properties: { + 'Feed Type': 'private' + }, + userId: 'test-user-ufi5bgkko5', + anonymousId: 'arky4h2sh7k' + }), + createTestEvent({ + name: 'Home', + properties: { + 'Feed Type': 'private' + }, + userId: 'test-user-ufi5bgkko5', + anonymousId: 'arky4h2sh7k' + }) + ] - const responses = await testDestination.testAction('sendScreen', { - event, + const responses = await testDestination.testBatchAction('sendScreen', { + events, mapping: defaultScreenMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT - }, - features: { - 'actions-segment-tapi-internal-enabled': true + source_write_key: 'test-source-write-key' } }) const results = testDestination.results expect(responses.length).toBe(0) - expect(results.length).toBe(3) - expect(results[2].data).toMatchObject({ + expect(results.length).toBe(1) + expect(results[0].data).toMatchObject({ batch: [ { - userId: event.userId, - anonymousId: event.anonymousId, + userId: events[0].userId, + anonymousId: events[0].anonymousId, properties: { - ...event.properties + ...events[0].properties + }, + context: {} + }, + { + userId: events[1].userId, + anonymousId: events[1].anonymousId, + properties: { + ...events[1].properties }, context: {} } diff --git a/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/snapshot.test.ts index 2dcf0fa4a8..99bdc3e6f0 100644 --- a/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendScreen/__tests__/snapshot.test.ts @@ -1,8 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' -import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import nock from 'nock' const testDestination = createTestIntegration(destination) const actionSlug = 'sendScreen' @@ -10,8 +8,7 @@ const destinationSlug = 'Segment' const seedName = `${destinationSlug}#${actionSlug}` const settingsData = { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { @@ -19,63 +16,37 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } - - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment/sendScreen/generated-types.ts b/packages/destination-actions/src/destinations/segment/sendScreen/generated-types.ts index d54821c89a..0c85d4a6e3 100644 --- a/packages/destination-actions/src/destinations/segment/sendScreen/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment/sendScreen/generated-types.ts @@ -227,4 +227,8 @@ export interface Payload { properties?: { [k: string]: unknown } + /** + * This is always disabled pending a full removal. When enabled, the action will send batch data. Segment accepts batches of up to 225 events. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/segment/sendScreen/index.ts b/packages/destination-actions/src/destinations/segment/sendScreen/index.ts index e45b668c25..cb0cd36a9f 100644 --- a/packages/destination-actions/src/destinations/segment/sendScreen/index.ts +++ b/packages/destination-actions/src/destinations/segment/sendScreen/index.ts @@ -19,10 +19,10 @@ import { user_id, screen, locale, - location + location, + enable_batching } from '../segment-properties' -import { SEGMENT_ENDPOINTS } from '../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' const action: ActionDefinition = { title: 'Send Screen', @@ -46,56 +46,57 @@ const action: ActionDefinition = { user_agent, timezone, group_id, - properties + properties, + enable_batching }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { if (!payload.anonymous_id && !payload.user_id) { throw MissingUserOrAnonymousIdThrowableError } - const screenPayload: Object = { - userId: payload?.user_id, - anonymousId: payload?.anonymous_id, - timestamp: payload?.timestamp, - name: payload?.screen_name, - context: { - app: payload?.application, - campaign: payload?.campaign_parameters, - device: payload?.device, - ip: payload?.ip_address, - locale: payload?.locale, - location: payload?.location, - network: payload?.network, - os: payload?.operating_system, - page: payload?.page, - screen: payload?.screen, - userAgent: payload?.user_agent, - groupId: payload?.group_id - }, - properties: { - name: payload?.screen_name, - ...payload?.properties - } - } - - // Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - throw InvalidEndpointSelectedThrowableError - } + const screenPayload: Object = convertPayload(payload) - // Return transformed payload without sending it to TAPI endpoint - if (features && features['actions-segment-tapi-internal-enabled']) { - statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendScreen']) - const payload = { ...screenPayload, type: 'screen' } - return { batch: [payload] } - } + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendScreen']) + return { batch: [screenPayload] } + }, + performBatch: (_request, { payload, statsContext }) => { + const screenPayload = payload.map((data) => { + if (!data.anonymous_id && !data.user_id) { + throw MissingUserOrAnonymousIdThrowableError + } + return convertPayload(data) + }) - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendBatchScreen']) + return { batch: screenPayload } + } +} - return request(`${selectedSegmentEndpoint}/screen`, { - method: 'POST', - json: screenPayload - }) +function convertPayload(data: Payload) { + return { + userId: data?.user_id, + anonymousId: data?.anonymous_id, + timestamp: data?.timestamp, + name: data?.screen_name, + context: { + app: data?.application, + campaign: data?.campaign_parameters, + device: data?.device, + ip: data?.ip_address, + locale: data?.locale, + location: data?.location, + network: data?.network, + os: data?.operating_system, + page: data?.page, + screen: data?.screen, + userAgent: data?.user_agent, + groupId: data?.group_id + }, + properties: { + name: data?.screen_name, + ...data?.properties + }, + type: 'screen' } } diff --git a/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/__snapshots__/snapshot.test.ts.snap index 14a1a11f4e..474431be76 100644 --- a/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/__snapshots__/snapshot.test.ts.snap @@ -2,87 +2,116 @@ exports[`Testing snapshot for Segment's sendTrack destination action: all fields 1`] = ` Object { - "anonymousId": "ET^Xg5cIGF]2ok", - "context": Object { - "app": Object { - "build": "ET^Xg5cIGF]2ok", - "name": "ET^Xg5cIGF]2ok", - "namespace": "ET^Xg5cIGF]2ok", - "version": "ET^Xg5cIGF]2ok", - }, - "campaign": Object { - "content": "ET^Xg5cIGF]2ok", - "medium": "ET^Xg5cIGF]2ok", - "name": "ET^Xg5cIGF]2ok", - "source": "ET^Xg5cIGF]2ok", - "term": "ET^Xg5cIGF]2ok", - }, - "device": Object { - "adTracking_Enabled": false, - "advertising_id": "ET^Xg5cIGF]2ok", - "id": "ET^Xg5cIGF]2ok", - "manufacturer": "ET^Xg5cIGF]2ok", - "model": "ET^Xg5cIGF]2ok", - "name": "ET^Xg5cIGF]2ok", - "token": "ET^Xg5cIGF]2ok", - "type": "ET^Xg5cIGF]2ok", - }, - "groupId": "ET^Xg5cIGF]2ok", - "ip": "ET^Xg5cIGF]2ok", - "locale": "ET^Xg5cIGF]2ok", - "location": Object { - "city": "ET^Xg5cIGF]2ok", - "country": "ET^Xg5cIGF]2ok", - "latitude": 17838569150218.24, - "longitude": 17838569150218.24, - "speed": 17838569150218.24, - }, - "network": Object { - "bluetooth": false, - "carrier": "ET^Xg5cIGF]2ok", - "cellular": false, - "wifi": false, - }, - "os": Object { - "name": "ET^Xg5cIGF]2ok", - "version": "ET^Xg5cIGF]2ok", - }, - "page": Object { - "path": "ET^Xg5cIGF]2ok", - "referrer": "ET^Xg5cIGF]2ok", - "search": "ET^Xg5cIGF]2ok", - "title": "ET^Xg5cIGF]2ok", - "url": "ET^Xg5cIGF]2ok", - }, - "screen": Object { - "density": 17838569150218.24, - "height": 17838569150218.24, - "width": 17838569150218.24, - }, - "timezone": "ET^Xg5cIGF]2ok", - "traits": Object { - "testType": "ET^Xg5cIGF]2ok", - }, - "userAgent": "ET^Xg5cIGF]2ok", + "data": Object { + "batch": Array [ + Object { + "anonymousId": "ET^Xg5cIGF]2ok", + "context": Object { + "app": Object { + "build": "ET^Xg5cIGF]2ok", + "name": "ET^Xg5cIGF]2ok", + "namespace": "ET^Xg5cIGF]2ok", + "version": "ET^Xg5cIGF]2ok", + }, + "campaign": Object { + "content": "ET^Xg5cIGF]2ok", + "medium": "ET^Xg5cIGF]2ok", + "name": "ET^Xg5cIGF]2ok", + "source": "ET^Xg5cIGF]2ok", + "term": "ET^Xg5cIGF]2ok", + }, + "device": Object { + "adTracking_Enabled": false, + "advertising_id": "ET^Xg5cIGF]2ok", + "id": "ET^Xg5cIGF]2ok", + "manufacturer": "ET^Xg5cIGF]2ok", + "model": "ET^Xg5cIGF]2ok", + "name": "ET^Xg5cIGF]2ok", + "token": "ET^Xg5cIGF]2ok", + "type": "ET^Xg5cIGF]2ok", + }, + "groupId": "ET^Xg5cIGF]2ok", + "ip": "ET^Xg5cIGF]2ok", + "locale": "ET^Xg5cIGF]2ok", + "location": Object { + "city": "ET^Xg5cIGF]2ok", + "country": "ET^Xg5cIGF]2ok", + "latitude": 17838569150218.24, + "longitude": 17838569150218.24, + "speed": 17838569150218.24, + }, + "network": Object { + "bluetooth": false, + "carrier": "ET^Xg5cIGF]2ok", + "cellular": false, + "wifi": false, + }, + "os": Object { + "name": "ET^Xg5cIGF]2ok", + "version": "ET^Xg5cIGF]2ok", + }, + "page": Object { + "path": "ET^Xg5cIGF]2ok", + "referrer": "ET^Xg5cIGF]2ok", + "search": "ET^Xg5cIGF]2ok", + "title": "ET^Xg5cIGF]2ok", + "url": "ET^Xg5cIGF]2ok", + }, + "screen": Object { + "density": 17838569150218.24, + "height": 17838569150218.24, + "width": 17838569150218.24, + }, + "timezone": "ET^Xg5cIGF]2ok", + "traits": Object { + "testType": "ET^Xg5cIGF]2ok", + }, + "userAgent": "ET^Xg5cIGF]2ok", + }, + "event": "ET^Xg5cIGF]2ok", + "properties": Object { + "testType": "ET^Xg5cIGF]2ok", + }, + "timestamp": "ET^Xg5cIGF]2ok", + "type": "track", + "userId": "ET^Xg5cIGF]2ok", + }, + ], }, - "event": "ET^Xg5cIGF]2ok", - "properties": Object { - "testType": "ET^Xg5cIGF]2ok", - }, - "timestamp": "ET^Xg5cIGF]2ok", - "userId": "ET^Xg5cIGF]2ok", + "output": "Action Executed", } `; exports[`Testing snapshot for Segment's sendTrack destination action: required fields 1`] = ` Object { - "anonymousId": "ET^Xg5cIGF]2ok", - "context": Object { - "groupId": "ET^Xg5cIGF]2ok", - "traits": Object {}, + "data": Object { + "batch": Array [ + Object { + "anonymousId": "ET^Xg5cIGF]2ok", + "context": Object { + "app": undefined, + "campaign": undefined, + "device": undefined, + "groupId": "ET^Xg5cIGF]2ok", + "ip": undefined, + "locale": undefined, + "location": undefined, + "network": undefined, + "os": undefined, + "page": undefined, + "screen": undefined, + "timezone": undefined, + "traits": Object {}, + "userAgent": undefined, + }, + "event": "ET^Xg5cIGF]2ok", + "properties": Object {}, + "timestamp": undefined, + "type": "track", + "userId": "ET^Xg5cIGF]2ok", + }, + ], }, - "event": "ET^Xg5cIGF]2ok", - "properties": Object {}, - "userId": "ET^Xg5cIGF]2ok", + "output": "Action Executed", } `; diff --git a/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/index.test.ts b/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/index.test.ts index baab3de862..b32a6cd2bd 100644 --- a/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/index.test.ts @@ -1,8 +1,7 @@ import nock from 'nock' -import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { createTestEvent, createTestIntegration, SegmentEvent } from '@segment/actions-core' import Destination from '../../index' -import { SEGMENT_ENDPOINTS, DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../../errors' const testDestination = createTestIntegration(Destination) @@ -48,7 +47,7 @@ describe('Segment.sendTrack', () => { ).rejects.toThrowError(MissingUserOrAnonymousIdThrowableError) }) - test('Should throw an error if Segment Endpoint is incorrectly defined', async () => { + test('Should return transformed event', async () => { const event = createTestEvent({ properties: { plan: 'Business' @@ -58,86 +57,76 @@ describe('Segment.sendTrack', () => { event: 'Test Event' }) - await expect( - testDestination.testAction('sendTrack', { - event, - mapping: defaultTrackMapping, - settings: { - source_write_key: 'test-source-write-key', - endpoint: 'incorrect-endpoint' - } - }) - ).rejects.toThrowError(InvalidEndpointSelectedThrowableError) - }) - - test('Should send an track event to Segment', async () => { - // Mock: Segment Track Call - const segmentEndpoint = SEGMENT_ENDPOINTS[DEFAULT_SEGMENT_ENDPOINT].url - nock(segmentEndpoint).post('/track').reply(200, { success: true }) - - const event = createTestEvent({ - properties: { - plan: 'Business' - }, - traits: { email: 'testuser@gmail.com', age: 47 }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k', - event: 'Test Event' - }) - const responses = await testDestination.testAction('sendTrack', { event, mapping: defaultTrackMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } }) - expect(responses.length).toBe(1) - expect(responses[0].status).toEqual(200) - expect(responses[0].options.json).toMatchObject({ - userId: event.userId, - anonymousId: event.anonymousId, - properties: { - ...event.properties - }, - context: { traits: { email: 'testuser@gmail.com', age: 47 } } + const results = testDestination.results + expect(responses.length).toBe(0) + expect(results.length).toBe(3) + expect(results[2].data).toMatchObject({ + batch: [ + { + userId: event.userId, + anonymousId: event.anonymousId, + properties: { + ...event.properties + }, + context: {} + } + ] }) }) - test('Should not send event if actions-segment-tapi-internal-enabled flag is enabled', async () => { - const event = createTestEvent({ - properties: { - plan: 'Business' - }, - userId: 'test-user-ufi5bgkko5', - anonymousId: 'arky4h2sh7k', - event: 'Test Event' - }) + it('should work with batch events', async () => { + const events: SegmentEvent[] = [ + createTestEvent({ + properties: { + plan: 'Business' + }, + userId: 'test-user-ufi5bgkko5', + anonymousId: 'arky4h2sh7k', + event: 'Test Event' + }), + createTestEvent({ + properties: { + plan: 'Business' + }, + event: 'Test Event', + timestamp: '2022-12-01T17:40:04.055Z' + }) + ] - const responses = await testDestination.testAction('sendTrack', { - event, + const responses = await testDestination.testBatchAction('sendTrack', { + events, mapping: defaultTrackMapping, settings: { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT - }, - features: { - 'actions-segment-tapi-internal-enabled': true + source_write_key: 'test-source-write-key' } }) const results = testDestination.results expect(responses.length).toBe(0) - expect(results.length).toBe(3) - expect(results[2].data).toMatchObject({ + expect(results.length).toBe(1) + expect(results[0].data).toMatchObject({ batch: [ { - userId: event.userId, - anonymousId: event.anonymousId, + userId: events[0].userId, + anonymousId: events[0].anonymousId, properties: { - ...event.properties + ...events[0].properties + }, + context: {} + }, + { + userId: events[1].userId, + anonymousId: events[1].anonymousId, + properties: { + ...events[1].properties }, context: {} } diff --git a/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/snapshot.test.ts index 18722602e2..04d033a0a9 100644 --- a/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/segment/sendTrack/__tests__/snapshot.test.ts @@ -1,8 +1,6 @@ import { createTestEvent, createTestIntegration } from '@segment/actions-core' import { generateTestData } from '../../../../lib/test-data' import destination from '../../index' -import { DEFAULT_SEGMENT_ENDPOINT } from '../../properties' -import nock from 'nock' const testDestination = createTestIntegration(destination) const actionSlug = 'sendTrack' @@ -10,8 +8,7 @@ const destinationSlug = 'Segment' const seedName = `${destinationSlug}#${actionSlug}` const settingsData = { - source_write_key: 'test-source-write-key', - endpoint: DEFAULT_SEGMENT_ENDPOINT + source_write_key: 'test-source-write-key' } describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { @@ -19,63 +16,44 @@ describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination ac const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, true) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) - expect(request.headers).toMatchSnapshot() + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) it('all fields', async () => { const action = destination.actions[actionSlug] const [eventData, _] = generateTestData(seedName, destination, action, false) - nock(/.*/).persist().get(/.*/).reply(200) - nock(/.*/).persist().post(/.*/).reply(200) - nock(/.*/).persist().put(/.*/).reply(200) - const event = createTestEvent({ properties: eventData }) - const responses = await testDestination.testAction(actionSlug, { + await testDestination.testAction(actionSlug, { event: event, mapping: event.properties, settings: settingsData, auth: undefined }) - const request = responses[0].request - const rawBody = await request.text() - - try { - const json = JSON.parse(rawBody) - expect(json).toMatchSnapshot() - return - } catch (err) { - expect(rawBody).toMatchSnapshot() - } + const results = testDestination.results + expect(results[results.length - 1]).toMatchSnapshot() }) }) diff --git a/packages/destination-actions/src/destinations/segment/sendTrack/generated-types.ts b/packages/destination-actions/src/destinations/segment/sendTrack/generated-types.ts index 207bac4f02..096970d222 100644 --- a/packages/destination-actions/src/destinations/segment/sendTrack/generated-types.ts +++ b/packages/destination-actions/src/destinations/segment/sendTrack/generated-types.ts @@ -233,4 +233,8 @@ export interface Payload { traits?: { [k: string]: unknown } + /** + * This is always disabled pending a full removal. When enabled, the action will send batch data. Segment accepts batches of up to 225 events. + */ + enable_batching?: boolean } diff --git a/packages/destination-actions/src/destinations/segment/sendTrack/index.ts b/packages/destination-actions/src/destinations/segment/sendTrack/index.ts index 06247d5fbc..973e6dd517 100644 --- a/packages/destination-actions/src/destinations/segment/sendTrack/index.ts +++ b/packages/destination-actions/src/destinations/segment/sendTrack/index.ts @@ -20,10 +20,10 @@ import { timezone, group_id, properties, - traits + traits, + enable_batching } from '../segment-properties' -import { SEGMENT_ENDPOINTS } from '../properties' -import { MissingUserOrAnonymousIdThrowableError, InvalidEndpointSelectedThrowableError } from '../errors' +import { MissingUserOrAnonymousIdThrowableError } from '../errors' const action: ActionDefinition = { title: 'Send Track', @@ -48,58 +48,60 @@ const action: ActionDefinition = { timezone, group_id, properties, - traits + traits, + enable_batching }, - perform: (request, { payload, settings, features, statsContext }) => { + perform: (_request, { payload, statsContext }) => { if (!payload.anonymous_id && !payload.user_id) { throw MissingUserOrAnonymousIdThrowableError } - const trackPayload: Object = { - userId: payload?.user_id, - anonymousId: payload?.anonymous_id, - timestamp: payload?.timestamp, - event: payload?.event_name, - context: { - traits: { - ...payload?.traits - }, - app: payload?.application, - campaign: payload?.campaign_parameters, - device: payload?.device, - ip: payload?.ip_address, - locale: payload?.locale, - location: payload?.location, - network: payload?.network, - os: payload?.operating_system, - page: payload?.page, - screen: payload?.screen, - userAgent: payload?.user_agent, - timezone: payload?.timezone, - groupId: payload?.group_id - }, - properties: { - ...payload?.properties - } - } + const trackPayload: Object = convertPayload(payload) - // Throw an error if endpoint is not defined or invalid - if (!settings.endpoint || !(settings.endpoint in SEGMENT_ENDPOINTS)) { - throw InvalidEndpointSelectedThrowableError - } + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendTrack']) + return { batch: [trackPayload] } + }, + performBatch: (_request, { payload, statsContext }) => { + const trackPayload = payload.map((data) => { + if (!data.anonymous_id && !data.user_id) { + throw MissingUserOrAnonymousIdThrowableError + } + return convertPayload(data) + }) - // Return transformed payload without sending it to TAPI endpoint - if (features && features['actions-segment-tapi-internal-enabled']) { - statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendTrack']) - const payload = { ...trackPayload, type: 'track' } - return { batch: [payload] } - } + statsContext?.statsClient?.incr('tapi_internal', 1, [...statsContext.tags, 'action:sendBatchTrack']) + return { batch: trackPayload } + } +} - const selectedSegmentEndpoint = SEGMENT_ENDPOINTS[settings.endpoint].url - return request(`${selectedSegmentEndpoint}/track`, { - method: 'POST', - json: trackPayload - }) +function convertPayload(data: Payload) { + return { + userId: data?.user_id, + anonymousId: data?.anonymous_id, + timestamp: data?.timestamp, + event: data?.event_name, + context: { + traits: { + ...data?.traits + }, + app: data?.application, + campaign: data?.campaign_parameters, + device: data?.device, + ip: data?.ip_address, + locale: data?.locale, + location: data?.location, + network: data?.network, + os: data?.operating_system, + page: data?.page, + screen: data?.screen, + userAgent: data?.user_agent, + timezone: data?.timezone, + groupId: data?.group_id + }, + properties: { + ...data?.properties + }, + type: 'track' } } diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts b/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts index b46cd9eadf..480a0adb18 100644 --- a/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts +++ b/packages/destination-actions/src/destinations/snap-conversions-api/_tests_/index.test.ts @@ -2,16 +2,17 @@ import nock from 'nock' import { createTestEvent, createTestIntegration } from '@segment/actions-core' import Definition from '../index' import { Settings } from '../generated-types' +import { buildRequestURL } from '../reportConversionEvent/snap-capi-v3' const testDestination = createTestIntegration(Definition) const timestamp = '2022-05-12T15:21:15.449Z' const settings: Settings = { snap_app_id: 'test123', - pixel_id: 'test123', - app_id: 'test123' + pixel_id: 'pixel123', + app_id: 'app123' } -const accessToken = 'test123' -const refreshToken = 'test123' +const accessToken = 'access123' +const refreshToken = 'refresh123' const testEvent = createTestEvent({ timestamp: timestamp, @@ -19,7 +20,7 @@ const testEvent = createTestEvent({ event: 'PURCHASE', type: 'track', properties: { - email: 'test123@gmail.com', + email: ' Test123@gmail.com ', phone: '+44 844 412 4653', event_tag: 'back-to-school', number_items: 10, @@ -29,201 +30,464 @@ const testEvent = createTestEvent({ } }) -const conversionEventUrl = 'https://tr.snapchat.com/v2/conversion' +const features = {} beforeEach(() => { - nock.cleanAll(); // Clear all Nock interceptors and filters -}); + nock.cleanAll() // Clear all Nock interceptors and filters +}) describe('Snap Conversions API ', () => { describe('ReportConversionEvent', () => { - it('should use products array over number_items, product_id and category fields', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - const event = createTestEvent({ - ...testEvent, - properties: { - email: 'test123@gmail.com', - phone: '+44 844 412 4653', - event_tag: 'back-to-school', - number_items: 10, - price: '15', - currency: 'USD', - level: 3, - products: [ - { product_id: '123', category: 'games', brand: 'Hasbro' }, - { product_id: '456', category: 'games', brand: 'Mattel' } - ] - }, - context: {} - }) + describe('CAPIv3 Implementation', () => { + it('should use products array over number_items, product_id and category fields', async () => { + nock(/.*/).post(/.*/).reply(200) + + const event = createTestEvent({ + ...testEvent, + properties: { + email: 'test123@gmail.com', + phone: '+44 844 412 4653', + event_tag: 'back-to-school', + quantity: 10, + revenue: '15', + currency: 'USD', + level: 3, + products: [ + { product_id: '123', category: 'games', brand: 'Hasbro' }, + { product_id: '456', category: 'games', brand: 'Mattel' } + ] + }, + context: {} + }) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' + } + }) - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'PURCHASE', - event_conversion_type: 'WEB' - } + expect(responses).not.toBeNull() + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + const { data } = body + expect(data.length).toBe(1) + + const { integration, event_name, event_time, user_data, custom_data, action_source, app_data } = data[0] + const { em, ph } = user_data + const { brands, content_category, content_ids, currency, num_items, value } = custom_data + + expect(integration).toBe('segment') + expect(event_name).toBe('PURCHASE') + expect(event_time).toBe(1652368875449) + + expect(em[0]).toBe('cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9') + expect(ph[0]).toBe('dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383') + expect(currency).toBe('USD') + expect(value).toBe(15) + expect(action_source).toBe('website') + // app_data is only defined when action_source is app + expect(app_data).toBeUndefined() + + expect(brands).toEqual(['Hasbro', 'Mattel']) + expect(content_category).toEqual(['games', 'games']) + expect(content_ids).toEqual(['123', '456']) + expect(num_items).toBe(2) }) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + it('should handle a basic event', async () => { + nock(/.*/).post(/.*/).reply(200) - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"PURCHASE\\",\\"event_conversion_type\\":\\"WEB\\",\\"timestamp\\":1652368875449,\\"hashed_email\\":\\"cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9\\",\\"hashed_phone_number\\":\\"dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383\\",\\"item_category\\":\\"games;games\\",\\"brands\\":[\\"Hasbro\\",\\"Mattel\\"],\\"item_ids\\":\\"123;456\\",\\"currency\\":\\"USD\\",\\"pixel_id\\":\\"test123\\"}"` - ) - }) + const event = createTestEvent(testEvent) - it('should handle a basic event', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - - const event = createTestEvent(testEvent) - - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'PURCHASE', - event_conversion_type: 'WEB' - } + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' + } + }) + + expect(responses).not.toBeNull() + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + + const { data } = body + expect(data.length).toBe(1) + + const { + integration, + event_name, + event_source_url, + event_time, + user_data, + custom_data, + action_source, + app_data + } = data[0] + const { client_ip_address, client_user_agent, em, ph } = user_data + const { currency, value } = custom_data + + expect(integration).toBe('segment') + expect(event_name).toBe('PURCHASE') + expect(event_source_url).toBe('https://segment.com/academy/') + expect(event_time).toBe(1652368875449) + expect(client_ip_address).toBe('8.8.8.8') + expect(client_user_agent).toBe( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + ) + expect(em[0]).toBe('cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9') + expect(ph[0]).toBe('dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383') + expect(currency).toBe('USD') + expect(value).toBe(15) + expect(action_source).toBe('website') + // app_data is only defined when action_source is app + expect(app_data).toBeUndefined() }) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + it('should fail web event without pixel_id', async () => { + nock(/.*/).post(/.*/).reply(200) + + const event = createTestEvent(testEvent) + + await expect( + testDestination.testAction('reportConversionEvent', { + event, + settings: { + snap_app_id: 'test123' + }, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' + } + }) + ).rejects.toThrowError('If event conversion type is "WEB" then Pixel ID must be defined') + }) - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"PURCHASE\\",\\"event_conversion_type\\":\\"WEB\\",\\"timestamp\\":1652368875449,\\"hashed_email\\":\\"cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9\\",\\"hashed_phone_number\\":\\"dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383\\",\\"user_agent\\":\\"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1\\",\\"hashed_ip_address\\":\\"838c4c2573848f58e74332341a7ca6bc5cd86a8aec7d644137d53b4d597f10f5\\",\\"price\\":15,\\"currency\\":\\"USD\\",\\"page_url\\":\\"https://segment.com/academy/\\",\\"pixel_id\\":\\"test123\\"}"` - ) - }) + it('should fail app event without snap_app_id', async () => { + nock(/.*/).post(/.*/).reply(200) + + const event = createTestEvent(testEvent) + + await expect( + testDestination.testAction('reportConversionEvent', { + event, + settings: { + pixel_id: 'test123', + app_id: 'test123' + }, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'MOBILE_APP' + } + }) + ).rejects.toThrowError('If event conversion type is "MOBILE_APP" then Snap App ID must be defined') + }) - it('should fail web event without pixel_id', async () => { - nock(conversionEventUrl).post('').reply(400, {}) + it('should handle an offline event conversion type', async () => { + nock(/.*/).post(/.*/).reply(200) - const event = createTestEvent(testEvent) + const event = createTestEvent(testEvent) - await expect( - testDestination.testAction('reportConversionEvent', { + const responses = await testDestination.testAction('reportConversionEvent', { event, - settings: { - app_id: 'test123' - }, + settings, useDefaultMappings: true, auth: { accessToken, refreshToken }, + features, mapping: { - event_type: 'PURCHASE', - event_conversion_type: 'WEB' + event_type: 'SAVE', + event_conversion_type: 'OFFLINE' } }) - ).rejects.toThrowError('If event conversion type is "WEB" then Pixel ID must be defined') - }) - it('should fail web event without snap_app_id', async () => { - nock(conversionEventUrl).post('').reply(400, {}) + expect(responses).not.toBeNull() + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + + const { data } = body + expect(data.length).toBe(1) + + const { + integration, + event_name, + event_source_url, + event_time, + user_data, + custom_data, + action_source, + app_data + } = data[0] + const { client_ip_address, client_user_agent, em, ph } = user_data + const { currency, value } = custom_data + + expect(integration).toBe('segment') + expect(event_name).toBe('SAVE') + expect(event_source_url).toBe('https://segment.com/academy/') + expect(event_time).toBe(1652368875449) + expect(client_ip_address).toBe('8.8.8.8') + expect(client_user_agent).toBe( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + ) + expect(em[0]).toBe('cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9') + expect(ph[0]).toBe('dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383') + expect(currency).toBe('USD') + expect(value).toBe(15) + expect(action_source).toBe('OFFLINE') + + // App data is only defined for app events + expect(app_data).toBeUndefined() + }) + + it('should handle a mobile app event conversion type', async () => { + nock(/.*/).post(/.*/).reply(200) - const event = createTestEvent(testEvent) + const event = createTestEvent(testEvent) - await expect( - testDestination.testAction('reportConversionEvent', { + const responses = await testDestination.testAction('reportConversionEvent', { event, settings: { - pixel_id: 'test123', - app_id: 'test123' + snap_app_id: '123', + app_id: '123' }, useDefaultMappings: true, auth: { accessToken, refreshToken }, + features, mapping: { - event_type: 'PURCHASE', + device_model: 'iPhone12,1', + os_version: '17.2', + event_type: 'SAVE', event_conversion_type: 'MOBILE_APP' } }) - ).rejects.toThrowError('If event conversion type is "MOBILE_APP" then Snap App ID and App ID must be defined') - }) - it('should handle an offline event conversion type', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - - const event = createTestEvent(testEvent) - - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'SAVE', - event_conversion_type: 'OFFLINE' - } + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + + const { data } = body + expect(data.length).toBe(1) + + const { + integration, + event_name, + event_source_url, + event_time, + user_data, + custom_data, + action_source, + app_data + } = data[0] + const { client_ip_address, client_user_agent, em, ph } = user_data + const { currency, value } = custom_data + const { extinfo, advertiser_tracking_enabled } = app_data + + expect(integration).toBe('segment') + expect(event_name).toBe('SAVE') + expect(event_source_url).toBe('https://segment.com/academy/') + expect(event_time).toBe(1652368875449) + expect(client_ip_address).toBe('8.8.8.8') + expect(client_user_agent).toBe( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + ) + expect(em[0]).toBe('cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9') + expect(ph[0]).toBe('dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383') + expect(currency).toBe('USD') + expect(value).toBe(15) + expect(action_source).toBe('app') + expect(extinfo).toEqual(['i2', '', '', '', '17.2', 'iPhone12,1', '', '', '', '', '', '', '', '', '', '']) + expect(advertiser_tracking_enabled).toBe(0) }) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + it('should fail invalid currency', async () => { + nock(/.*/).post(/.*/).reply(200) - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"SAVE\\",\\"event_conversion_type\\":\\"OFFLINE\\",\\"timestamp\\":1652368875449,\\"hashed_email\\":\\"cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9\\",\\"hashed_phone_number\\":\\"dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383\\",\\"user_agent\\":\\"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1\\",\\"hashed_ip_address\\":\\"838c4c2573848f58e74332341a7ca6bc5cd86a8aec7d644137d53b4d597f10f5\\",\\"price\\":15,\\"currency\\":\\"USD\\",\\"page_url\\":\\"https://segment.com/academy/\\",\\"pixel_id\\":\\"test123\\"}"` - ) - }) + const event = createTestEvent({ + ...testEvent, + properties: { + currency: 'Galleon' + } + }) - it('should handle a mobile app event conversion type', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - - const event = createTestEvent(testEvent) - - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings: { - snap_app_id: '123', - app_id: '123' - }, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'SAVE', - event_conversion_type: 'MOBILE_APP' - } + await expect( + testDestination.testAction('reportConversionEvent', { + event, + settings, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' + } + }) + ).rejects.toThrowError('Galleon is not a valid currency code.') }) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + it('should fail missing event conversion type', async () => { + nock(/.*/).post(/.*/).reply(200) + + const event = createTestEvent(testEvent) + + await expect( + testDestination.testAction('reportConversionEvent', { + event, + settings, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE' + } + }) + ).rejects.toThrowError("The root value is missing the required field 'event_conversion_type'.") + }) - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"SAVE\\",\\"event_conversion_type\\":\\"MOBILE_APP\\",\\"timestamp\\":1652368875449,\\"hashed_email\\":\\"cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9\\",\\"hashed_phone_number\\":\\"dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383\\",\\"user_agent\\":\\"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1\\",\\"hashed_ip_address\\":\\"838c4c2573848f58e74332341a7ca6bc5cd86a8aec7d644137d53b4d597f10f5\\",\\"price\\":15,\\"currency\\":\\"USD\\",\\"page_url\\":\\"https://segment.com/academy/\\",\\"snap_app_id\\":\\"123\\",\\"app_id\\":\\"123\\"}"` - ) - }) + it('should handle a custom event', async () => { + nock(/.*/).post(/.*/).reply(200) - it('should fail invalid currency', async () => { - nock(conversionEventUrl).post('').reply(400, {}) + const event = createTestEvent({ + ...testEvent, + event: 'CUSTOM_EVENT_5' + }) - const event = createTestEvent({ - ...testEvent, - properties: { - currency: 'Galleon' - } + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: { + snap_app_id: '123', + app_id: '123' + }, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: { '@path': '$.event' }, + event_conversion_type: 'MOBILE_APP' + } + }) + + expect(responses).not.toBeNull() + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + + const { data } = body + expect(data.length).toBe(1) + + const { + integration, + event_name, + event_source_url, + event_time, + user_data, + custom_data, + action_source, + app_data + } = data[0] + const { client_ip_address, client_user_agent, em, ph } = user_data + const { currency, value } = custom_data + const { app_id, advertiser_tracking_enabled } = app_data + + expect(integration).toBe('segment') + expect(event_name).toBe('CUSTOM_EVENT_5') + expect(event_source_url).toBe('https://segment.com/academy/') + expect(event_time).toBe(1652368875449) + expect(client_ip_address).toBe('8.8.8.8') + expect(client_user_agent).toBe( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + ) + expect(em[0]).toBe('cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9') + expect(ph[0]).toBe('dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383') + expect(currency).toBe('USD') + expect(value).toBe(15) + expect(action_source).toBe('app') + expect(app_id).toBe('123') + expect(advertiser_tracking_enabled).toBe(0) + }) + + it('should fail event missing all Snap identifiers', async () => { + const event = createTestEvent({ + ...testEvent, + properties: {}, + context: {} + }) + + await expect( + testDestination.testAction('reportConversionEvent', { + event, + settings, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' + } + }) + ).rejects.toThrowError( + 'Payload must contain values for Email or Phone Number or Mobile Ad Identifier or both IP Address and User Agent fields' + ) }) - await expect( - testDestination.testAction('reportConversionEvent', { + it('should handle event with email as only Snap identifier', async () => { + nock(/.*/).post(/.*/).reply(200) + const event = createTestEvent({ + ...testEvent, + properties: { + email: 'test123@gmail.com' + }, + context: {} + }) + + const responses = await testDestination.testAction('reportConversionEvent', { event, settings, useDefaultMappings: true, @@ -231,21 +495,43 @@ describe('Snap Conversions API ', () => { accessToken, refreshToken }, + features, mapping: { event_type: 'PURCHASE', event_conversion_type: 'WEB' } }) - ).rejects.toThrowError('Galleon is not a valid currency code.') - }) - it('should fail missing event conversion type', async () => { - nock(conversionEventUrl).post('').reply(400, {}) + expect(responses).not.toBeNull() + expect(responses[0].status).toBe(200) + + const body = JSON.parse(responses[0].options.body as string) + + const { data } = body + expect(data.length).toBe(1) + + const { integration, event_name, event_time, user_data, action_source } = data[0] + const { em, ph } = user_data - const event = createTestEvent(testEvent) + expect(integration).toBe('segment') + expect(event_name).toBe('PURCHASE') + expect(event_time).toBe(1652368875449) + expect(em[0]).toBe('cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9') + expect(ph).toBeUndefined() + expect(action_source).toBe('website') + }) + + it('should handle event with phone as only Snap identifier', async () => { + nock(/.*/).post(/.*/).reply(200) + const event = createTestEvent({ + ...testEvent, + properties: { + phone: '+44 844 412 4653' + }, + context: {} + }) - await expect( - testDestination.testAction('reportConversionEvent', { + const responses = await testDestination.testAction('reportConversionEvent', { event, settings, useDefaultMappings: true, @@ -253,55 +539,79 @@ describe('Snap Conversions API ', () => { accessToken, refreshToken }, + features, mapping: { - event_type: 'PURCHASE' + event_type: 'PURCHASE', + event_conversion_type: 'WEB' } }) - ).rejects.toThrowError("The root value is missing the required field 'event_conversion_type'.") - }) - it('should handle a custom event', async () => { - nock(conversionEventUrl).post('').reply(200, {}) + const body = JSON.parse(responses[0].options.body as string) - const event = createTestEvent({ - ...testEvent, - event: 'CUSTOM_EVENT_5' - }) + const { data } = body + expect(data.length).toBe(1) + + const { integration, event_name, event_time, user_data, action_source } = data[0] + const { ph } = user_data - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings: { - snap_app_id: '123', - app_id: '123' - }, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: { '@path': '$.event' }, - event_conversion_type: 'MOBILE_APP' - } + expect(integration).toBe('segment') + expect(event_name).toBe('PURCHASE') + expect(event_time).toBe(1652368875449) + expect(ph[0]).toBe('dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383') + expect(action_source).toBe('website') }) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + it('should handle event with advertising_id as only Snap identifier', async () => { + nock(/.*/).post(/.*/).reply(200) + const advertisingId = '87a7def4-b6e9-4bf7-91b6-66372842007a' + const event = createTestEvent({ + ...testEvent, + properties: {}, + context: { + device: { + advertisingId + } + } + }) - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"CUSTOM_EVENT_5\\",\\"event_conversion_type\\":\\"MOBILE_APP\\",\\"timestamp\\":1652368875449,\\"hashed_email\\":\\"cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9\\",\\"hashed_phone_number\\":\\"dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383\\",\\"user_agent\\":\\"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1\\",\\"hashed_ip_address\\":\\"838c4c2573848f58e74332341a7ca6bc5cd86a8aec7d644137d53b4d597f10f5\\",\\"price\\":15,\\"currency\\":\\"USD\\",\\"page_url\\":\\"https://segment.com/academy/\\",\\"snap_app_id\\":\\"123\\",\\"app_id\\":\\"123\\"}"` - ) - }) + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' + } + }) - it('should fail event missing all Snap identifiers', async () => { - const event = createTestEvent({ - ...testEvent, - properties: {}, - context: {} + const body = JSON.parse(responses[0].options.body as string) + + const { data } = body + expect(data.length).toBe(1) + + const { integration, event_name, event_time, user_data, action_source } = data[0] + const { madid } = user_data + + expect(integration).toBe('segment') + expect(event_name).toBe('PURCHASE') + expect(event_time).toBe(1652368875449) + expect(madid).toBe(advertisingId) + expect(action_source).toBe('website') }) - await expect( - testDestination.testAction('reportConversionEvent', { + it('should handle event with ip and user_agent as only Snap identifiers', async () => { + nock(/.*/).post(/.*/).reply(200) + const event = createTestEvent({ + ...testEvent, + properties: {} + }) + + const responses = await testDestination.testAction('reportConversionEvent', { event, settings, useDefaultMappings: true, @@ -309,141 +619,118 @@ describe('Snap Conversions API ', () => { accessToken, refreshToken }, + features, mapping: { event_type: 'PURCHASE', event_conversion_type: 'WEB' } }) - ).rejects.toThrowError( - 'Payload must contain values for Email or Phone Number or Mobile Ad Identifier or both IP Address and User Agent fields' - ) - }) - it('should handle event with email as only Snap identifier', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - const event = createTestEvent({ - ...testEvent, - properties: { - email: 'test123@gmail.com' - }, - context: {} - }) + const body = JSON.parse(responses[0].options.body as string) - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'PURCHASE', - event_conversion_type: 'WEB' - } - }) + const { data } = body + expect(data.length).toBe(1) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) - - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"PURCHASE\\",\\"event_conversion_type\\":\\"WEB\\",\\"timestamp\\":1652368875449,\\"hashed_email\\":\\"cc779c04191c2e736d89e45c11339c8382832bcaf70383f7df94e3d08ba7a6d9\\",\\"pixel_id\\":\\"test123\\"}"` - ) - }) + const { integration, event_name, event_time, user_data, action_source } = data[0] + const { client_ip_address, client_user_agent } = user_data - it('should handle event with phone as only Snap identifier', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - const event = createTestEvent({ - ...testEvent, - properties: { - phone: '+44 844 412 4653' - }, - context: {} + expect(integration).toBe('segment') + expect(event_name).toBe('PURCHASE') + expect(event_time).toBe(1652368875449) + expect(client_ip_address).toBe('8.8.8.8') + expect(client_user_agent).toBe( + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1' + ) + expect(action_source).toBe('website') }) - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'PURCHASE', - event_conversion_type: 'WEB' - } - }) + it('should always use the pixel id in settings for web events', async () => { + nock(/.*/).post(/.*/).reply(200) + const event = createTestEvent({ + ...testEvent, + properties: {} + }) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' + } + }) - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"PURCHASE\\",\\"event_conversion_type\\":\\"WEB\\",\\"timestamp\\":1652368875449,\\"hashed_phone_number\\":\\"dc008fda46e2e64002cf2f82a4906236282d431c4f75e5b60bfe79fc48546383\\",\\"pixel_id\\":\\"test123\\"}"` - ) - }) + expect(responses[0].url).toBe(buildRequestURL('pixel123', 'access123')) + }) - it('should handle event with advertising_id as only Snap identifier', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - const event = createTestEvent({ - ...testEvent, - properties: {}, - context: { - device: { - advertisingId: '87a7def4-b6e9-4bf7-91b6-66372842007a' + it('should trim a pixel id with leading or trailing whitespace', async () => { + nock(/.*/).post(/.*/).reply(200) + const event = createTestEvent({ + ...testEvent, + properties: {} + }) + + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: { + pixel_id: ' pixel123 ' + }, + useDefaultMappings: true, + auth: { + accessToken, + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB' } - } - }) + }) - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'PURCHASE', - event_conversion_type: 'WEB' - } + expect(responses[0].url).toBe(buildRequestURL('pixel123', 'access123')) }) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + it('should exclude number_items that is not a valid integer', async () => { + nock(/.*/).post(/.*/).reply(200) + const event = createTestEvent({ + ...testEvent, + properties: {} + }) - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"PURCHASE\\",\\"event_conversion_type\\":\\"WEB\\",\\"timestamp\\":1652368875449,\\"hashed_mobile_ad_id\\":\\"5af103f270fdc673b5e121ea929d1e47b2cee679e2059226a23c4cba37f8c9a9\\",\\"pixel_id\\":\\"test123\\"}"` - ) - }) + const responses = await testDestination.testAction('reportConversionEvent', { + event, + settings: { + pixel_id: ' pixel123 ' + }, + useDefaultMappings: true, + auth: { + accessToken: ' access123 ', + refreshToken + }, + features, + mapping: { + event_type: 'PURCHASE', + event_conversion_type: 'WEB', + number_items: 'six' + } + }) - it('should handle event with ip and user_agent as only Snap identifiers', async () => { - nock(conversionEventUrl).post('').reply(200, {}) - const event = createTestEvent({ - ...testEvent, - properties: {} - }) + expect(responses[0].url).toBe(buildRequestURL('pixel123', 'access123')) - const responses = await testDestination.testAction('reportConversionEvent', { - event, - settings, - useDefaultMappings: true, - auth: { - accessToken, - refreshToken - }, - mapping: { - event_type: 'PURCHASE', - event_conversion_type: 'WEB' - } - }) + const body = JSON.parse(responses[0].options.body as string) + const { data } = body + expect(data.length).toBe(1) - expect(responses).not.toBeNull() - expect(responses[0].status).toBe(200) + const { custom_data } = data[0] - expect(responses[0].options.body).toMatchInlineSnapshot( - `"{\\"integration\\":\\"segment\\",\\"event_type\\":\\"PURCHASE\\",\\"event_conversion_type\\":\\"WEB\\",\\"timestamp\\":1652368875449,\\"user_agent\\":\\"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1\\",\\"hashed_ip_address\\":\\"838c4c2573848f58e74332341a7ca6bc5cd86a8aec7d644137d53b4d597f10f5\\",\\"page_url\\":\\"https://segment.com/academy/\\",\\"pixel_id\\":\\"test123\\"}"` - ) + expect(custom_data).toBeUndefined() + }) }) }) }) diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/index.ts b/packages/destination-actions/src/destinations/snap-conversions-api/index.ts index 443e76d250..c19c82f087 100644 --- a/packages/destination-actions/src/destinations/snap-conversions-api/index.ts +++ b/packages/destination-actions/src/destinations/snap-conversions-api/index.ts @@ -14,6 +14,13 @@ interface RefreshTokenResponse { } const presets: DestinationDefinition['presets'] = [ + { + name: 'Snap Browser Plugin', + subscribe: 'type = "track" or type = "identify" or type = "group" or type = "page" or type = "alias"', + partnerAction: 'snapPlugin', + mapping: {}, + type: 'automatic' + }, { name: 'Add Billing', subscribe: 'event = "Payment Info Entered"', diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/index.ts b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/index.ts index e55a0dbdd4..043dc84e55 100644 --- a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/index.ts +++ b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/index.ts @@ -1,4 +1,4 @@ -import { ActionDefinition, IntegrationError } from '@segment/actions-core' +import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { @@ -27,15 +27,11 @@ import { search_string, page_url, sign_up_method, - formatPayload, - CURRENCY_ISO_4217_CODES, - conversionType, device_model, os_version, click_id } from '../snap-capi-properties' - -const CONVERSION_EVENT_URL = 'https://tr.snapchat.com/v2/conversion' +import { performSnapCAPIv3 as perform } from './snap-capi-v3' const action: ActionDefinition = { title: 'Report Conversion Event', @@ -71,41 +67,7 @@ const action: ActionDefinition = { device_model: device_model, click_id: click_id }, - perform: (request, data) => { - if (data.payload.currency && !CURRENCY_ISO_4217_CODES.has(data.payload.currency.toUpperCase())) { - throw new IntegrationError( - `${data.payload.currency} is not a valid currency code.`, - 'Misconfigured required field', - 400 - ) - } - - if ( - !data.payload.email && - !data.payload.phone_number && - !data.payload.mobile_ad_id && - (!data.payload.ip_address || !data.payload.user_agent) - ) { - throw new IntegrationError( - `Payload must contain values for Email or Phone Number or Mobile Ad Identifier or both IP Address and User Agent fields`, - 'Misconfigured required field', - 400 - ) - } - - const payload: Object = formatPayload(data.payload) - const settings: Settings = conversionType(data.settings, data.payload.event_conversion_type) - - //Create Conversion Event Request - return request(CONVERSION_EVENT_URL, { - method: 'post', - json: { - integration: 'segment', - ...payload, - ...settings - } - }) - } + perform } export default action diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/snap-capi-v3.ts b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/snap-capi-v3.ts new file mode 100644 index 0000000000..9afa6e5300 --- /dev/null +++ b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/snap-capi-v3.ts @@ -0,0 +1,225 @@ +import { ExecuteInput, ModifiedResponse, RequestClient } from '@segment/actions-core' +import { Payload } from './generated-types' +import { Settings } from '../generated-types' +import { + box, + emptyObjectToUndefined, + hash, + hashEmailSafe, + isNullOrUndefined, + splitListValueToArray, + raiseMisconfiguredRequiredFieldErrorIf, + raiseMisconfiguredRequiredFieldErrorIfNullOrUndefined, + emptyStringToUndefined, + parseNumberSafe +} from './utils' +import { CURRENCY_ISO_4217_CODES } from '../snap-capi-properties' + +export const validatePayload = (payload: Payload): Payload => { + raiseMisconfiguredRequiredFieldErrorIf( + !isNullOrUndefined(payload.currency) && !CURRENCY_ISO_4217_CODES.has(payload.currency.toUpperCase()), + `${payload.currency} is not a valid currency code.` + ) + + raiseMisconfiguredRequiredFieldErrorIf( + isNullOrUndefined(payload.email) && + isNullOrUndefined(payload.phone_number) && + isNullOrUndefined(payload.mobile_ad_id) && + (isNullOrUndefined(payload.ip_address) || isNullOrUndefined(payload.user_agent)), + `Payload must contain values for Email or Phone Number or Mobile Ad Identifier or both IP Address and User Agent fields` + ) + + return payload +} + +const eventConversionTypeToActionSource: { [k in string]?: string } = { + WEB: 'website', + MOBILE_APP: 'app', + + // Use the snap event_conversion_type for offline events + OFFLINE: 'OFFLINE' +} + +const iosAppIDRegex = new RegExp('^[0-9]+$') + +export const formatPayload = (payload: Payload, settings: Settings): object => { + const app_id = emptyStringToUndefined(settings.app_id) + + // event_conversion_type is a required parameter whose value is enforced as + // always OFFLINE, WEB, or MOBILE_APP, so in practice action_source will always have a value. + const action_source = eventConversionTypeToActionSource[payload.event_conversion_type] + + const event_id = emptyStringToUndefined(payload.client_dedup_id) + + // Removes all leading and trailing whitespace and converts all characters to lowercase. + const email = hashEmailSafe(payload.email?.replace(/\s/g, '').toLowerCase()) + + // Removes all non-numberic characters and leading zeros. + const phone_number = hash(payload.phone_number?.replace(/\D|^0+/g, '')) + + // Converts all characters to lowercase + const madid = payload.mobile_ad_id?.toLowerCase() + + // If customer populates products array, use it instead of the individual fields + const products = (payload.products ?? []).filter(({ item_id }) => item_id != null) + + const { content_ids, content_category, brands, num_items } = + products.length > 0 + ? { + content_ids: products.map(({ item_id }) => item_id), + content_category: products.map(({ item_category }) => item_category ?? ''), + brands: products.map((product) => product.brand ?? ''), + num_items: products.length + } + : (() => { + const content_ids = splitListValueToArray(payload.item_ids ?? '') + return { + content_ids, + content_category: splitListValueToArray(payload.item_category ?? ''), + brands: payload.brands, + num_items: parseNumberSafe(payload.number_items) ?? content_ids?.length + } + })() + + // FIXME: Ideally advertisers on iOS 14.5+ would pass the ATT_STATUS from the device. + // However the field is required for app events, so hardcode the value to false (0) + // for any events sent that include app_data. + const advertiser_tracking_enabled = !isNullOrUndefined(app_id) ? 0 : undefined + const extInfoVersion = iosAppIDRegex.test((app_id ?? '').trim()) ? 'i2' : 'a2' + + // extinfo needs to be defined whenever app_data is included in the data payload + const extinfo = !isNullOrUndefined(app_id) + ? [ + extInfoVersion, // required per spec version must be a2 for Android, must be i2 for iOS + '', // app package name + '', // short version + '', // long version + + // FIXME: extract from the user agent if available + payload.os_version ?? '', // os version + payload.device_model ?? '', // device model name + '', // local + '', // timezone abbr + '', // carrier + '', //screen width + '', // screen height + '', // screen density + '', // cpu core + '', // external storage size + '', // freespace in external storage size + '' // device time zone + ] + : undefined + + // Only set app data for app events + const app_data = + action_source === 'app' + ? emptyObjectToUndefined({ + app_id, + advertiser_tracking_enabled, + extinfo + }) + : undefined + + const result = { + data: [ + { + integration: 'segment', + event_id, + + // Snaps CAPI v3 supports the legacy v2 events so don't bother + // translating them + event_name: payload.event_type, + event_source_url: payload.page_url, + event_time: Date.parse(payload.timestamp), + user_data: emptyObjectToUndefined({ + client_ip_address: payload.ip_address, + client_user_agent: payload.user_agent, + em: box(email), + idfv: payload.idfv, + madid, + ph: box(phone_number), + sc_click_id: payload.click_id, + sc_cookie1: payload.uuid_c1 + }), + custom_data: emptyObjectToUndefined({ + brands, + content_category, + content_ids, + currency: payload.currency, + num_items, + order_id: emptyStringToUndefined(payload.transaction_id), + search_string: payload.search_string, + sign_up_method: payload.sign_up_method, + value: payload.price + }), + + action_source, + app_data + } + ] + } + + return result +} + +export const validateAppOrPixelID = (settings: Settings, event_conversion_type: string): string => { + const { snap_app_id, pixel_id } = settings + const snapAppID = emptyStringToUndefined(snap_app_id) + const snapPixelID = emptyStringToUndefined(pixel_id) + + // Some configurations specify both a snapPixelID and a snapAppID. In these cases + // check the conversion type to ensure that the right id is selected and used. + const appOrPixelID = (() => { + switch (event_conversion_type) { + case 'WEB': + case 'OFFLINE': + return snapPixelID + case 'MOBILE_APP': + return snapAppID + default: + return undefined + } + })() + + raiseMisconfiguredRequiredFieldErrorIf( + event_conversion_type === 'OFFLINE' && isNullOrUndefined(snapPixelID), + 'If event conversion type is "OFFLINE" then Pixel ID must be defined' + ) + + raiseMisconfiguredRequiredFieldErrorIf( + event_conversion_type === 'MOBILE_APP' && isNullOrUndefined(snapAppID), + 'If event conversion type is "MOBILE_APP" then Snap App ID must be defined' + ) + + raiseMisconfiguredRequiredFieldErrorIf( + event_conversion_type === 'WEB' && isNullOrUndefined(snapPixelID), + `If event conversion type is "${event_conversion_type}" then Pixel ID must be defined` + ) + + raiseMisconfiguredRequiredFieldErrorIfNullOrUndefined(appOrPixelID, 'Missing valid app or pixel ID') + + return appOrPixelID +} + +export const buildRequestURL = (appOrPixelID: string, authToken: string) => + `https://tr.snapchat.com/v3/${appOrPixelID}/events?access_token=${authToken}` + +export const performSnapCAPIv3 = async ( + request: RequestClient, + data: ExecuteInput +): Promise> => { + const { payload, settings } = data + const { event_conversion_type } = payload + const authToken = emptyStringToUndefined(data.auth?.accessToken) + + raiseMisconfiguredRequiredFieldErrorIfNullOrUndefined(authToken, 'Missing valid auth token') + + const url = buildRequestURL(validateAppOrPixelID(settings, event_conversion_type), authToken) + const json = formatPayload(validatePayload(payload), settings) + + return request(url, { + method: 'post', + json + }) +} diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/utils.ts b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/utils.ts new file mode 100644 index 0000000000..1dccb13154 --- /dev/null +++ b/packages/destination-actions/src/destinations/snap-conversions-api/reportConversionEvent/utils.ts @@ -0,0 +1,79 @@ +import { IntegrationError } from '@segment/actions-core' +import { createHash } from 'crypto' + +export const isNullOrUndefined = (v: T | null | undefined): v is null | undefined => v == null + +export const hash = (value: string | undefined): string | undefined => { + if (value === undefined) return + + const hash = createHash('sha256') + hash.update(value) + return hash.digest('hex') +} + +const isHashedEmail = (email: string): boolean => new RegExp(/[0-9abcdef]{64}/gi).test(email) + +export const hashEmailSafe = (email: string | undefined): string | undefined => + isHashedEmail(String(email)) ? email : hash(email) + +export const raiseMisconfiguredRequiredFieldErrorIf = (condition: boolean, message: string) => { + if (condition) { + throw new IntegrationError(message, 'Misconfigured required field', 400) + } +} + +// Use an interface to work around typescript limitation of using arrow functions for assertions +interface S { + raiseMisconfiguredRequiredFieldErrorIfNullOrUndefined(v: T | undefined, message: string): asserts v is T +} + +export const raiseMisconfiguredRequiredFieldErrorIfNullOrUndefined: S['raiseMisconfiguredRequiredFieldErrorIfNullOrUndefined'] = + (v: T | undefined, message: string): asserts v is T => + raiseMisconfiguredRequiredFieldErrorIf(isNullOrUndefined(v), message) + +export const box = (v: string | undefined): readonly string[] | undefined => + (v ?? '').length > 0 ? [v as string] : undefined + +export const emptyObjectToUndefined = (v: { [k in string]?: unknown }) => { + const properties = Object.getOwnPropertyNames(v) + + if (properties.length === 0) { + return undefined + } + + for (const prop of properties) { + if (v[prop] !== undefined) { + return v + } + } + + return undefined +} + +export const splitListValueToArray = (input: string): readonly string[] | undefined => { + // Default to comma seperated values unless semi-colons are present + const separator = input.includes(';') ? ';' : ',' + + // split on the separator, remove whitespace and remove any empty values. + const result = input + .split(separator) + .map((x) => x.trim()) + .filter((x) => x != '') + + return result.length > 0 ? result : undefined +} + +export const emptyStringToUndefined = (v: string | undefined): string | undefined => { + const trimmed = v?.trim() + return (trimmed ?? '').length > 0 ? trimmed : undefined +} + +export const parseNumberSafe = (v: string | number | undefined): number | undefined => { + if (Number.isSafeInteger(v)) { + return v as number + } else if (v != null) { + const parsed = Number.parseInt(String(v) ?? '') + return Number.isSafeInteger(parsed) ? parsed : undefined + } + return undefined +} diff --git a/packages/destination-actions/src/destinations/snap-conversions-api/snap-capi-properties.ts b/packages/destination-actions/src/destinations/snap-conversions-api/snap-capi-properties.ts index 9b4a25ce01..2018788a1e 100644 --- a/packages/destination-actions/src/destinations/snap-conversions-api/snap-capi-properties.ts +++ b/packages/destination-actions/src/destinations/snap-conversions-api/snap-capi-properties.ts @@ -1,8 +1,4 @@ -import { IntegrationError } from '@segment/actions-core' import { InputField } from '@segment/actions-core/destination-kit/types' -import { createHash } from 'crypto' -import { Settings } from '../snap-conversions-api/generated-types' -import { Payload } from './reportConversionEvent/generated-types' export const CURRENCY_ISO_4217_CODES = new Set([ 'USD', @@ -175,7 +171,10 @@ export const uuid_c1: InputField = { label: 'uuid_c1 Cookie', description: 'Unique user ID cookie. If you are using the Pixel SDK, you can access a cookie1 by looking at the _scid value.', - type: 'string' + type: 'string', + default: { + '@path': '$.integrations.Snap Conversions Api.uuid_c1' + } } export const idfv: InputField = { @@ -351,111 +350,8 @@ export const click_id: InputField = { label: 'Click ID', description: "The ID value stored in the landing page URL's `&ScCid=` query parameter. Using this ID improves ad measurement performance. We also encourage advertisers who are using `click_id` to pass the full url in the `page_url` field. For more details, please refer to [Sending a Click ID](#sending-a-click-id)", - type: 'string' -} - -//Check to see what ids need to be passed depending on the event_conversion_type -export const conversionType = (settings: Settings, event_conversion_type: String): Settings => { - if (event_conversion_type === 'MOBILE_APP') { - if (!settings?.snap_app_id || !settings?.app_id) { - throw new IntegrationError( - 'If event conversion type is "MOBILE_APP" then Snap App ID and App ID must be defined', - 'Misconfigured required field', - 400 - ) - } - delete settings?.pixel_id - } else { - if (!settings?.pixel_id) { - throw new IntegrationError( - `If event conversion type is "${event_conversion_type}" then Pixel ID must be defined`, - 'Misconfigured required field', - 400 - ) - } - delete settings?.snap_app_id - delete settings?.app_id - } - return settings -} - -export const hash = (value: string | undefined): string | undefined => { - if (value === undefined) return - - const hash = createHash('sha256') - hash.update(value) - return hash.digest('hex') -} - -const isHashedEmail = (email: string): boolean => new RegExp(/[0-9abcdef]{64}/gi).test(email) - -const transformProperty = (property: string, items: Array>): string => - items - .map((i) => - i[property] === undefined || i[property] === null - ? '' - : typeof i[property] === 'number' - ? (i[property] as number).toString() - : (i[property] as string).toString().replace(/;/g, '') - ) - .join(';') - -export const formatPayload = (payload: Payload): Object => { - //Normalize fields based on Snapchat Data Hygiene https://marketingapi.snapchat.com/docs/conversion.html#auth-requirements - if (payload.email) { - //Removes all leading and trailing whitespace and converts all characters to lowercase. - payload.email = payload.email.replace(/\s/g, '').toLowerCase() - } - - if (payload.phone_number) { - //Removes all non-numberic characters and leading zeros. - payload.phone_number = payload.phone_number.replace(/\D|^0+/g, '') - } - - if (payload.mobile_ad_id) { - //Converts all characters to lowercase - payload.mobile_ad_id = payload.mobile_ad_id.toLowerCase() - } - - let item_ids: string | undefined = undefined - let item_category: string | undefined = undefined - let brands: string[] | undefined = undefined - - // if customer populates products array, use it instead of individual fields - const p = payload?.products - if (p && Array.isArray(p) && p.length > 0) { - item_ids = transformProperty('item_id', p) - item_category = transformProperty('item_category', p) - brands = p.map((product) => product.brand ?? '') - } - - return { - event_type: payload?.event_type, - event_conversion_type: payload?.event_conversion_type, - event_tag: payload?.event_tag, - timestamp: Date.parse(payload?.timestamp), - hashed_email: isHashedEmail(String(payload?.email)) ? payload?.email : hash(payload?.email), - hashed_mobile_ad_id: hash(payload?.mobile_ad_id), - uuid_c1: payload?.uuid_c1, - hashed_idfv: hash(payload?.idfv), - hashed_phone_number: hash(payload?.phone_number), - user_agent: payload?.user_agent, - hashed_ip_address: hash(payload?.ip_address), - item_category: item_category ?? payload?.item_category, - brands: brands ?? payload?.brands, - item_ids: item_ids ?? payload?.item_ids, - description: payload?.description, - number_items: payload?.number_items, - price: payload?.price, - currency: payload?.currency, - transaction_id: payload?.transaction_id, - level: payload?.level, - client_dedup_id: payload?.client_dedup_id, - search_string: payload?.search_string, - page_url: payload?.page_url, - sign_up_method: payload?.sign_up_method, - device_model: payload?.device_model, - os_version: payload?.os_version, - click_id: payload?.click_id + type: 'string', + default: { + '@path': '$.integrations.Snap Conversions Api.click_id' } } diff --git a/packages/destination-actions/src/destinations/stackadapt/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/stackadapt/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..0d30afd313 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-stackadapt destination: forwardEvent action - all fields 1`] = `""`; + +exports[`Testing snapshot for actions-stackadapt destination: forwardEvent action - required fields 1`] = `""`; + +exports[`Testing snapshot for actions-stackadapt destination: forwardEvent action - required fields 2`] = ` +Headers { + Symbol(map): Object { + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "", + ], + "x-forwarded-for": Array [ + "", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/stackadapt/__tests__/index.test.ts b/packages/destination-actions/src/destinations/stackadapt/__tests__/index.test.ts new file mode 100644 index 0000000000..869e1956f5 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/__tests__/index.test.ts @@ -0,0 +1,219 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { SegmentEvent } from '@segment/actions-core/*' + +const testDestination = createTestIntegration(Definition) +const pixelHostUrl = 'https://tags.srv.stackadapt.com' +const pixelPath = '/saq_pxl' +const mockFirstName = 'John' +const mockLastName = 'Doe' +const mockEmail = 'admin@stackadapt.com' +const mockPhone = '1234567890' +const mockPageTitle = 'Test Page Title' +const mockPageUrl = 'https://www.example.com/example.html' +const mockReferrer = 'https://www.example.net/page.html' +const mockUserAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' +const mockIpAddress = '172.0.0.1' +const mockUtmSource = 'stackadapt' +const mockUserId = 'user-id' +const mockAnonymousId = 'anonymous-id' +const mockPixelId = 'sqHQa3Ob1hiF__2EcY3VZg1' +const mockProduct = { + price: 10.51, + quantity: 1, + category: 'Test Category', + product_id: 'Test Product Id', + name: 'Test Product Name' +} +const mockRevenue = 8.72 +const mockOrderId = 'Test Order Id' +const mockSingleProductAction = 'Product Added' +const mockMultiProductAction = 'Order Completed' + +const expectedProduct = { + product_price: mockProduct.price, + product_quantity: mockProduct.quantity, + product_id: mockProduct.product_id, + product_category: mockProduct.category, + product_name: mockProduct.name +} + +const defaultExpectedConversionArgs = { + action: 'Test Event', + utm_source: mockUtmSource, + user_id: mockUserId, + first_name: mockFirstName, + last_name: mockLastName, + email: mockEmail, + phone: mockPhone +} + +const defaultExpectedParams = { + segment_ss: '1', + event_type: 'identify', + title: mockPageTitle, + url: mockPageUrl, + ref: mockReferrer, + ip_fwd: mockIpAddress, + ua_fwd: mockUserAgent, + uid: mockPixelId, + args: JSON.stringify(defaultExpectedConversionArgs) +} + +const defaultEventPayload: Partial = { + anonymousId: mockAnonymousId, + userId: mockUserId, + type: 'identify', + traits: { + first_name: mockFirstName, + last_name: mockLastName, + email: mockEmail, + phone: mockPhone + }, + context: { + ip: mockIpAddress, + userAgent: mockUserAgent, + page: { + title: mockPageTitle, + url: mockPageUrl, + referrer: mockReferrer + }, + campaign: { + name: 'Campaign', + term: 'Term', + content: 'Content', + source: mockUtmSource, + medium: 'Medium' + } + } +} + +describe('StackAdapt', () => { + describe('forwardEvent', () => { + it('should validate action fields', async () => { + try { + await testDestination.testAction('createOrUpdateContact', { + settings: { pixelId: mockPixelId } + }) + } catch (err) { + expect(err.message).toContain("missing the required field 'userId'.") + } + }) + + it('Sends event data to pixel endpoint in expected format with expected headers', async () => { + nock(pixelHostUrl).get(pixelPath).query(true).reply(200, {}) + + const event = createTestEvent(defaultEventPayload) + const responses = await testDestination.testAction('forwardEvent', { + event, + useDefaultMappings: true, + settings: { + pixelId: mockPixelId + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const requestParams = Object.fromEntries(new URL(responses[0].request.url).searchParams) + expect(requestParams).toEqual(defaultExpectedParams) + expect(responses[0].request.headers).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", + ], + "x-forwarded-for": Array [ + "172.0.0.1", + ], + }, + } + `) + }) + + it('Serializes product data for single product event', async () => { + nock(pixelHostUrl).get(pixelPath).query(true).reply(200, {}) + + const eventPayload: Partial = { + ...defaultEventPayload, + type: 'track', + event: mockSingleProductAction, + properties: { + revenue: mockRevenue, + order_id: mockOrderId, + ...mockProduct + } + } + const event = createTestEvent(eventPayload) + const responses = await testDestination.testAction('forwardEvent', { + event, + useDefaultMappings: true, + settings: { + pixelId: mockPixelId + } + }) + + const expectedParams = { + ...defaultExpectedParams, + event_type: 'track', + args: expect.any(String) + } + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const requestParams = Object.fromEntries(new URL(responses[0].request.url).searchParams) + expect(requestParams).toEqual(expectedParams) + expect(JSON.parse(requestParams.args)).toEqual({ + ...defaultExpectedConversionArgs, + action: mockSingleProductAction, + revenue: mockRevenue, + order_id: mockOrderId, + ...expectedProduct + }) + }) + + it('Serializes product data for product array event', async () => { + nock(pixelHostUrl).get(pixelPath).query(true).reply(200, {}) + + const eventPayload: Partial = { + ...defaultEventPayload, + type: 'track', + event: mockMultiProductAction, + properties: { + revenue: mockRevenue, + order_id: mockOrderId, + products: [mockProduct] + } + } + const event = createTestEvent(eventPayload) + const responses = await testDestination.testAction('forwardEvent', { + event, + useDefaultMappings: true, + settings: { + pixelId: mockPixelId + } + }) + + const expectedParams = { + ...defaultExpectedParams, + event_type: 'track', + args: expect.any(String) + } + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + const requestParams = Object.fromEntries(new URL(responses[0].request.url).searchParams) + expect(requestParams).toEqual(expectedParams) + expect(JSON.parse(requestParams.args)).toEqual({ + ...defaultExpectedConversionArgs, + action: mockMultiProductAction, + revenue: mockRevenue, + order_id: mockOrderId, + products: [expectedProduct] + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/stackadapt/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/stackadapt/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..882cb78b36 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-stackadapt' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/stackadapt/forwardEvent/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..4a0515375d --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Stackadapt's forwardEvent destination action: all fields 1`] = `""`; + +exports[`Testing snapshot for Stackadapt's forwardEvent destination action: required fields 1`] = `""`; + +exports[`Testing snapshot for Stackadapt's forwardEvent destination action: required fields 2`] = ` +Headers { + Symbol(map): Object { + "content-type": Array [ + "application/json", + ], + "user-agent": Array [ + "", + ], + "x-forwarded-for": Array [ + "", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/stackadapt/forwardEvent/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..473e5c9c08 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'forwardEvent' +const destinationSlug = 'Stackadapt' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/stackadapt/forwardEvent/generated-types.ts b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/generated-types.ts new file mode 100644 index 0000000000..3c7f6e57b3 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/generated-types.ts @@ -0,0 +1,116 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The ID of the user in Segment + */ + user_id: string + /** + * The Segment event type (page, track, etc.) + */ + event_type?: string + /** + * IP address of the user + */ + ip_fwd?: string + /** + * The title of the page where the event occurred. + */ + title?: string + /** + * The URL of the page where the event occurred. + */ + url?: string + /** + * The referrer of the page where the event occurred. + */ + referrer?: string + /** + * UTM source parameter associated with event + */ + utm_source?: string + /** + * User-Agent of the user + */ + user_agent?: string + /** + * Email address of the individual who triggered the event. + */ + email?: string + /** + * Phone number of the individual who triggered the event + */ + phone?: string + /** + * First name of the individual who triggered the event. + */ + first_name?: string + /** + * Last name of the individual who triggered the event. + */ + last_name?: string + /** + * Additional ecommerce fields that are included in the pixel payload. + */ + ecommerce_data?: { + /** + * The event name (e.g. Order Completed) + */ + action?: string + /** + * The revenue generated from the event. + */ + revenue?: number + /** + * The ID of the order. + */ + order_id?: string + /** + * The price of the product. + */ + product_price?: number + /** + * The quantity of the product. + */ + product_quantity?: number + /** + * An identifier for the product. + */ + product_id?: string + /** + * A category for the product. + */ + product_category?: string + /** + * The name of the product. + */ + product_name?: string + [k: string]: unknown + } + /** + * The list of products associated with the event (for events with multiple products, such as Order Completed) + */ + ecommerce_products?: { + /** + * The price of the product. + */ + product_price?: number + /** + * The quantity of the product. + */ + product_quantity?: number + /** + * An identifier for the product. + */ + product_id?: string + /** + * A category for the product. + */ + product_category?: string + /** + * The name of the product. + */ + product_name?: string + [k: string]: unknown + }[] +} diff --git a/packages/destination-actions/src/destinations/stackadapt/forwardEvent/index.ts b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/index.ts new file mode 100644 index 0000000000..39b8afa32e --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/forwardEvent/index.ts @@ -0,0 +1,278 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import isEmpty from 'lodash/isEmpty' + +const action: ActionDefinition = { + title: 'Forward Event', + description: 'Forward Segment events to StackAdapt for conversion tracking', + defaultSubscription: 'type = "identify" or type = "page" or type = "screen" or type = "track"', + fields: { + user_id: { + label: 'Segment User ID', + description: 'The ID of the user in Segment', + type: 'string', + required: true, + default: { + // By default we want to use the permanent user id that's consistent across a customer's lifetime. + // But if we don't have that we can fall back to the anonymous id + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + event_type: { + label: 'Event Type', + description: 'The Segment event type (page, track, etc.)', + type: 'string', + default: { + '@path': '$.type' + } + }, + ip_fwd: { + description: 'IP address of the user', + label: 'IP Address', + type: 'string', + default: { + '@path': '$.context.ip' + } + }, + title: { + type: 'string', + description: 'The title of the page where the event occurred.', + label: 'Page Title', + default: { '@path': '$.context.page.title' } + }, + url: { + type: 'string', + description: 'The URL of the page where the event occurred.', + label: 'URL', + default: { '@path': '$.context.page.url' } + }, + referrer: { + type: 'string', + description: 'The referrer of the page where the event occurred.', + label: 'Referrer', + default: { '@path': '$.context.page.referrer' } + }, + utm_source: { + type: 'string', + format: 'text', + label: 'UTM Source', + description: 'UTM source parameter associated with event', + default: { '@path': '$.context.campaign.source' } + }, + user_agent: { + description: 'User-Agent of the user', + label: 'User Agent', + type: 'string', + default: { + '@path': '$.context.userAgent' + } + }, + email: { + label: 'Email', + description: 'Email address of the individual who triggered the event.', + type: 'string', + format: 'email', + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + phone: { + label: 'Phone Number', + description: 'Phone number of the individual who triggered the event', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + } + }, + first_name: { + label: 'First Name', + description: 'First name of the individual who triggered the event.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.first_name' }, + then: { '@path': '$.traits.first_name' }, + else: { '@path': '$.properties.first_name' } + } + } + }, + last_name: { + label: 'Last Name', + description: 'Last name of the individual who triggered the event.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.traits.last_name' }, + then: { '@path': '$.traits.last_name' }, + else: { '@path': '$.properties.last_name' } + } + } + }, + ecommerce_data: { + label: 'Ecommerce Data', + description: 'Additional ecommerce fields that are included in the pixel payload.', + type: 'object', + additionalProperties: true, + properties: { + action: { + label: 'Event Name', + description: 'The event name (e.g. Order Completed)', + type: 'string' + }, + revenue: { + label: 'Revenue', + type: 'number', + description: 'The revenue generated from the event.' + }, + order_id: { + label: 'Order ID', + type: 'string', + description: 'The ID of the order.' + }, + product_price: { + label: 'Price', + type: 'number', + description: 'The price of the product.' + }, + product_quantity: { + label: 'Quantity', + type: 'integer', + description: 'The quantity of the product.' + }, + product_id: { + label: 'Product ID', + type: 'string', + description: 'An identifier for the product.' + }, + product_category: { + label: 'Product Category', + type: 'string', + description: 'A category for the product.' + }, + product_name: { + label: 'Product Name', + type: 'string', + description: 'The name of the product.' + } + }, + default: { + action: { '@path': '$.event' }, + revenue: { '@path': '$.properties.revenue' }, + order_id: { '@path': '$.properties.order_id' }, + product_price: { '@path': '$.properties.price' }, + product_quantity: { '@path': '$.properties.quantity' }, + product_id: { '@path': '$.properties.product_id' }, + product_category: { '@path': '$.properties.category' }, + product_name: { '@path': '$.properties.name' } + } + }, + ecommerce_products: { + label: 'Products', + description: + 'The list of products associated with the event (for events with multiple products, such as Order Completed)', + type: 'object', + multiple: true, + additionalProperties: true, + properties: { + product_price: { + label: 'Price', + type: 'number', + description: 'The price of the product.' + }, + product_quantity: { + label: 'Quantity', + type: 'integer', + description: 'The quantity of the product.' + }, + product_id: { + label: 'Product ID', + type: 'string', + description: 'An identifier for the product.' + }, + product_category: { + label: 'Product Category', + type: 'string', + description: 'A category for the product.' + }, + product_name: { + label: 'Product Name', + type: 'string', + description: 'The name of the product.' + } + }, + default: { + '@arrayPath': [ + '$.properties.products', + { + product_price: { + '@path': 'price' + }, + product_quantity: { + '@path': 'quantity' + }, + product_id: { + '@path': 'product_id' + }, + product_category: { + '@path': 'category' + }, + product_name: { + '@path': 'name' + } + } + ] + } + } + }, + perform: async (request, { payload, settings }) => { + // Don't include ecommerce data if it's empty or if it only contains the action field + return request(`https://tags.srv.stackadapt.com/saq_pxl`, { + method: 'GET', + searchParams: getAvailableData(payload, settings), + headers: { + 'Content-Type': 'application/json', + 'X-Forwarded-For': payload.ip_fwd ?? '', + 'User-Agent': payload.user_agent ?? '' + } + }) + } +} + +function getAvailableData(payload: Payload, settings: Settings) { + const conversionArgs = { + ...payload.ecommerce_data, + ...(!isEmpty(payload.ecommerce_products) && { products: payload.ecommerce_products }), + utm_source: payload.utm_source ?? '', + user_id: payload.user_id, + first_name: payload.first_name, + last_name: payload.last_name, + email: payload.email, + phone: payload.phone + } + return { + segment_ss: '1', + event_type: payload.event_type ?? '', + title: payload.title ?? '', + url: payload.url ?? '', + ref: payload.referrer ?? '', + ip_fwd: payload.ip_fwd ?? '', + ua_fwd: payload.user_agent ?? '', + uid: settings.pixelId, + ...(!isEmpty(conversionArgs) && { args: JSON.stringify(conversionArgs) }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/stackadapt/generated-types.ts b/packages/destination-actions/src/destinations/stackadapt/generated-types.ts new file mode 100644 index 0000000000..46b38db5f2 --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your StackAdapt Universal Pixel ID + */ + pixelId: string +} diff --git a/packages/destination-actions/src/destinations/stackadapt/index.ts b/packages/destination-actions/src/destinations/stackadapt/index.ts new file mode 100644 index 0000000000..49811689ff --- /dev/null +++ b/packages/destination-actions/src/destinations/stackadapt/index.ts @@ -0,0 +1,38 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import { defaultValues } from '@segment/actions-core' +import forwardEvent from './forwardEvent' + +const destination: DestinationDefinition = { + name: 'StackAdapt Cloud (Actions)', + slug: 'actions-stackadapt-cloud', + mode: 'cloud', + description: + 'Forward Segment events to StackAdapt for tracking ad conversions, and generating lookalike and retargeting Audiences', + authentication: { + scheme: 'custom', + fields: { + pixelId: { + label: 'Universal Pixel ID', + description: 'Your StackAdapt Universal Pixel ID', + type: 'string', + required: true + } + } + }, + presets: [ + { + name: 'Forward Event', + subscribe: 'type = "identify" or type = "page" or type = "screen" or type = "track"', + partnerAction: 'forwardEvent', + mapping: defaultValues(forwardEvent.fields), + type: 'automatic' + } + ], + actions: { + forwardEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/surveysparrow/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/surveysparrow/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..089eaa32f5 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-surveysparrow destination: createContact action - all fields 1`] = ` +Object { + "email": "puhdal@lanuh.eg", + "full_name": ")vXolamUv*aA^%I)KHo", + "job_title": ")vXolamUv*aA^%I)KHo", + "mobile": ")vXolamUv*aA^%I)KHo", + "phone": ")vXolamUv*aA^%I)KHo", + "testType": ")vXolamUv*aA^%I)KHo", +} +`; + +exports[`Testing snapshot for actions-surveysparrow destination: triggerSurvey action - all fields 1`] = ` +Object { + "contacts": Array [ + Object { + "email": "fonhom@aswoj.mg", + "mobile": "Kx&gS1q$N(lVM", + }, + ], + "survey_id": 10571614257152, + "variables": Object { + "testType": "Kx&gS1q$N(lVM", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/surveysparrow/__tests__/index.test.ts b/packages/destination-actions/src/destinations/surveysparrow/__tests__/index.test.ts new file mode 100644 index 0000000000..e0b2f345b9 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/__tests__/index.test.ts @@ -0,0 +1,25 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Surveysparrow', () => { + describe('testAuthentication', () => { + const authData = { + apiToken: 'CUSTOM_AUTH_TOKEN' + } + + it('should validate authentication inputs', async () => { + nock('https://api.surveysparrow.com').get('/v3/users').reply(200, {}) + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + + it('should fail on authentication failure', async () => { + nock('https://api.surveysparrow.com').get('/v3/users').reply(401, {}) + + await expect(testDestination.testAuthentication(authData)).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/surveysparrow/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/surveysparrow/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..9b1fdfd85f --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/__tests__/snapshot.test.ts @@ -0,0 +1,66 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-surveysparrow' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + await testDestination + .testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + .catch(() => {}) + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..85d526ecbe --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Surveysparrow's createContact destination action: all fields 1`] = ` +Object { + "email": "note@bame.uk", + "full_name": "30LJ^FhJnm0", + "job_title": "30LJ^FhJnm0", + "mobile": "30LJ^FhJnm0", + "phone": "30LJ^FhJnm0", + "testType": "30LJ^FhJnm0", +} +`; diff --git a/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/index.test.ts b/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/index.test.ts new file mode 100644 index 0000000000..5788f17f53 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/index.test.ts @@ -0,0 +1,69 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +beforeEach(() => nock.cleanAll()) + +const defaultMapping = { + full_name: { + '@path': '$.traits.name' + }, + email: { + '@path': '$.traits.email' + }, + mobile: { + '@path': '$.traits.mobile' + } +} +const endpoint = 'https://api.surveysparrow.com' + +describe('Surveysparrow.createContact', () => { + it('should create contacts with valid payload', async () => { + nock(endpoint).post('/v3/contacts').reply(200, { success: true }) + + const event = createTestEvent({ + traits: { + name: 'contact_1', + email: 'contact_45@email.com' + } + }) + + const responses = await testDestination.testAction('createContact', { + event, + mapping: defaultMapping, + settings: { + apiToken: 'test-source-write-key' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toEqual(200) + }) + + it('should throw errors when creating a contact', async () => { + nock(endpoint).post('/v3/contacts').reply(400, { success: false }) + + const event = createTestEvent({ + traits: { + name: 'contact_1', + email: { + email: 'ndejfk@jisf.com' + } + } + }) + + await testDestination + .testAction('createContact', { + event, + mapping: defaultMapping, + settings: { + apiToken: 'test-source-write-key' + } + }) + .catch((error) => { + expect(error.message).toEqual('Email must be a string but it was an object.') + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..5bc770f861 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/createContact/__tests__/snapshot.test.ts @@ -0,0 +1,64 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'createContact' +const destinationSlug = 'Surveysparrow' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + await testDestination + .testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + .catch(() => {}) + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/surveysparrow/createContact/generated-types.ts b/packages/destination-actions/src/destinations/surveysparrow/createContact/generated-types.ts new file mode 100644 index 0000000000..16bed60321 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/createContact/generated-types.ts @@ -0,0 +1,30 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Full name of the Contact + */ + full_name?: string + /** + * Non Mobile Phone number for the Contact. This should include + followed by Country Code. For Example, +18004810410 + */ + phone?: string + /** + * Mobile number for the Contact. This should include + followed by Country Code. For Example, +18004810410 + */ + mobile?: string + /** + * Email Address for the Contact + */ + email?: string + /** + * Job Title for the Contact + */ + job_title?: string + /** + * Key:Value Custom Properties to be added to the Contact in SurveySparrow. [Contact Property](https://support.surveysparrow.com/hc/en-us/articles/7078996288925-How-to-add-custom-properties-to-your-contact) should be created in SurveySparrow in advance. + */ + custom_fields?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/surveysparrow/createContact/index.ts b/packages/destination-actions/src/destinations/surveysparrow/createContact/index.ts new file mode 100644 index 0000000000..a00ca60fc9 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/createContact/index.ts @@ -0,0 +1,109 @@ +import { PayloadValidationError, ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Create or Update Contact in SurveySparrow', + defaultSubscription: 'type=identify', + description: + 'This Action will create a new Contact or update an existing Contact in SurveySparrow. One of Email or Mobile are mandatory when creating a Contact.', + fields: { + full_name: { + label: 'Name', + type: 'string', + description: 'Full name of the Contact', + default: { + '@if': { + exists: { '@path': '$.properties.name' }, + then: { '@path': '$.properties.name' }, + else: { '@path': '$.context.traits.name' } + } + } + }, + phone: { + label: 'Phone', + type: 'string', + description: + 'Non Mobile Phone number for the Contact. This should include + followed by Country Code. For Example, +18004810410', + default: { + '@if': { + exists: { '@path': '$.properties.phone' }, + then: { '@path': '$.properties.phone' }, + else: { '@path': '$.context.traits.phone' } + } + } + }, + mobile: { + label: 'Mobile', + type: 'string', + description: + 'Mobile number for the Contact. This should include + followed by Country Code. For Example, +18004810410', + default: { + '@if': { + exists: { '@path': '$.properties.mobile' }, + then: { '@path': '$.properties.mobile' }, + else: { '@path': '$.context.traits.mobile' } + } + } + }, + email: { + label: 'Email', + type: 'string', + format: 'email', + description: 'Email Address for the Contact', + default: { + '@if': { + exists: { '@path': '$.properties.email' }, + then: { '@path': '$.properties.email' }, + else: { '@path': '$.context.traits.email' } + } + } + }, + job_title: { + label: 'Job Title', + type: 'string', + description: 'Job Title for the Contact', + default: { + '@if': { + exists: { '@path': '$.properties.job_title' }, + then: { '@path': '$.properties.job_title' }, + else: { '@path': '$.context.traits.job_title' } + } + } + }, + custom_fields: { + label: 'Custom Contact Properties', + type: 'object', + defaultObjectUI: 'keyvalue', + description: + 'Key:Value Custom Properties to be added to the Contact in SurveySparrow. [Contact Property](https://support.surveysparrow.com/hc/en-us/articles/7078996288925-How-to-add-custom-properties-to-your-contact) should be created in SurveySparrow in advance.', + default: { + '@if': { + exists: { '@path': '$.properties.custom_fields' }, + then: { '@path': '$.properties.custom_fields' }, + else: { '@path': '$.context.traits.custom_fields' } + } + } + } + }, + perform: (request, { payload }) => { + if (payload.email || payload.mobile) { + const transformedPayload = { + full_name: payload.full_name, + phone: payload.phone, + mobile: payload.mobile, + email: payload.email, + job_title: payload.job_title, + ...payload.custom_fields + } + return request('https://api.surveysparrow.com/v3/contacts', { + method: 'post', + json: transformedPayload + }) + } else { + throw new PayloadValidationError('Either Email or Mobile are required') + } + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/surveysparrow/generated-types.ts b/packages/destination-actions/src/destinations/surveysparrow/generated-types.ts new file mode 100644 index 0000000000..26b23521e8 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * SurveySparrow Access Token can be found in Settings > Apps and Integrations > Create a Custom app + */ + apiToken: string +} diff --git a/packages/destination-actions/src/destinations/surveysparrow/index.ts b/packages/destination-actions/src/destinations/surveysparrow/index.ts new file mode 100644 index 0000000000..09b446c549 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/index.ts @@ -0,0 +1,43 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import createContact from './createContact' + +import triggerSurvey from './triggerSurvey' + +const destination: DestinationDefinition = { + name: 'Surveysparrow', + slug: 'actions-surveysparrow', + mode: 'cloud', + description: 'Trigger Surveys and Create Contacts in SurveySparrow', + + authentication: { + scheme: 'custom', + fields: { + apiToken: { + label: 'Access Token', + description: + 'SurveySparrow Access Token can be found in Settings > Apps and Integrations > Create a Custom app', + type: 'password', + required: true + } + }, + testAuthentication: (request) => { + return request(`https://api.surveysparrow.com/v3/users`, { + method: 'get' + }) + } + }, + extendRequest({ settings }) { + return { + headers: { Authorization: `Bearer ${settings.apiToken}` } + } + }, + + actions: { + createContact, + triggerSurvey + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..3dc995e7ad --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Surveysparrow's triggerSurvey destination action: all fields 1`] = ` +Object { + "contacts": Array [ + Object { + "email": "lotocmo@je.sn", + "mobile": "m@7$j@#YSxk", + }, + ], + "survey_id": -12350633919119.36, + "variables": Object { + "testType": "m@7$j@#YSxk", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/index.test.ts b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/index.test.ts new file mode 100644 index 0000000000..94f1708ab2 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/index.test.ts @@ -0,0 +1,127 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +beforeEach(() => nock.cleanAll()) + +const defaultMapping = { + id: { + '@path': '$.properties.channel_id' + }, + survey_id: { + '@path': '$.properties.survey_id' + }, + email: { + '@path': '$.properties.email' + }, + mobile: { + '@path': '$.properties.mobile' + }, + share_type: { + '@path': '$.properties.share_type' + } +} + +const endpoint = 'https://api.surveysparrow.com' +const channelId = 1 + +describe('Surveysparrow.triggerSurvey', () => { + it('should trigger a email survey with valid payload', async () => { + nock(endpoint).put(`/v3/channels/${channelId}`).reply(200, { success: true }) + + const event = createTestEvent({ + properties: { + channel_id: channelId, + survey_id: 1, + email: 'jhdfgjewh@hgjsd.com', + share_type: 'Email' + } + }) + + const responses = await testDestination.testAction('triggerSurvey', { + event, + mapping: defaultMapping, + settings: { + apiToken: 'test-source-write-key' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toEqual(200) + }) + + it('should trigger a sms survey with valid payload', async () => { + nock(endpoint).put(`/v3/channels/${channelId}`).reply(200, { success: true }) + + const event = createTestEvent({ + properties: { + channel_id: channelId, + survey_id: 1, + mobile: '+919876543210', + share_type: 'SMS' + } + }) + + const responses = await testDestination.testAction('triggerSurvey', { + event, + mapping: defaultMapping, + settings: { + apiToken: 'test-source-write-key' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toEqual(200) + }) + + it('should throw errors when triggering a survey', async () => { + nock(endpoint).put(`/v3/channels/${channelId}`).reply(400, { success: false }) + + const event = createTestEvent({ + properties: { + channel_id: channelId, + survey_id: 1, + email: 'hi-up@mail.com' + } + }) + + await testDestination + .testAction('triggerSurvey', { + event, + mapping: defaultMapping, + settings: { + apiToken: 'test-source-write-key' + } + }) + .catch((error) => { + expect(error.message).toEqual("The root value is missing the required field 'share_type'.") + }) + }) + + it('should throw errors when triggering a SMS survey', async () => { + nock(endpoint).put(`/v3/channels/${channelId}`).reply(400, { success: false }) + + const event = createTestEvent({ + properties: { + channel_id: channelId, + survey_id: 1, + email: 'hi-up@mail.com', + share_type: 'SMS' + } + }) + + await testDestination + .testAction('triggerSurvey', { + event, + mapping: defaultMapping, + settings: { + apiToken: 'test-source-write-key' + } + }) + .catch((error) => { + expect(error.message).toEqual('Mobile is a Required Field for SMS Shares') + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..f5418254e6 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/__tests__/snapshot.test.ts @@ -0,0 +1,64 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'triggerSurvey' +const destinationSlug = 'Surveysparrow' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + await testDestination + .testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + .catch(() => {}) + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/generated-types.ts b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/generated-types.ts new file mode 100644 index 0000000000..2e7de4d233 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/generated-types.ts @@ -0,0 +1,30 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Channel ID is the unique identifier for the Share Channel in SurveySparrow. This can be copied from the URL. + */ + id: number + /** + * Type of Survey Share to be triggered + */ + share_type: string + /** + * Select the SurveySparrow Survey you want to trigger + */ + survey_id: number + /** + * Mobile number to send Survey to for either SMS or WhatsApp. This should include + followed by Country Code. For Example, +18004810410. Mobile is required for SMS or WhatsApp Shares + */ + mobile?: string + /** + * Email address to send Survey to. This is required for an Email Share. + */ + email?: string + /** + * Variables you want to pass to SurveySparrow. + */ + variables?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/index.ts b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/index.ts new file mode 100644 index 0000000000..1315e48769 --- /dev/null +++ b/packages/destination-actions/src/destinations/surveysparrow/triggerSurvey/index.ts @@ -0,0 +1,169 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { DynamicFieldItem, DynamicFieldResponse } from '@segment/actions-core' +import { RequestClient, PayloadValidationError } from '@segment/actions-core' +import { HTTPError } from '@segment/actions-core' + +export async function getSurveys(request: RequestClient): Promise { + const choices: DynamicFieldItem[] = [] + try { + let has_next_page = false + let page = 1 + do { + const response = await request(`https://api.surveysparrow.com/v3/surveys?page=${page}`, { + method: 'get' + }) + const data = JSON.parse(response.content) + const surveys = data.data + for (const survey of surveys) { + choices.push({ + label: `${survey.name}`, + value: survey.id + }) + } + page++ + has_next_page = data.has_next_page + } while (has_next_page) + } catch (err) { + return getError(err) + } + return { + choices + } +} + +async function getError(err: unknown) { + const errResponse = (err as HTTPError)?.response + const errorBody = await errResponse.json() + return { + choices: [], + error: { + message: errorBody?.meta?.error?.errorMessage ?? 'Unknown Error', + code: errResponse?.status.toString() ?? '500' + } + } +} + +const action: ActionDefinition = { + title: 'Trigger Survey in SurveySparrow', + defaultSubscription: 'type=track and event="Trigger Survey"', + description: + 'This Action will trigger a SurveySparrow survey to a user via Email, SMS or WhatsApp. The [Survey](https://support.surveysparrow.com/hc/en-us/articles/7079412445213-How-to-create-surveys-using-SurveySparrow) and required Share [Channel](https://support.surveysparrow.com/hc/en-us/articles/7078359450269-How-to-share-surveys-across-different-channels) should be created in SurveySparrow.', + fields: { + id: { + label: 'Channel ID', + type: 'number', + required: true, + description: + 'Channel ID is the unique identifier for the Share Channel in SurveySparrow. This can be copied from the URL.', + default: { + '@path': '$.properties.channel_id' + } + }, + share_type: { + label: 'Share Type', + type: 'string', + required: true, + description: 'Type of Survey Share to be triggered', + choices: [ + { + label: 'Email', + value: 'Email' + }, + { + label: 'SMS', + value: 'SMS' + }, + { + label: 'WhatsApp', + value: 'WhatsApp' + } + ], + default: { + '@path': '$.properties.share_type' + } + }, + survey_id: { + label: 'Survey', + type: 'number', + required: true, + description: 'Select the SurveySparrow Survey you want to trigger', + dynamic: true, + default: { + '@path': '$.properties.survey_id' + } + }, + mobile: { + label: 'Mobile', + type: 'string', + description: + 'Mobile number to send Survey to for either SMS or WhatsApp. This should include + followed by Country Code. For Example, +18004810410. Mobile is required for SMS or WhatsApp Shares', + default: { + '@if': { + exists: { '@path': '$.properties.mobile' }, + then: { '@path': '$.properties.mobile' }, + else: { '@path': '$.context.traits.mobile' } + } + } + }, + email: { + label: 'Email', + type: 'string', + format: 'email', + description: 'Email address to send Survey to. This is required for an Email Share.', + default: { + '@if': { + exists: { '@path': '$.properties.email' }, + then: { '@path': '$.properties.email' }, + else: { '@path': '$.context.traits.email' } + } + } + }, + variables: { + label: 'Variables', + type: 'object', + defaultObjectUI: 'keyvalue', + description: 'Variables you want to pass to SurveySparrow.', + default: { + '@path': '$.properties.variables' + } + } + }, + dynamicFields: { + survey_id: getSurveys + }, + perform: (request, data) => { + switch (data.payload.share_type) { + case 'Email': { + if (!data.payload.email) { + throw new PayloadValidationError('Email is a Required Field Email Shares') + } else break + } + case 'SMS': + case 'WhatsApp': { + if (!data.payload.mobile) { + throw new PayloadValidationError(`Mobile is a Required Field for ${data.payload.share_type} Shares`) + } else break + } + } + + const payload = { + survey_id: data.payload.survey_id, + contacts: [ + { + email: data.payload.email, + mobile: data.payload.mobile + } + ], + variables: data.payload.variables + } + + return request(`https://api.surveysparrow.com/v3/channels/${data.payload.id}`, { + method: 'put', + json: payload + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/__tests__/index.test.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/__tests__/index.test.ts new file mode 100644 index 0000000000..4231014bce --- /dev/null +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/__tests__/index.test.ts @@ -0,0 +1,76 @@ +import nock from 'nock' +import { createTestIntegration, IntegrationError } from '@segment/actions-core' +import Destination from '../index' +import { API_VERSION } from '../index' + +const testDestination = createTestIntegration(Destination) + +const createAudienceInput = { + settings: { + auth_token: 'AUTH_TOKEN', + advertiser_id: 'ADVERTISER_ID', + __segment_internal_engage_force_full_sync: true, + __segment_internal_engage_batch_sync: true + }, + audienceName: '', + audienceSettings: { + region: 'US' + } +} + +const getAudienceInput = { + settings: { + auth_token: 'AUTH_TOKEN', + advertiser_id: 'ADVERTISER_ID', + __segment_internal_engage_force_full_sync: true, + __segment_internal_engage_batch_sync: true + }, + externalId: 'crm_data_id', + audienceSettings: { + region: 'US' + } +} + +describe('The Trade Desk CRM', () => { + describe('createAudience', () => { + it('should fail if no audience name is set', async () => { + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + + it('should create a new Trade Desk CRM Data Segment', async () => { + nock(`https://api.thetradedesk.com/${API_VERSION}/crmdata/segment`).post(/.*/).reply(200, { + CrmDataId: 'test_audience' + }) + + createAudienceInput.audienceName = 'The Super Mario Brothers Fans' + createAudienceInput.audienceSettings.region = 'US' + + const r = await testDestination.createAudience(createAudienceInput) + expect(r).toEqual({ externalId: 'test_audience' }) + }) + }) + + describe('getAudience', () => { + it('should fail if Trade Desk replies with an error', async () => { + nock(`https://api.thetradedesk.com/${API_VERSION}/crmdata/segment/advertiser_id?pagingToken=paging_token`) + .get(/.*/) + .reply(400, { Segments: [], PagingToken: null }) + await expect(testDestination.getAudience(getAudienceInput)).rejects.toThrowError() + }) + + it('should succeed when Segment External ID matches Data Segment in TikTok', async () => { + nock(`https://api.thetradedesk.com/${API_VERSION}/crmdata/segment/advertiser_id`) + .get(/.*/) + .reply(200, { + Segments: [{ SegmentName: 'not_test_audience', CrmDataId: 'crm_data_id' }], + PagingToken: 'paging_token' + }) + nock(`https://api.thetradedesk.com/${API_VERSION}/crmdata/segment/advertiser_id?pagingToken=paging_token`) + .get(/.*/) + .reply(200, { Segments: [], PagingToken: null }) + + const r = await testDestination.getAudience(getAudienceInput) + expect(r).toEqual({ externalId: 'crm_data_id' }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/awsClient.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/awsClient.ts index d384b633ae..327ab21065 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/awsClient.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/awsClient.ts @@ -8,7 +8,6 @@ interface SendToAWSRequest { TDDAuthToken: string AdvertiserId: string CrmDataId: string - SegmentName: string UsersFormatted: string DropOptions: { PiiType: string @@ -21,7 +20,6 @@ interface SendToAWSRequest { interface TTDEventPayload { TDDAuthToken: string AdvertiserId: string - SegmentName: string CrmDataId: string RequeueCount: number DropReferenceId?: string @@ -59,7 +57,6 @@ export const sendEventToAWS = async (request: RequestClient, input: SendToAWSReq const metadata = JSON.stringify({ TDDAuthToken: input.TDDAuthToken, AdvertiserId: input.AdvertiserId, - SegmentName: input.SegmentName, CrmDataId: input.CrmDataId, DropOptions: input.DropOptions, RequeueCount: 0 diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/functions.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/functions.ts index 9920882637..a9cfb35f1b 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/functions.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/functions.ts @@ -1,7 +1,8 @@ -import { IntegrationError, RequestClient, ModifiedResponse, PayloadValidationError } from '@segment/actions-core' +import { RequestClient, ModifiedResponse, PayloadValidationError } from '@segment/actions-core' import { Settings } from './generated-types' import { Payload } from './syncAudience/generated-types' import { createHash } from 'crypto' +import { IntegrationError } from '@segment/actions-core' import { sendEventToAWS } from './awsClient' @@ -48,12 +49,16 @@ const TTD_MIN_RECORD_COUNT = 1500 export const TTD_LEGACY_FLOW_FLAG_NAME = 'actions-the-trade-desk-crm-legacy-flow' export const TTD_LIST_ACTION_FLOW_FLAG_NAME = 'ttd-list-action-destination' +const sha256HashedRegex = /^[a-f0-9]{64}$/i +const base64HashedRegex = /^[A-Za-z0-9+/]*={1,2}$/i +const validEmailRegex = /^\S+@\S+\.\S+$/i + export async function processPayload(input: ProcessPayloadInput) { let crmID - if (input?.features?.[TTD_LIST_ACTION_FLOW_FLAG_NAME]) { - crmID = input?.payloads?.[0]?.external_id || '' + if (!input.payloads[0].external_id) { + throw new PayloadValidationError(`No external_id found in payload.`) } else { - crmID = await getCRMInfo(input.request, input.settings, input.payloads[0]) + crmID = input.payloads[0].external_id } // Get user emails from the payloads @@ -86,76 +91,20 @@ export async function processPayload(input: ProcessPayloadInput) { TDDAuthToken: input.settings.auth_token, AdvertiserId: input.settings.advertiser_id, CrmDataId: crmID, - SegmentName: input.payloads[0].name, UsersFormatted: usersFormatted, DropOptions: { PiiType: input.payloads[0].pii_type, - MergeMode: 'Replace' - } - }) - } -} - -async function getAllDataSegments(request: RequestClient, settings: Settings) { - const allDataSegments: Segments[] = [] - // initial call to get first page - let response: ModifiedResponse = await request( - `${BASE_URL}/crmdata/segment/${settings.advertiser_id}`, - { - method: 'GET' - } - ) - let segments = response.data.Segments - // pagingToken leads you to the next page - let pagingToken = response.data.PagingToken - // keep iterating through pages until the last empty page - while (segments.length > 0) { - allDataSegments.push(...segments) - response = await request(`${BASE_URL}/crmdata/segment/${settings.advertiser_id}?pagingToken=${pagingToken}`, { - method: 'GET' - }) - - segments = response.data.Segments - pagingToken = response.data.PagingToken - } - return allDataSegments -} - -async function getCRMInfo(request: RequestClient, settings: Settings, payload: Payload): Promise { - let segmentId: string - const segments = await getAllDataSegments(request, settings) - const segmentExists = segments.filter(function (segment) { - if (segment.SegmentName == payload.name) { - return segment - } - }) - - // More than 1 audience returned matches name - if (segmentExists.length > 1) { - throw new IntegrationError('Multiple audiences found with the same name', 'INVALID_SETTINGS', 400) - } else if (segmentExists.length == 1) { - segmentId = segmentExists[0].CrmDataId - } else { - // If an audience does not exist, we will create it. In V1, we will send a single batch - // of full audience syncs every 24 hours to eliminate the risk of a race condition. - const response: ModifiedResponse = await request(`${BASE_URL}/crmdata/segment`, { - method: 'POST', - json: { - AdvertiserId: settings.advertiser_id, - SegmentName: payload.name, - Region: payload.region + MergeMode: 'Replace', + RetentionEnabled: true } }) - segmentId = response.data.CrmDataId } - - return segmentId } function extractUsers(payloads: Payload[]): string { let users = '' payloads.forEach((payload: Payload) => { - if (!payload.email) { + if (!payload.email || !validateEmail(payload.email, payload.pii_type)) { return } @@ -164,14 +113,24 @@ function extractUsers(payloads: Payload[]): string { } if (payload.pii_type == 'EmailHashedUnifiedId2') { - const normalizedEmail = normalizeEmail(payload.email) - const hashedEmail = hash(normalizedEmail) + const hashedEmail = hash(payload.email) users += `${hashedEmail}\n` } }) return users } +function validateEmail(email: string, pii_type: string): boolean { + const isSha256HashedEmail = sha256HashedRegex.test(email) + const isBase64Hashed = base64HashedRegex.test(email) + const isValidEmail = validEmailRegex.test(email) + + if (pii_type == 'Email') { + return isValidEmail + } + return isSha256HashedEmail || isBase64Hashed || isValidEmail +} + // More info about email normalization: https://api.thetradedesk.com/v3/portal/data/doc/DataPiiNormalization#email-normalize function normalizeEmail(email: string) { // Remove all of the leading and trailing whitespace and convert to lowercase @@ -194,8 +153,20 @@ function normalizeEmail(email: string) { } export const hash = (value: string): string => { + const isSha256HashedEmail = sha256HashedRegex.test(value) + const isBase64Hashed = base64HashedRegex.test(value) + + if (isSha256HashedEmail) { + return Buffer.from(value, 'hex').toString('base64') + } + + if (isBase64Hashed) { + return value + } + + const normalizedEmail = normalizeEmail(value) const hash = createHash('sha256') - hash.update(value) + hash.update(normalizedEmail) return hash.digest('base64') } @@ -207,7 +178,8 @@ async function getCRMDataDropEndpoint(request: RequestClient, settings: Settings method: 'POST', json: { PiiType: payload.pii_type, - MergeMode: 'Replace' + MergeMode: 'Replace', + RetentionEnabled: true } } ) @@ -225,3 +197,37 @@ async function uploadCRMDataToDropEndpoint(request: RequestClient, endpoint: str body: users }) } + +export async function getAllDataSegments(request: RequestClient, advertiserId: string, authToken: string) { + const allDataSegments: Segments[] = [] + // initial call to get first page + let response: ModifiedResponse = await request(`${BASE_URL}/crmdata/segment/${advertiserId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'TTD-Auth': authToken + } + }) + + if (response.status != 200 || !response.data.Segments) { + throw new IntegrationError('Invalid response from get audience request', 'INVALID_RESPONSE', 400) + } + let segments = response.data.Segments + // pagingToken leads you to the next page + let pagingToken = response.data.PagingToken + // keep iterating through pages until the last empty page + while (segments.length > 0) { + allDataSegments.push(...segments) + response = await request(`${BASE_URL}/crmdata/segment/${advertiserId}?pagingToken=${pagingToken}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'TTD-Auth': authToken + } + }) + + segments = response.data.Segments + pagingToken = response.data.PagingToken + } + return allDataSegments +} diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/generated-types.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/generated-types.ts index 1154986b2d..f54093708f 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/generated-types.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/generated-types.ts @@ -22,7 +22,7 @@ export interface Settings { export interface AudienceSettings { /** - * Region of your audience. + * The geographical region of the CRM data segment based on the origin of PII. Can be US (United States and Canada), EU (European Union and the UK), or APAC (Asia-Pacific) */ - region?: string + region: string } diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/index.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/index.ts index de6fa665c0..241afccfde 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/index.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/index.ts @@ -1,9 +1,10 @@ import type { AudienceDestinationDefinition, ModifiedResponse } from '@segment/actions-core' import type { Settings, AudienceSettings } from './generated-types' import { IntegrationError } from '@segment/actions-core' +import { getAllDataSegments } from './functions' import syncAudience from './syncAudience' -const API_VERSION = 'v3' +export const API_VERSION = 'v3' const BASE_URL = `https://api.thetradedesk.com/${API_VERSION}` export interface CreateApiResponse { @@ -74,7 +75,9 @@ const destination: AudienceDestinationDefinition = { region: { type: 'string', label: 'Region', - description: 'Region of your audience.' + description: + 'The geographical region of the CRM data segment based on the origin of PII. Can be US (United States and Canada), EU (European Union and the UK), or APAC (Asia-Pacific)', + required: true } }, audienceConfig: { @@ -118,33 +121,24 @@ const destination: AudienceDestinationDefinition = { const advertiserId = getAudienceInput.settings.advertiser_id const authToken = getAudienceInput.settings.auth_token - const response: ModifiedResponse = await request( - `${BASE_URL}/crmdata/segment/${advertiserId}/${crmDataId}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'TTD-Auth': authToken - } - } - ) - - if (response.status !== 200) { - throw new IntegrationError('Invalid response from get audience request', 'INVALID_RESPONSE', 400) - } + const allDataSegments = await getAllDataSegments(request, advertiserId, authToken) - const externalId = response.data.CrmDataId + const segmentExists = allDataSegments.filter(function (segment: Segments) { + if (segment.CrmDataId == crmDataId) { + return segment + } + }) - if (externalId !== getAudienceInput.externalId) { + if (segmentExists.length != 1) { throw new IntegrationError( - "Unable to verify ownership over audience. Segment Audience ID doesn't match The Trade Desk's Audience ID.", + `No CRM Data Segment with Id ${crmDataId} found for advertiser ${advertiserId}`, 'INVALID_REQUEST_DATA', 400 ) } return { - externalId: externalId + externalId: crmDataId } } }, diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/properties.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/properties.ts index e802953e5b..0c95332f15 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/properties.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/properties.ts @@ -10,26 +10,6 @@ export const external_id: InputField = { unsafe_hidden: true } -export const name: InputField = { - label: 'Segment Name', - description: - 'The name of The Trade Desk CRM Data Segment you want to sync. If the audience name does not exist Segment will create one.', - type: 'string', - required: true -} - -export const region: InputField = { - label: 'Region', - description: 'The geographical region of the CRM data segment based on the origin of PII.', - type: 'string', - default: 'US', - choices: [ - { label: 'US', value: 'US' }, - { label: 'EU', value: 'EU' }, - { label: 'APAC', value: 'APAC' } - ] -} - export const pii_type: InputField = { label: 'PII Type', description: 'The type of personally identifiable data (PII) sent by the advertiser.', diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/__tests__/index.test.ts index 4095f6fdc0..9dc4bf7e8d 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/__tests__/index.test.ts @@ -41,6 +41,9 @@ for (let index = 1; index <= 1500; index++) { }, traits: { email: `testing${index}@testing.com` + }, + personas: { + external_audience_id: 'external_audience_id' } } }) @@ -81,38 +84,54 @@ const event = createTestEvent({ }, traits: { email: 'testing@testing.com' + }, + personas: { + external_audience_id: 'external_audience_id' } } }) describe('TheTradeDeskCrm.syncAudience', () => { - it('should succeed and create a Segment if an existing CRM Segment is not found', async () => { + it('should fail if batch has less than 1500 and using legacy flow', async () => { nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id`) .get(/.*/) .reply(200, { - Segments: [{ SegmentName: 'not_test_audience', CrmDataId: 'crm_data_id' }], + Segments: [{ SegmentName: 'test_audience', CrmDataId: 'crm_data_id' }], PagingToken: 'paging_token' }) + nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) + .get(/.*/) + .reply(200, { Segments: [], PagingToken: null }) - nock(`https://api.thetradedesk.com/v3/crmdata/segment`) - .post(/.*/) - .reply(200, { - data: { - CrmDataId: 'test_audience' + await expect( + testDestination.testAction('syncAudience', { + event, + settings: { + advertiser_id: 'advertiser_id', + auth_token: 'test_token', + __segment_internal_engage_force_full_sync: true, + __segment_internal_engage_batch_sync: true + }, + features: { 'actions-the-trade-desk-crm-legacy-flow': true }, + useDefaultMappings: true, + mapping: { + name: 'test_audience', + region: 'US', + pii_type: 'Email' } }) + ).rejects.toThrow(`received payload count below The Trade Desk's ingestion minimum. Expected: >=1500 actual: 1`) + }) - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) - .get(/.*/) - .reply(200, { Segments: [], PagingToken: null }) + it('should execute legacy flow if flagon override is defined', async () => { + const dropReferenceId = 'aabbcc5b01-c9c7-4000-9191-000000000000' + const dropEndpoint = `https://thetradedesk-crm-data.s3.us-east-1.amazonaws.com/data/advertiser/advertiser-id/drop/${dropReferenceId}/pii?X-Amz-Security-Token=token&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=date&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Credential=credentials&X-Amz-Signature=signature&` - nock(/https?:\/\/([a-z0-9-]+)\.s3\.([a-z0-9-]+)\.amazonaws\.com:.*/) - .put(/.*/) - .reply(200) + nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id/crm_data_id`) + .post(/.*/, { PiiType: 'Email', MergeMode: 'Replace', RetentionEnabled: true }) + .reply(200, { ReferenceId: dropReferenceId, Url: dropEndpoint }) - nock(/https?:\/\/([a-z0-9-]+)\.s3\.([a-z0-9-]+)\.amazonaws\.com:.*/) - .put(/.*/) - .reply(200) + nock(dropEndpoint).put(/.*/).reply(200) const responses = await testDestination.testBatchAction('syncAudience', { events, @@ -122,6 +141,9 @@ describe('TheTradeDeskCrm.syncAudience', () => { __segment_internal_engage_force_full_sync: true, __segment_internal_engage_batch_sync: true }, + features: { + [TTD_LEGACY_FLOW_FLAG_NAME]: true + }, useDefaultMappings: true, mapping: { name: 'test_audience', @@ -130,28 +152,18 @@ describe('TheTradeDeskCrm.syncAudience', () => { } }) - expect(responses.length).toBe(5) + expect(responses.length).toBe(2) }) - it('should succeed and update a Segment with Email if an existing CRM Segment is not found', async () => { - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id`) - .get(/.*/) - .reply(200, { - Segments: [{ SegmentName: 'test_audience', CrmDataId: 'crm_data_id' }], - PagingToken: 'paging_token' - }) - - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) - .get(/.*/) - .reply(200, { Segments: [], PagingToken: null }) + it('should use external_id from payload', async () => { + const dropReferenceId = 'aabbcc5b01-c9c7-4000-9191-000000000000' + const dropEndpoint = `https://thetradedesk-crm-data.s3.us-east-1.amazonaws.com/data/advertiser/advertiser-id/drop/${dropReferenceId}/pii?X-Amz-Security-Token=token&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=date&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Credential=credentials&X-Amz-Signature=signature&` - nock(/https?:\/\/([a-z0-9-]+)\.s3\.([a-z0-9-]+)\.amazonaws\.com:.*/) - .put(/.*/) - .reply(200) + nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id/personas_test_audience`) + .post(/.*/, { PiiType: 'Email', MergeMode: 'Replace', RetentionEnabled: true }) + .reply(200, { ReferenceId: dropReferenceId, Url: dropEndpoint }) - nock(/https?:\/\/([a-z0-9-]+)\.s3\.([a-z0-9-]+)\.amazonaws\.com:.*/) - .put(/.*/) - .reply(200) + nock(dropEndpoint).put(/.*/).reply(200) const responses = await testDestination.testBatchAction('syncAudience', { events, @@ -161,6 +173,7 @@ describe('TheTradeDeskCrm.syncAudience', () => { __segment_internal_engage_force_full_sync: true, __segment_internal_engage_batch_sync: true }, + features: { 'actions-the-trade-desk-crm-legacy-flow': true, 'ttd-list-action-destination': true }, useDefaultMappings: true, mapping: { name: 'test_audience', @@ -169,102 +182,45 @@ describe('TheTradeDeskCrm.syncAudience', () => { } }) - expect(responses.length).toBe(4) + expect(responses.length).toBe(2) }) - it('should succeed and update a Segment with EmailHashedUnifiedId2 if an existing CRM Segment is not found', async () => { - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id`) - .get(/.*/) - .reply(200, { - Segments: [{ SegmentName: 'test_audience', CrmDataId: 'crm_data_id' }], - PagingToken: 'paging_token' - }) - - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) - .get(/.*/) - .reply(200, { Segments: [], PagingToken: null }) + it('should fail if no external_id in payload', async () => { + const dropReferenceId = 'aabbcc5b01-c9c7-4000-9191-000000000000' + const dropEndpoint = `https://thetradedesk-crm-data.s3.us-east-1.amazonaws.com/data/advertiser/advertiser-id/drop/${dropReferenceId}/pii?X-Amz-Security-Token=token&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=date&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Credential=credentials&X-Amz-Signature=signature&` - nock(/https?:\/\/([a-z0-9-]+)\.s3\.([a-z0-9-]+)\.amazonaws\.com:.*/) - .put(/.*/) - .reply(200) + nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id/personas_test_audience`) + .post(/.*/, { PiiType: 'Email', MergeMode: 'Replace', RetentionEnabled: true }) + .reply(200, { ReferenceId: dropReferenceId, Url: dropEndpoint }) - nock(/https?:\/\/([a-z0-9-]+)\.s3\.([a-z0-9-]+)\.amazonaws\.com:.*/) - .put(/.*/) - .reply(200) + nock(dropEndpoint).put(/.*/).reply(200) - const responses = await testDestination.testBatchAction('syncAudience', { - events, - settings: { - advertiser_id: 'advertiser_id', - auth_token: 'test_token', - __segment_internal_engage_force_full_sync: true, - __segment_internal_engage_batch_sync: true + const newEvent = createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: { + audience_key: 'personas_test_audience' }, - useDefaultMappings: true, - mapping: { - name: 'test_audience', - region: 'US', - pii_type: 'EmailHashedUnifiedId2' - } - }) - - expect(responses.length).toBe(4) - }) - - it('should fail if multiple CRM Segments found with same name', async () => { - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id`) - .get(/.*/) - .reply(200, { - Segments: [ - { SegmentName: 'test_audience', CrmDataId: 'crm_data_id' }, - { SegmentName: 'test_audience', CrmDataId: 'crm_data_id' } - ], - PagingToken: 'paging_token' - }) - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) - .get(/.*/) - .reply(200, { Segments: [], PagingToken: null }) - - await expect( - testDestination.testBatchAction('syncAudience', { - events, - settings: { - advertiser_id: 'advertiser_id', - auth_token: 'test_token', - __segment_internal_engage_force_full_sync: true, - __segment_internal_engage_batch_sync: true + context: { + device: { + advertisingId: '123' }, - useDefaultMappings: true, - mapping: { - name: 'test_audience', - region: 'US', - pii_type: 'Email' + traits: { + email: 'testing@testing.com' } - }) - ).rejects.toThrow('Multiple audiences found with the same name') - }) - - it('should fail if batch has less than 1500 and using legacy flow', async () => { - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id`) - .get(/.*/) - .reply(200, { - Segments: [{ SegmentName: 'test_audience', CrmDataId: 'crm_data_id' }], - PagingToken: 'paging_token' - }) - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) - .get(/.*/) - .reply(200, { Segments: [], PagingToken: null }) + } + }) await expect( testDestination.testAction('syncAudience', { - event, + event: newEvent, settings: { advertiser_id: 'advertiser_id', auth_token: 'test_token', __segment_internal_engage_force_full_sync: true, __segment_internal_engage_batch_sync: true }, - features: { 'actions-the-trade-desk-crm-legacy-flow': true }, + features: { 'actions-the-trade-desk-crm-legacy-flow': true, 'ttd-list-action-destination': true }, useDefaultMappings: true, mapping: { name: 'test_audience', @@ -272,30 +228,61 @@ describe('TheTradeDeskCrm.syncAudience', () => { pii_type: 'Email' } }) - ).rejects.toThrow(`received payload count below The Trade Desk's ingestion minimum. Expected: >=1500 actual: 1`) + ).rejects.toThrow(`No external_id found in payload.`) }) - it('should execute legacy flow if flagon override is defined', async () => { + it('should not double hash an email that is already base64 encoded', async () => { const dropReferenceId = 'aabbcc5b01-c9c7-4000-9191-000000000000' const dropEndpoint = `https://thetradedesk-crm-data.s3.us-east-1.amazonaws.com/data/advertiser/advertiser-id/drop/${dropReferenceId}/pii?X-Amz-Security-Token=token&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=date&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Credential=credentials&X-Amz-Signature=signature&` - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id`) - .get(/.*/) - .reply(200, { - Segments: [{ SegmentName: 'test_audience', CrmDataId: 'crm_data_id' }], - PagingToken: 'paging_token' - }) - - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) - .get(/.*/) - .reply(200, { Segments: [], PagingToken: null }) - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id/crm_data_id`) - .post(/.*/, { PiiType: 'Email', MergeMode: 'Replace' }) + .post(/.*/, { PiiType: 'EmailHashedUnifiedId2', MergeMode: 'Replace', RetentionEnabled: true }) .reply(200, { ReferenceId: dropReferenceId, Url: dropEndpoint }) nock(dropEndpoint).put(/.*/).reply(200) + const events: SegmentEvent[] = [] + for (let index = 1; index <= 1500; index++) { + events.push( + createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: { + audience_key: 'personas_test_audience' + }, + context: { + device: { + advertisingId: '123' + }, + personas: { + external_audience_id: 'external_audience_id' + } + } + }) + ) + } + + events.push( + createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: { + audience_key: 'personas_test_audience' + }, + context: { + device: { + advertisingId: '123' + }, + traits: { + email: `yhI0QL7dpdaHFq6DEyKlqKPn2vj7KX91BQeqhniYRvI=` + }, + personas: { + external_audience_id: 'external_audience_id' + } + } + }) + ) + const responses = await testDestination.testBatchAction('syncAudience', { events, settings: { @@ -311,34 +298,69 @@ describe('TheTradeDeskCrm.syncAudience', () => { mapping: { name: 'test_audience', region: 'US', - pii_type: 'Email' + pii_type: 'EmailHashedUnifiedId2' } }) - expect(responses.length).toBe(4) + expect(responses.length).toBe(2) + expect(responses[1].options.body).toMatchInlineSnapshot(` + "yhI0QL7dpdaHFq6DEyKlqKPn2vj7KX91BQeqhniYRvI= + " + `) }) - it('should use external_id from payload', async () => { + it('should base64 encode a sha256 hashed email', async () => { const dropReferenceId = 'aabbcc5b01-c9c7-4000-9191-000000000000' const dropEndpoint = `https://thetradedesk-crm-data.s3.us-east-1.amazonaws.com/data/advertiser/advertiser-id/drop/${dropReferenceId}/pii?X-Amz-Security-Token=token&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=date&X-Amz-SignedHeaders=host&X-Amz-Expires=3600&X-Amz-Credential=credentials&X-Amz-Signature=signature&` - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id`) - .get(/.*/) - .reply(200, { - Segments: [{ SegmentName: 'test_audience', CrmDataId: 'crm_data_id' }], - PagingToken: 'paging_token' - }) - - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id?pagingToken=paging_token`) - .get(/.*/) - .reply(200, { Segments: [], PagingToken: null }) - - nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id/personas_test_audience`) - .post(/.*/, { PiiType: 'Email', MergeMode: 'Replace' }) + nock(`https://api.thetradedesk.com/v3/crmdata/segment/advertiser_id/crm_data_id`) + .post(/.*/, { PiiType: 'EmailHashedUnifiedId2', MergeMode: 'Replace', RetentionEnabled: true }) .reply(200, { ReferenceId: dropReferenceId, Url: dropEndpoint }) nock(dropEndpoint).put(/.*/).reply(200) + const events: SegmentEvent[] = [] + for (let index = 1; index <= 1500; index++) { + events.push( + createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: { + audience_key: 'personas_test_audience' + }, + context: { + device: { + advertisingId: '123' + }, + personas: { + external_audience_id: 'external_audience_id' + } + } + }) + ) + } + + events.push( + createTestEvent({ + event: 'Audience Entered', + type: 'track', + properties: { + audience_key: 'personas_test_audience' + }, + context: { + device: { + advertisingId: '123' + }, + traits: { + email: `ca123440bedda5d68716ae831322a5a8a3e7daf8fb297f750507aa86789846f2` + }, + personas: { + external_audience_id: 'external_audience_id' + } + } + }) + ) + const responses = await testDestination.testBatchAction('syncAudience', { events, settings: { @@ -347,15 +369,21 @@ describe('TheTradeDeskCrm.syncAudience', () => { __segment_internal_engage_force_full_sync: true, __segment_internal_engage_batch_sync: true }, - features: { 'actions-the-trade-desk-crm-legacy-flow': true, 'ttd-list-action-destination': true }, + features: { + [TTD_LEGACY_FLOW_FLAG_NAME]: true + }, useDefaultMappings: true, mapping: { name: 'test_audience', region: 'US', - pii_type: 'Email' + pii_type: 'EmailHashedUnifiedId2' } }) expect(responses.length).toBe(2) + expect(responses[1].options.body).toMatchInlineSnapshot(` + "yhI0QL7dpdaHFq6DEyKlqKPn2vj7KX91BQeqhniYRvI= + " + `) }) }) diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/generated-types.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/generated-types.ts index 616a3b8307..aeaabca519 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/generated-types.ts @@ -1,14 +1,6 @@ // Generated file. DO NOT MODIFY IT BY HAND. export interface Payload { - /** - * The name of The Trade Desk CRM Data Segment you want to sync. If the audience name does not exist Segment will create one. - */ - name: string - /** - * The geographical region of the CRM data segment based on the origin of PII. - */ - region?: string /** * The CRM Data ID for The Trade Desk Segment. */ diff --git a/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/index.ts b/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/index.ts index 043cb79743..8a76a19a02 100644 --- a/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/index.ts +++ b/packages/destination-actions/src/destinations/the-trade-desk-crm/syncAudience/index.ts @@ -1,7 +1,7 @@ import type { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' -import { name, region, external_id, pii_type, email, enable_batching, event_name, batch_size } from '../properties' +import { external_id, pii_type, email, enable_batching, event_name, batch_size } from '../properties' import { processPayload } from '../functions' const action: ActionDefinition = { @@ -9,8 +9,6 @@ const action: ActionDefinition = { description: 'Drop users into the given CRM Data Segment', defaultSubscription: 'event = "Audience Entered"', fields: { - name: { ...name }, - region: { ...region }, external_id: { ...external_id }, pii_type: { ...pii_type }, email: { ...email }, diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/__tests__/functions.test.ts b/packages/destination-actions/src/destinations/tiktok-audiences/__tests__/functions.test.ts new file mode 100644 index 0000000000..61bb33a328 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-audiences/__tests__/functions.test.ts @@ -0,0 +1,27 @@ +import { extractUsers } from '../functions' + +describe('TikTok Audiences Functions', () => { + describe('extractUsers', () => { + it('Should hash email address when email is in a plain format', () => { + const payload = { + email: 'scroogemcduck@disney.com', + send_email: true, + audience_id: '1234567890' + } + + const result: any[][] = extractUsers([payload]) + expect(result[0][0].id).toEqual('77bc071241f37b4736df28c0c1cb0a99163d1050696134325b99246b2183d408') + }) + + it('Should NOT hash email address when email is already hashed', () => { + const payload = { + email: '77bc071241f37b4736df28c0c1cb0a99163d1050696134325b99246b2183d408', + send_email: true, + audience_id: '1234567890' + } + + const result: any[][] = extractUsers([payload]) + expect(result[0][0].id).toEqual('77bc071241f37b4736df28c0c1cb0a99163d1050696134325b99246b2183d408') + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-audiences/__tests__/index.test.ts index 4408d60991..7afdf1425f 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/__tests__/index.test.ts @@ -80,7 +80,7 @@ const getAudienceResponse = { } } -describe('Tik Tok Audiences', () => { +describe('TikTok Audiences', () => { describe('createAudience', () => { it('should fail if no audience name is set', async () => { await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/__tests__/index.test.ts index faa956c429..a7c7c205b5 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/__tests__/index.test.ts @@ -29,7 +29,8 @@ const event = createTestEvent({ advertisingId: ADVERTISING_ID }, traits: { - email: 'testing@testing.com' + email: 'testing@testing.com', + phone: '1234567890' }, personas: { audience_settings: { @@ -42,7 +43,7 @@ const event = createTestEvent({ }) const updateUsersRequestBody = { - id_schema: ['EMAIL_SHA256', 'IDFA_SHA256'], + id_schema: ['EMAIL_SHA256', 'PHONE_SHA256', 'IDFA_SHA256'], advertiser_ids: [ADVERTISER_ID], action: 'add', batch_data: [ @@ -51,6 +52,10 @@ const updateUsersRequestBody = { id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', audience_ids: [EXTERNAL_AUDIENCE_ID] }, + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: [EXTERNAL_AUDIENCE_ID] + }, { id: '0315b4020af3eccab7706679580ac87a710d82970733b8719e70af9b57e7b9e6', audience_ids: [EXTERNAL_AUDIENCE_ID] @@ -66,7 +71,6 @@ describe('TiktokAudiences.addToAudience', () => { const r = await testDestination.testAction('addToAudience', { auth, event, - features: { 'tiktok-hide-create-audience-action': true }, settings: {}, useDefaultMappings: true, mapping: { @@ -75,6 +79,9 @@ describe('TiktokAudiences.addToAudience', () => { }) expect(r[0].status).toEqual(200) + expect(r[0].options.body).toMatchInlineSnapshot( + `"{\\"advertiser_ids\\":[\\"123\\"],\\"action\\":\\"add\\",\\"id_schema\\":[\\"EMAIL_SHA256\\",\\"PHONE_SHA256\\",\\"IDFA_SHA256\\"],\\"batch_data\\":[[{\\"id\\":\\"584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777\\",\\"audience_ids\\":[\\"12345\\"]},{\\"id\\":\\"c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646\\",\\"audience_ids\\":[\\"12345\\"]},{\\"id\\":\\"0315b4020af3eccab7706679580ac87a710d82970733b8719e70af9b57e7b9e6\\",\\"audience_ids\\":[\\"12345\\"]}]]}"` + ) }) it('should normalize and hash emails correctly', async () => { @@ -96,14 +103,14 @@ describe('TiktokAudiences.addToAudience', () => { const responses = await testDestination.testAction('addToAudience', { event, - features: { 'tiktok-hide-create-audience-action': true }, settings: { advertiser_ids: ['123'] }, useDefaultMappings: true, auth, mapping: { - send_advertising_id: false + send_advertising_id: false, + send_phone: false } }) @@ -112,6 +119,41 @@ describe('TiktokAudiences.addToAudience', () => { ) }) + it('should normalize and hash phone correctly', async () => { + nock(`${BASE_URL}${TIKTOK_API_VERSION}`) + .post('/segment/mapping/', { + advertiser_ids: ['123'], + action: 'add', + id_schema: ['PHONE_SHA256'], + batch_data: [ + [ + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: [EXTERNAL_AUDIENCE_ID] + } + ] + ] + }) + .reply(200) + + const responses = await testDestination.testAction('addToAudience', { + event, + settings: { + advertiser_ids: ['123'] + }, + useDefaultMappings: true, + auth, + mapping: { + send_advertising_id: false, + send_email: false + } + }) + + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"advertiser_ids\\":[\\"123\\"],\\"action\\":\\"add\\",\\"id_schema\\":[\\"PHONE_SHA256\\"],\\"batch_data\\":[[{\\"id\\":\\"c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646\\",\\"audience_ids\\":[\\"12345\\"]}]]}"` + ) + }) + it('should fail if an audience id is invalid', async () => { const anotherEvent = createTestEvent({ event: 'Audience Entered', @@ -124,7 +166,8 @@ describe('TiktokAudiences.addToAudience', () => { advertisingId: ADVERTISING_ID }, traits: { - email: 'testing@testing.com' + email: 'testing@testing.com', + phone: '1234567890' }, personas: { audience_settings: { @@ -138,7 +181,7 @@ describe('TiktokAudiences.addToAudience', () => { nock(`${BASE_URL}${TIKTOK_API_VERSION}/segment/mapping/`) .post(/.*/, { - id_schema: ['EMAIL_SHA256', 'IDFA_SHA256'], + id_schema: ['EMAIL_SHA256', 'PHONE_SHA256', 'IDFA_SHA256'], advertiser_ids: [ADVERTISER_ID], action: 'add', batch_data: [ @@ -147,6 +190,10 @@ describe('TiktokAudiences.addToAudience', () => { id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', audience_ids: ['THIS_ISNT_REAL'] }, + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: ['THIS_ISNT_REAL'] + }, { id: '0315b4020af3eccab7706679580ac87a710d82970733b8719e70af9b57e7b9e6', audience_ids: ['THIS_ISNT_REAL'] @@ -158,7 +205,6 @@ describe('TiktokAudiences.addToAudience', () => { const r = await testDestination.testAction('addToAudience', { event: anotherEvent, - features: { 'tiktok-hide-create-audience-action': true }, settings: { advertiser_ids: ['123'] }, @@ -176,7 +222,6 @@ describe('TiktokAudiences.addToAudience', () => { await expect( testDestination.testAction('addToAudience', { event, - features: { 'tiktok-hide-create-audience-action': true }, settings: { advertiser_ids: ['123'] }, @@ -186,10 +231,11 @@ describe('TiktokAudiences.addToAudience', () => { selected_advertiser_id: '123', audience_id: '123456', send_email: false, - send_advertising_id: false + send_advertising_id: false, + send_phone: false } }) - ).rejects.toThrow('At least one of `Send Email`, or `Send Advertising ID` must be set to `true`.') + ).rejects.toThrow('At least one of `Send Email`, `Send Phone` or `Send Advertising ID` must be set to `true`.') }) it('should fail if email and/or advertising_id is not in the payload', async () => { nock(`${BASE_URL}${TIKTOK_API_VERSION}/segment/mapping/`).post(/.*/, updateUsersRequestBody).reply(400) @@ -200,7 +246,6 @@ describe('TiktokAudiences.addToAudience', () => { await expect( testDestination.testAction('addToAudience', { event, - features: { 'tiktok-hide-create-audience-action': true }, settings: { advertiser_ids: ['123'] }, diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/generated-types.ts index 87e5448708..df0e8160a2 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/generated-types.ts @@ -5,6 +5,10 @@ export interface Payload { * The user's email address to send to TikTok. */ email?: string + /** + * The user's phone number to send to TikTok. + */ + phone?: string /** * The user's mobile advertising ID to send to TikTok. This could be a GAID, IDFA, or AAID */ @@ -13,6 +17,10 @@ export interface Payload { * Send email to TikTok. Segment will hash this value before sending */ send_email?: boolean + /** + * Send phone number to TikTok. Segment will hash this value before sending + */ + send_phone?: boolean /** * Send mobile advertising ID (IDFA, AAID or GAID) to TikTok. Segment will hash this value before sending. */ diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/index.ts index 5d21103a25..0b778a611c 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/addToAudience/index.ts @@ -5,14 +5,15 @@ import { processPayload } from '../functions' import { email, advertising_id, + phone, send_email, + send_phone, send_advertising_id, event_name, enable_batching, external_audience_id } from '../properties' import { IntegrationError } from '@segment/actions-core' -import { MIGRATION_FLAG_NAME } from '../constants' const action: ActionDefinition = { title: 'Add to Audience', @@ -20,17 +21,16 @@ const action: ActionDefinition = { defaultSubscription: 'event = "Audience Entered"', fields: { email: { ...email }, + phone: { ...phone }, advertising_id: { ...advertising_id }, send_email: { ...send_email }, + send_phone: { ...send_phone }, send_advertising_id: { ...send_advertising_id }, event_name: { ...event_name }, enable_batching: { ...enable_batching }, external_audience_id: { ...external_audience_id } }, - perform: async (request, { audienceSettings, payload, statsContext, features }) => { - if (features && !features[MIGRATION_FLAG_NAME]) { - return - } + perform: async (request, { audienceSettings, payload, statsContext }) => { const statsClient = statsContext?.statsClient const statsTag = statsContext?.tags @@ -44,10 +44,7 @@ const action: ActionDefinition = { return processPayload(request, audienceSettings, [payload], 'add') }, - performBatch: async (request, { payload, audienceSettings, statsContext, features }) => { - if (features && !features[MIGRATION_FLAG_NAME]) { - return - } + performBatch: async (request, { payload, audienceSettings, statsContext }) => { const statsClient = statsContext?.statsClient const statsTag = statsContext?.tags diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/addUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-audiences/addUser/__tests__/index.test.ts index c93fcd9720..6b220836a9 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/addUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/addUser/__tests__/index.test.ts @@ -26,7 +26,8 @@ const event = createTestEvent({ advertisingId: '123' }, traits: { - email: 'testing@testing.com' + email: 'testing@testing.com', + phone: '1234567890' } } }) @@ -34,13 +35,17 @@ const event = createTestEvent({ const updateUsersRequestBody = { advertiser_ids: ['123'], action: 'add', - id_schema: ['EMAIL_SHA256', 'IDFA_SHA256'], + id_schema: ['EMAIL_SHA256', 'PHONE_SHA256', 'IDFA_SHA256'], batch_data: [ [ { id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', audience_ids: ['1234345'] }, + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: ['1234345'] + }, { id: 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', audience_ids: ['1234345'] @@ -94,7 +99,8 @@ describe('TiktokAudiences.addUser', () => { mapping: { selected_advertiser_id: '123', audience_id: '1234345', - send_advertising_id: false + send_advertising_id: false, + send_phone: false } }) expect(responses[0].options.body).toMatchInlineSnapshot( @@ -102,6 +108,41 @@ describe('TiktokAudiences.addUser', () => { ) }) + it('should normalize and hash phone correctly', async () => { + nock(`${BASE_URL}${TIKTOK_API_VERSION}/segment/mapping/`) + .post(/.*/, { + advertiser_ids: ['123'], + action: 'add', + id_schema: ['PHONE_SHA256'], + batch_data: [ + [ + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: ['1234345'] + } + ] + ] + }) + .reply(200) + const responses = await testDestination.testAction('addUser', { + event, + settings: { + advertiser_ids: ['123'] + }, + useDefaultMappings: true, + auth, + mapping: { + selected_advertiser_id: '123', + audience_id: '1234345', + send_advertising_id: false, + send_email: false + } + }) + expect(responses[0].options.body).toMatchInlineSnapshot( + `"{\\"advertiser_ids\\":[\\"123\\"],\\"action\\":\\"add\\",\\"id_schema\\":[\\"PHONE_SHA256\\"],\\"batch_data\\":[[{\\"id\\":\\"c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646\\",\\"audience_ids\\":[\\"1234345\\"]}]]}"` + ) + }) + it('should fail if an audience id is invalid', async () => { nock(`${BASE_URL}${TIKTOK_API_VERSION}/segment/mapping/`).post(/.*/, updateUsersRequestBody).reply(400) @@ -136,10 +177,11 @@ describe('TiktokAudiences.addUser', () => { selected_advertiser_id: '123', audience_id: '123456', send_email: false, - send_advertising_id: false + send_advertising_id: false, + send_phone: false } }) - ).rejects.toThrow('At least one of `Send Email`, or `Send Advertising ID` must be set to `true`.') + ).rejects.toThrow('At least one of `Send Email`, `Send Phone` or `Send Advertising ID` must be set to `true`.') }) it('should fail if email and/or advertising_id is not in the payload', async () => { nock(`${BASE_URL}${TIKTOK_API_VERSION}/segment/mapping/`).post(/.*/, updateUsersRequestBody).reply(400) @@ -159,7 +201,8 @@ describe('TiktokAudiences.addUser', () => { selected_advertiser_id: '123', audience_id: 'personas_test_audience', send_email: true, - send_advertising_id: true + send_advertising_id: true, + send_phone: true } }) ).rejects.toThrowError('At least one of Email Id or Advertising ID must be provided.') diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/addUser/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-audiences/addUser/generated-types.ts index ac0bb830f7..444db36fe4 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/addUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/addUser/generated-types.ts @@ -13,6 +13,10 @@ export interface Payload { * The user's email address to send to TikTok. */ email?: string + /** + * The user's phone number to send to TikTok. + */ + phone?: string /** * The user's mobile advertising ID to send to TikTok. This could be a GAID, IDFA, or AAID */ @@ -21,6 +25,10 @@ export interface Payload { * Send email to TikTok. Segment will hash this value before sending */ send_email?: boolean + /** + * Send phone number to TikTok. Segment will hash this value before sending + */ + send_phone?: boolean /** * Send mobile advertising ID (IDFA, AAID or GAID) to TikTok. Segment will hash this value before sending. */ diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/addUser/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/addUser/index.ts index e70679454f..2b8fb6dbeb 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/addUser/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/addUser/index.ts @@ -6,29 +6,32 @@ import { selected_advertiser_id, audience_id, email, + phone, advertising_id, send_email, + send_phone, send_advertising_id, event_name, enable_batching } from '../properties' import { TikTokAudiences } from '../api' -import { MIGRATION_FLAG_NAME } from '../constants' // NOTE // This action is not used by the native Segment Audiences feature. // TODO: Remove on cleanup. const action: ActionDefinition = { - title: 'Add Users', + title: 'Add Users (Legacy)', description: 'Add contacts from an Engage Audience to a TikTok Audience Segment.', defaultSubscription: 'event = "Audience Entered"', fields: { selected_advertiser_id: { ...selected_advertiser_id }, audience_id: { ...audience_id }, email: { ...email }, + phone: { ...phone }, advertising_id: { ...advertising_id }, send_email: { ...send_email }, + send_phone: { ...send_phone }, send_advertising_id: { ...send_advertising_id }, event_name: { ...event_name }, enable_batching: { ...enable_batching } @@ -75,18 +78,12 @@ const action: ActionDefinition = { } } }, - perform: async (request, { settings, payload, statsContext, features }) => { - if (features && features[MIGRATION_FLAG_NAME]) { - return - } - statsContext?.statsClient?.incr('addUser', 1, statsContext?.tags) + perform: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('addUserLegacy', 1, statsContext?.tags) return processPayload(request, settings, [payload], 'add') }, - performBatch: async (request, { settings, payload, statsContext, features }) => { - if (features && features[MIGRATION_FLAG_NAME]) { - return - } - statsContext?.statsClient?.incr('addUser', 1, statsContext?.tags) + performBatch: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('addUserLegacy', 1, statsContext?.tags) return processPayload(request, settings, payload, 'add') } } diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/constants.ts b/packages/destination-actions/src/destinations/tiktok-audiences/constants.ts index 856113a564..b510c4855e 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/constants.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/constants.ts @@ -2,4 +2,4 @@ export const TIKTOK_API_VERSION = 'v1.3' export const BASE_URL = 'https://business-api.tiktok.com/open_api/' export const CREATE_AUDIENCE_URL = `${BASE_URL}${TIKTOK_API_VERSION}/segment/audience/` export const GET_AUDIENCE_URL = `${BASE_URL}${TIKTOK_API_VERSION}/dmp/custom_audience/get` -export const MIGRATION_FLAG_NAME = 'tiktok-hide-create-audience-action' +export const MIGRATION_FLAG_NAME = 'actions-migrated-tiktok' diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/createAudience/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/createAudience/index.ts index 0d26828284..e12c65d4c4 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/createAudience/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/createAudience/index.ts @@ -10,8 +10,8 @@ import { TikTokAudiences } from '../api' // Consider it deprecated and do not emulate its behavior. const action: ActionDefinition = { - title: 'Create Audience', - description: 'Creates a new audience in TikTok Audience Segment.', + title: 'Create Audience (Legacy)', + description: 'Use this action to create a new audience in TikTok Audience Segment. This is required for legacy instances of the TikTok Audience destination to create a partner audience within TikTok for syncing Engage audiences to.', defaultSubscription: 'event = "Create Audience"', fields: { selected_advertiser_id: { ...selected_advertiser_id }, diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts b/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts index 25d3934522..0b8e3b9f5d 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/functions.ts @@ -69,9 +69,13 @@ export async function createAudience( } export function validate(payloads: GenericPayload[]): void { - if (payloads[0].send_email === false && payloads[0].send_advertising_id === false) { + if ( + payloads[0].send_email === false && + payloads[0].send_advertising_id === false && + payloads[0].send_phone === false + ) { throw new IntegrationError( - 'At least one of `Send Email`, or `Send Advertising ID` must be set to `true`.', + 'At least one of `Send Email`, `Send Phone` or `Send Advertising ID` must be set to `true`.', 'INVALID_SETTINGS', 400 ) @@ -85,6 +89,9 @@ export function getIDSchema(payload: GenericPayload): string[] { if (payload.send_email === true) { id_schema.push('EMAIL_SHA256') } + if (payload.send_phone === true) { + id_schema.push('PHONE_SHA256') + } if (payload.send_advertising_id === true) { id_schema.push('IDFA_SHA256') } @@ -92,6 +99,8 @@ export function getIDSchema(payload: GenericPayload): string[] { return id_schema } +const isHashedEmail = (email: string): boolean => new RegExp(/[0-9abcdef]{64}/gi).test(email) + export function extractUsers(payloads: GenericPayload[]): {}[][] { const batch_data: {}[][] = [] @@ -119,14 +128,32 @@ export function extractUsers(payloads: GenericPayload[]): {}[][] { .replace(/\+.*@/, '@') .replace(/\.(?=.*@)/g, '') .toLowerCase() + + // If email is already hashed, don't hash it again + let hashedEmail = payload.email + if (!isHashedEmail(payload.email)) { + hashedEmail = createHash('sha256').update(payload.email).digest('hex') + } + email_id = { - id: createHash('sha256').update(payload.email).digest('hex'), + id: hashedEmail, audience_ids: [external_audience_id] } } user_ids.push(email_id) } + if (payload.send_phone === true) { + let phone_id = {} + if (payload.phone) { + phone_id = { + id: createHash('sha256').update(payload.phone).digest('hex'), + audience_ids: [external_audience_id] + } + } + user_ids.push(phone_id) + } + if (payload.send_advertising_id === true) { let advertising_id = {} if (payload.advertising_id) { diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/index.ts index abd59e0491..49ce0df1f3 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/index.ts @@ -174,11 +174,11 @@ const destination: AudienceDestinationDefinition = { } }, actions: { + addToAudience, + removeFromAudience, addUser, removeUser, - createAudience, - addToAudience, - removeFromAudience + createAudience } } diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/properties.ts b/packages/destination-actions/src/destinations/tiktok-audiences/properties.ts index 21c609ffd5..c8697360f9 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/properties.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/properties.ts @@ -42,30 +42,54 @@ export const custom_audience_name: InputField = { export const email: InputField = { label: 'User Email', description: "The user's email address to send to TikTok.", - type: 'hidden', // This field is hidden from customers because the desired value always appears at path '$.context.traits.email' in Personas events. + type: 'string', default: { - '@path': '$.context.traits.email' + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } } } export const send_email: InputField = { - label: 'Send Email', + label: 'Send Email?', description: 'Send email to TikTok. Segment will hash this value before sending', type: 'boolean', default: true } +export const phone: InputField = { + label: 'User Phone Number', + description: "The user's phone number to send to TikTok.", + type: 'string', + default: { + '@if': { + exists: { '@path': '$.context.traits.phone' }, + then: { '@path': '$.context.traits.phone' }, + else: { '@path': '$.properties.phone' } + } + } +} + +export const send_phone: InputField = { + label: 'Send Phone Number?', + description: 'Send phone number to TikTok. Segment will hash this value before sending', + type: 'boolean', + default: true +} + export const advertising_id: InputField = { label: 'User Advertising ID', description: "The user's mobile advertising ID to send to TikTok. This could be a GAID, IDFA, or AAID", - type: 'hidden', // This field is hidden from customers because the desired value always appears at path '$.context.device.advertisingId' in Personas events. + type: 'string', default: { '@path': '$.context.device.advertisingId' } } export const send_advertising_id: InputField = { - label: 'Send Mobile Advertising ID', + label: 'Send Mobile Advertising ID?', description: 'Send mobile advertising ID (IDFA, AAID or GAID) to TikTok. Segment will hash this value before sending.', type: 'boolean', @@ -75,7 +99,8 @@ export const send_advertising_id: InputField = { export const event_name: InputField = { label: 'Event Name', description: 'The name of the current Segment event.', - type: 'hidden', // This field is hidden from customers because the desired value always appears at path '$.event' in Personas events. + type: 'string', + unsafe_hidden: true, // This field is hidden from customers because the desired value always appears at path '$.event' in Personas events. default: { '@path': '$.event' } @@ -85,13 +110,15 @@ export const enable_batching: InputField = { label: 'Enable Batching', description: 'Enable batching of requests to the TikTok Audiences.', type: 'boolean', - default: true + default: true, + unsafe_hidden: true } export const external_audience_id: InputField = { label: 'External Audience ID', description: "The Audience ID in TikTok's DB.", - type: 'hidden', + type: 'string', + unsafe_hidden: true, default: { '@path': '$.context.personas.external_audience_id' } diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/__tests__/index.test.ts index b994b66cb4..89b33a36d4 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/__tests__/index.test.ts @@ -29,7 +29,8 @@ const event = createTestEvent({ advertisingId: ADVERTISING_ID }, traits: { - email: 'testing@testing.com' + email: 'testing@testing.com', + phone: '1234567890' }, personas: { audience_settings: { @@ -42,7 +43,7 @@ const event = createTestEvent({ }) const updateUsersRequestBody = { - id_schema: ['EMAIL_SHA256', 'IDFA_SHA256'], + id_schema: ['EMAIL_SHA256', 'PHONE_SHA256', 'IDFA_SHA256'], advertiser_ids: [ADVERTISER_ID], action: 'delete', batch_data: [ @@ -51,6 +52,10 @@ const updateUsersRequestBody = { id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', audience_ids: [EXTERNAL_AUDIENCE_ID] }, + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: [EXTERNAL_AUDIENCE_ID] + }, { id: '0315b4020af3eccab7706679580ac87a710d82970733b8719e70af9b57e7b9e6', audience_ids: [EXTERNAL_AUDIENCE_ID] @@ -66,7 +71,6 @@ describe('TiktokAudiences.removeFromAudience', () => { await expect( testDestination.testAction('removeFromAudience', { event, - features: { 'tiktok-hide-create-audience-action': true }, settings: { advertiser_ids: ['123'] }, @@ -127,7 +131,6 @@ describe('TiktokAudiences.removeFromAudience', () => { await expect( testDestination.testAction('removeFromAudience', { event: anotherEvent, - features: { 'tiktok-hide-create-audience-action': true }, settings: { advertiser_ids: ['123'] }, @@ -147,7 +150,6 @@ describe('TiktokAudiences.removeFromAudience', () => { await expect( testDestination.testAction('removeFromAudience', { event, - features: { 'tiktok-hide-create-audience-action': true }, settings: { advertiser_ids: ['123'] }, @@ -157,9 +159,10 @@ describe('TiktokAudiences.removeFromAudience', () => { selected_advertiser_id: '123', audience_id: '123456', send_email: false, - send_advertising_id: false + send_advertising_id: false, + send_phone: false } }) - ).rejects.toThrow('At least one of `Send Email`, or `Send Advertising ID` must be set to `true`.') + ).rejects.toThrow('At least one of `Send Email`, `Send Phone` or `Send Advertising ID` must be set to `true`.') }) }) diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/generated-types.ts index 87e5448708..df0e8160a2 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/generated-types.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/generated-types.ts @@ -5,6 +5,10 @@ export interface Payload { * The user's email address to send to TikTok. */ email?: string + /** + * The user's phone number to send to TikTok. + */ + phone?: string /** * The user's mobile advertising ID to send to TikTok. This could be a GAID, IDFA, or AAID */ @@ -13,6 +17,10 @@ export interface Payload { * Send email to TikTok. Segment will hash this value before sending */ send_email?: boolean + /** + * Send phone number to TikTok. Segment will hash this value before sending + */ + send_phone?: boolean /** * Send mobile advertising ID (IDFA, AAID or GAID) to TikTok. Segment will hash this value before sending. */ diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/index.ts index 54ecd7e9a2..077222e2e1 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/removeFromAudience/index.ts @@ -4,7 +4,9 @@ import type { Payload } from './generated-types' import { processPayload } from '../functions' import { email, + phone, send_email, + send_phone, send_advertising_id, advertising_id, event_name, @@ -12,7 +14,6 @@ import { external_audience_id } from '../properties' import { IntegrationError } from '@segment/actions-core' -import { MIGRATION_FLAG_NAME } from '../constants' const action: ActionDefinition = { title: 'Remove from Audience', @@ -20,18 +21,16 @@ const action: ActionDefinition = { defaultSubscription: 'event = "Audience Exited"', fields: { email: { ...email }, + phone: { ...phone }, advertising_id: { ...advertising_id }, send_email: { ...send_email }, + send_phone: { ...send_phone }, send_advertising_id: { ...send_advertising_id }, event_name: { ...event_name }, enable_batching: { ...enable_batching }, external_audience_id: { ...external_audience_id } }, - perform: async (request, { audienceSettings, payload, statsContext, features }) => { - if (features && !features[MIGRATION_FLAG_NAME]) { - return - } - + perform: async (request, { audienceSettings, payload, statsContext }) => { const statsClient = statsContext?.statsClient const statsTag = statsContext?.tags @@ -42,11 +41,7 @@ const action: ActionDefinition = { statsClient?.incr('removeFromAudience', 1, statsTag) return processPayload(request, audienceSettings, [payload], 'delete') }, - performBatch: async (request, { audienceSettings, payload, statsContext, features }) => { - if (features && !features[MIGRATION_FLAG_NAME]) { - return - } - + performBatch: async (request, { audienceSettings, payload, statsContext }) => { const statsClient = statsContext?.statsClient const statsTag = statsContext?.tags diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/__tests__/index.test.ts index 114ad04c54..9edc219614 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/__tests__/index.test.ts @@ -26,7 +26,8 @@ const event = createTestEvent({ advertisingId: '123' }, traits: { - email: 'testing@testing.com' + email: 'testing@testing.com', + phone: '1234567890' } } }) @@ -34,13 +35,17 @@ const event = createTestEvent({ const updateUsersRequestBody = { advertiser_ids: ['123'], action: 'delete', - id_schema: ['EMAIL_SHA256', 'IDFA_SHA256'], + id_schema: ['EMAIL_SHA256', 'PHONE_SHA256', 'IDFA_SHA256'], batch_data: [ [ { id: '584c4423c421df49955759498a71495aba49b8780eb9387dff333b6f0982c777', audience_ids: ['1234345'] }, + { + id: 'c775e7b757ede630cd0aa1113bd102661ab38829ca52a6422ab782862f268646', + audience_ids: ['1234345'] + }, { id: 'a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3', audience_ids: ['1234345'] @@ -103,9 +108,10 @@ describe('TiktokAudiences.removeUser', () => { selected_advertiser_id: '123', audience_id: '123456', send_email: false, - send_advertising_id: false + send_advertising_id: false, + send_phone: false } }) - ).rejects.toThrow('At least one of `Send Email`, or `Send Advertising ID` must be set to `true`.') + ).rejects.toThrow('At least one of `Send Email`, `Send Phone` or `Send Advertising ID` must be set to `true`.') }) }) diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/generated-types.ts index ac0bb830f7..444db36fe4 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/generated-types.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/generated-types.ts @@ -13,6 +13,10 @@ export interface Payload { * The user's email address to send to TikTok. */ email?: string + /** + * The user's phone number to send to TikTok. + */ + phone?: string /** * The user's mobile advertising ID to send to TikTok. This could be a GAID, IDFA, or AAID */ @@ -21,6 +25,10 @@ export interface Payload { * Send email to TikTok. Segment will hash this value before sending */ send_email?: boolean + /** + * Send phone number to TikTok. Segment will hash this value before sending + */ + send_phone?: boolean /** * Send mobile advertising ID (IDFA, AAID or GAID) to TikTok. Segment will hash this value before sending. */ diff --git a/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/index.ts b/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/index.ts index 39e76e9597..44fde531e4 100644 --- a/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-audiences/removeUser/index.ts @@ -6,29 +6,32 @@ import { selected_advertiser_id, audience_id, email, + phone, send_email, + send_phone, send_advertising_id, advertising_id, event_name, enable_batching } from '../properties' import { TikTokAudiences } from '../api' -import { MIGRATION_FLAG_NAME } from '../constants' // NOTE // This action is not used by the native Segment Audiences feature. // TODO: Remove on cleanup. const action: ActionDefinition = { - title: 'Remove Users', + title: 'Remove Users (Legacy)', description: 'Remove contacts from an Engage Audience to a TikTok Audience Segment.', defaultSubscription: 'event = "Audience Exited"', fields: { selected_advertiser_id: { ...selected_advertiser_id }, audience_id: { ...audience_id }, email: { ...email }, + phone: { ...phone }, advertising_id: { ...advertising_id }, send_email: { ...send_email }, + send_phone: { ...send_phone }, send_advertising_id: { ...send_advertising_id }, event_name: { ...event_name }, enable_batching: { ...enable_batching } @@ -74,18 +77,12 @@ const action: ActionDefinition = { } } }, - perform: async (request, { settings, payload, statsContext, features }) => { - if (features && features[MIGRATION_FLAG_NAME]) { - return - } - statsContext?.statsClient?.incr('removeUser', 1, statsContext?.tags) + perform: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('removeUserLegacy', 1, statsContext?.tags) return processPayload(request, settings, [payload], 'delete') }, - performBatch: async (request, { settings, payload, statsContext, features }) => { - if (features && features[MIGRATION_FLAG_NAME]) { - return - } - statsContext?.statsClient?.incr('removeUser', 1, statsContext?.tags) + performBatch: async (request, { settings, payload, statsContext }) => { + statsContext?.statsClient?.incr('removeUserLegacy', 1, statsContext?.tags) return processPayload(request, settings, payload, 'delete') } } diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/__tests__/index.test.ts new file mode 100644 index 0000000000..8c9fcfd51a --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/__tests__/index.test.ts @@ -0,0 +1,699 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { Settings } from '../generated-types' + +const testDestination = createTestIntegration(Definition) +const timestamp = '2024-01-08T13:52:50.212Z' +const settings: Settings = { + accessToken: 'test', + pixelCode: 'test' +} + +describe('Tiktok Conversions', () => { + describe('reportWebEvent', () => { + it('should send a successful InitiateCheckout event to reportWebEvent', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Checkout Started', + messageId: 'corey123', + type: 'track', + properties: { + email: 'coreytest1231@gmail.com', + phone: '+1555-555-5555', + ttclid: '12345', + currency: 'USD', + value: 100, + query: 'shoes' + }, + context: { + page: { + url: 'https://segment.com/', + referrer: 'https://google.com/' + }, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57', + ip: '0.0.0.0' + }, + userId: 'testId123' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track').post('/').reply(200, {}) + const responses = await testDestination.testAction('reportWebEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'InitiateCheckout' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: [ + { + event: 'InitiateCheckout', + event_id: 'corey123', + event_time: 1704721970, + limited_data_use: false, + page: { + referrer: 'https://google.com/', + url: 'https://segment.com/' + }, + properties: { + content_type: 'product', + contents: [], + currency: 'USD', + description: undefined, + order_id: undefined, + query: 'shoes', + shop_id: undefined, + value: 100 + }, + test_event_code: undefined, + user: { + email: ['eb9869a32b532840dd6aa714f7a872d21d6f650fc5aa933d9feefc64708969c7'], + external_id: ['481f202262e9c5ccc48d24e60798fadaa5f6ff1f8369f7ab927c04c3aa682a7f'], + ip: '0.0.0.0', + lead_id: undefined, + locale: undefined, + phone: ['910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0'], + ttclid: '12345', + ttp: undefined, + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57' + } + } + ], + event_source: 'web', + event_source_id: 'test', + partner_name: 'Segment' + }) + }) + + it('should send contents array properties to TikTok', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Checkout Started', + messageId: 'corey123', + type: 'track', + properties: { + email: 'coreytest1231@gmail.com', + phone: '+1555-555-5555', + ttclid: '12345', + currency: 'USD', + value: 100, + query: 'shoes', + products: [ + { + price: 100, + quantity: 2, + category: 'Air Force One (Size S)', + product_id: 'abc123', + name: 'pname1', + brand: 'Brand X' + } + ] + }, + context: { + page: { + url: 'https://segment.com/', + referrer: 'https://google.com/' + }, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57', + ip: '0.0.0.0' + }, + userId: 'testId123' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track').post('/').reply(200, {}) + const responses = await testDestination.testAction('reportWebEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'InitiateCheckout', + contents: { + '@arrayPath': [ + '$.properties.products', + { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } + } + ] + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: [ + { + event: 'InitiateCheckout', + event_id: 'corey123', + event_time: 1704721970, + limited_data_use: false, + page: { + referrer: 'https://google.com/', + url: 'https://segment.com/' + }, + properties: { + content_type: 'product', + contents: [ + { + price: 100, + quantity: 2, + content_id: 'abc123', + content_category: 'Air Force One (Size S)', + content_name: 'pname1', + brand: 'Brand X' + } + ], + currency: 'USD', + description: undefined, + order_id: undefined, + query: 'shoes', + shop_id: undefined, + value: 100 + }, + test_event_code: undefined, + user: { + email: ['eb9869a32b532840dd6aa714f7a872d21d6f650fc5aa933d9feefc64708969c7'], + external_id: ['481f202262e9c5ccc48d24e60798fadaa5f6ff1f8369f7ab927c04c3aa682a7f'], + ip: '0.0.0.0', + lead_id: undefined, + locale: undefined, + phone: ['910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0'], + ttclid: '12345', + ttp: undefined, + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57' + } + } + ], + event_source: 'web', + event_source_id: 'test', + partner_name: 'Segment' + }) + }) + + it('should coerce properties into the contents array', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Checkout Started', + messageId: 'corey123', + type: 'track', + properties: { + email: 'coreytest1231@gmail.com', + phone: '+1555-555-5555', + ttclid: '12345', + currency: 'USD', + value: 100, + query: 'shoes', + price: 100, + quantity: 2, + category: 'Air Force One (Size S)', + product_id: 'abc123', + name: 'pname1', + brand: 'Brand X' + }, + context: { + page: { + url: 'https://segment.com/', + referrer: 'https://google.com/' + }, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57', + ip: '0.0.0.0' + }, + userId: 'testId123' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track').post('/').reply(200, {}) + const responses = await testDestination.testAction('reportWebEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'AddToCart', + contents: { + '@arrayPath': [ + '$.properties', + { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } + } + ] + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: [ + { + event: 'AddToCart', + event_id: 'corey123', + event_time: 1704721970, + limited_data_use: false, + page: { + referrer: 'https://google.com/', + url: 'https://segment.com/' + }, + properties: { + content_type: 'product', + contents: [ + { + price: 100, + quantity: 2, + content_id: 'abc123', + content_category: 'Air Force One (Size S)', + content_name: 'pname1', + brand: 'Brand X' + } + ], + currency: 'USD', + description: undefined, + order_id: undefined, + query: 'shoes', + shop_id: undefined, + value: 100 + }, + test_event_code: undefined, + user: { + email: ['eb9869a32b532840dd6aa714f7a872d21d6f650fc5aa933d9feefc64708969c7'], + external_id: ['481f202262e9c5ccc48d24e60798fadaa5f6ff1f8369f7ab927c04c3aa682a7f'], + ip: '0.0.0.0', + lead_id: undefined, + locale: undefined, + phone: ['910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0'], + ttclid: '12345', + ttp: undefined, + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57' + } + } + ], + event_source: 'web', + event_source_id: 'test', + partner_name: 'Segment' + }) + }) + + it('should parse context.page.url ttclid if properties.ttclid not available', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Checkout Started', + messageId: 'corey123', + type: 'track', + properties: { + email: 'coreytest1231@gmail.com', + phone: '+1555-555-5555', + currency: 'USD', + value: 100, + query: 'shoes', + price: 100, + quantity: 2, + category: 'Air Force One (Size S)', + product_id: 'abc123', + name: 'pname1', + brand: 'Brand X' + }, + context: { + page: { + url: 'http://demo.mywebsite.com?a=b&ttclid=123ATXSfe', + referrer: 'https://google.com/' + }, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57', + ip: '0.0.0.0' + }, + userId: 'testId123' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track').post('/').reply(200, {}) + const responses = await testDestination.testAction('reportWebEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'AddToCart', + contents: { + '@arrayPath': [ + '$.properties', + { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } + } + ] + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: [ + { + event: 'AddToCart', + event_id: 'corey123', + event_time: 1704721970, + limited_data_use: false, + page: { + referrer: 'https://google.com/', + url: 'http://demo.mywebsite.com?a=b&ttclid=123ATXSfe' + }, + properties: { + content_type: 'product', + contents: [ + { + price: 100, + quantity: 2, + content_id: 'abc123', + content_category: 'Air Force One (Size S)', + content_name: 'pname1', + brand: 'Brand X' + } + ], + currency: 'USD', + description: undefined, + order_id: undefined, + query: 'shoes', + shop_id: undefined, + value: 100 + }, + test_event_code: undefined, + user: { + email: ['eb9869a32b532840dd6aa714f7a872d21d6f650fc5aa933d9feefc64708969c7'], + external_id: ['481f202262e9c5ccc48d24e60798fadaa5f6ff1f8369f7ab927c04c3aa682a7f'], + ip: '0.0.0.0', + lead_id: undefined, + locale: undefined, + phone: ['910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0'], + ttclid: '123ATXSfe', + ttp: undefined, + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57' + } + } + ], + event_source: 'web', + event_source_id: 'test', + partner_name: 'Segment' + }) + }) + + it('should send a successful lead_event event to reportWebEvent', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'lead_event', + messageId: 'corey123', + type: 'track', + properties: { + email: 'coreytest1231@gmail.com', + phone: '+1555-555-5555', + lead_id: '2229012621312', + currency: 'USD', + value: 100, + query: 'shoes', + price: 100, + quantity: 2, + category: 'Air Force One (Size S)', + product_id: 'abc123', + name: 'pname1', + brand: 'Brand X' + }, + context: { + page: { + url: 'http://demo.mywebsite.com?a=b&ttclid=123ATXSfe', + referrer: 'https://google.com/' + }, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57', + ip: '0.0.0.0' + }, + userId: 'testId123' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track').post('/').reply(200, {}) + const responses = await testDestination.testAction('reportWebEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'lead_event', + contents: { + '@arrayPath': [ + '$.properties', + { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } + } + ] + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: [ + { + event: 'lead_event', + event_id: 'corey123', + event_time: 1704721970, + limited_data_use: false, + page: { + referrer: 'https://google.com/', + url: 'http://demo.mywebsite.com?a=b&ttclid=123ATXSfe' + }, + properties: { + content_type: 'product', + contents: [ + { + price: 100, + quantity: 2, + content_id: 'abc123', + content_category: 'Air Force One (Size S)', + content_name: 'pname1', + brand: 'Brand X' + } + ], + currency: 'USD', + description: undefined, + order_id: undefined, + query: 'shoes', + shop_id: undefined, + value: 100 + }, + test_event_code: undefined, + user: { + email: ['eb9869a32b532840dd6aa714f7a872d21d6f650fc5aa933d9feefc64708969c7'], + external_id: ['481f202262e9c5ccc48d24e60798fadaa5f6ff1f8369f7ab927c04c3aa682a7f'], + ip: '0.0.0.0', + lead_id: '2229012621312', + locale: undefined, + phone: ['910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0'], + ttclid: '123ATXSfe', + ttp: undefined, + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57' + } + } + ], + event_source: 'web', + event_source_id: 'test', + partner_name: 'Segment' + }) + }) + + it('should send test_event_code if present in mapping', async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Checkout Started', + messageId: 'corey123', + type: 'track', + properties: { + email: 'coreytest1231@gmail.com', + phone: '+1555-555-5555', + ttclid: '12345', + currency: 'USD', + value: 100, + query: 'shoes', + price: 100, + quantity: 2, + category: 'Air Force One (Size S)', + product_id: 'abc123', + name: 'pname1', + brand: 'Brand X' + }, + context: { + page: { + url: 'https://segment.com/', + referrer: 'https://google.com/' + }, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57', + ip: '0.0.0.0' + }, + userId: 'testId123' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track').post('/').reply(200, {}) + const responses = await testDestination.testAction('reportWebEvent', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'AddToCart', + test_event_code: 'TEST04030', + contents: { + '@arrayPath': [ + '$.properties', + { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } + } + ] + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + data: [ + { + event: 'AddToCart', + event_id: 'corey123', + event_time: 1704721970, + limited_data_use: false, + page: { + referrer: 'https://google.com/', + url: 'https://segment.com/' + }, + properties: { + content_type: 'product', + contents: [ + { + price: 100, + quantity: 2, + content_id: 'abc123', + content_category: 'Air Force One (Size S)', + content_name: 'pname1', + brand: 'Brand X' + } + ], + currency: 'USD', + description: undefined, + order_id: undefined, + query: 'shoes', + shop_id: undefined, + value: 100 + }, + test_event_code: 'TEST04030', + user: { + email: ['eb9869a32b532840dd6aa714f7a872d21d6f650fc5aa933d9feefc64708969c7'], + external_id: ['481f202262e9c5ccc48d24e60798fadaa5f6ff1f8369f7ab927c04c3aa682a7f'], + ip: '0.0.0.0', + lead_id: undefined, + locale: undefined, + phone: ['910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0'], + ttclid: '12345', + ttp: undefined, + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/16D57' + } + } + ], + event_source: 'web', + event_source_id: 'test', + partner_name: 'Segment' + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/common_fields.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/common_fields.ts new file mode 100644 index 0000000000..463aa167bf --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/common_fields.ts @@ -0,0 +1,253 @@ +import { InputField } from '@segment/actions-core' + +export const commonFields: Record = { + event: { + label: 'Event Name', + type: 'string', + required: true, + description: + 'Conversion event name. Please refer to the "Supported Web Events" section on in TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1701890979375106) for accepted event names.' + }, + event_id: { + label: 'Event ID', + type: 'string', + description: 'Any hashed ID that can identify a unique user/session.', + default: { + '@path': '$.messageId' + } + }, + timestamp: { + label: 'Event Timestamp', + type: 'string', + description: 'Timestamp that the event took place, in ISO 8601 format.', + default: { + '@path': '$.timestamp' + } + }, + phone_number: { + label: 'Phone Number', + description: + 'A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. e.g. +14150000000. Segment will hash this value before sending to TikTok.', + type: 'string', + multiple: true, + default: { + '@if': { + exists: { '@path': '$.properties.phone' }, + then: { '@path': '$.properties.phone' }, + else: { '@path': '$.context.traits.phone' } + } + } + }, + email: { + label: 'Email', + description: + 'A single email address or an array of email addresses. Segment will hash this value before sending to TikTok.', + type: 'string', + multiple: true, + default: { + '@if': { + exists: { '@path': '$.properties.email' }, + then: { '@path': '$.properties.email' }, + else: { '@path': '$.context.traits.email' } + } + } + }, + order_id: { + label: 'Order ID', + type: 'string', + description: 'Order ID of the transaction.', + default: { + '@path': '$.properties.order_id' + } + }, + shop_id: { + label: 'Shop ID', + type: 'string', + description: 'Shop ID of the transaction.', + default: { + '@path': '$.properties.shop_id' + } + }, + external_id: { + label: 'External ID', + description: + 'Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Conversions Destination supports both string and string[] types for sending external ID(s).', + type: 'string', + multiple: true, + default: { + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + ttclid: { + label: 'TikTok Click ID', + description: + 'The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.ttclid' }, + then: { '@path': '$.properties.ttclid' }, + else: { '@path': '$.integrations.TikTok Conversions.ttclid' } + } + } + }, + ttp: { + label: 'TikTok Cookie ID', + description: + 'TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`).', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.ttp' }, + then: { '@path': '$.properties.ttp' }, + else: { '@path': '$.integrations.TikTok Conversions.ttp' } + } + } + }, + lead_id: { + label: 'TikTok Lead ID', + description: + 'ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability', + type: 'string', + default: { '@path': '$.properties.lead_id' } + }, + locale: { + label: 'Locale', + description: + 'The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt).', + type: 'string', + default: { + '@path': '$.context.locale' + } + }, + url: { + label: 'Page URL', + type: 'string', + description: 'The page URL where the conversion event took place.', + default: { + '@path': '$.context.page.url' + } + }, + referrer: { + label: 'Page Referrer', + type: 'string', + description: 'The page referrer.', + default: { + '@path': '$.context.page.referrer' + } + }, + ip: { + label: 'IP Address', + type: 'string', + description: 'IP address of the browser.', + default: { + '@path': '$.context.ip' + } + }, + user_agent: { + label: 'User Agent', + type: 'string', + description: 'User agent from the user’s device.', + default: { + '@path': '$.context.userAgent' + } + }, + contents: { + label: 'Contents', + type: 'object', + multiple: true, + description: 'Related item details for the event.', + properties: { + price: { + label: 'Price', + description: 'Price of the item.', + type: 'number' + }, + quantity: { + label: 'Quantity', + description: 'Number of items.', + type: 'number' + }, + content_category: { + label: 'Content Category', + description: 'Category of the product item.', + type: 'string' + }, + content_id: { + label: 'Content ID', + description: 'ID of the product item.', + type: 'string' + }, + content_name: { + label: 'Content Name', + description: 'Name of the product item.', + type: 'string' + }, + brand: { + label: 'Brand', + description: 'Brand name of the product item.', + type: 'string' + } + } + }, + content_type: { + label: 'Content Type', + description: + 'Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`.', + type: 'string', + choices: [ { label: 'product', value: 'product' }, { label: 'product_group', value: 'product_group' }], + default: 'product' + }, + currency: { + label: 'Currency', + type: 'string', + description: 'Currency for the value specified as ISO 4217 code.', + default: { + '@path': '$.properties.currency' + } + }, + value: { + label: 'Value', + type: 'number', + description: 'Value of the order or items sold.', + default: { + '@if': { + exists: { '@path': '$.properties.value' }, + then: { '@path': '$.properties.value' }, + else: { '@path': '$.properties.revenue' } + } + } + }, + description: { + label: 'Description', + type: 'string', + description: 'A string description of the web event.' + }, + query: { + label: 'Query', + type: 'string', + description: 'The text string that was searched for.', + default: { + '@path': '$.properties.query' + } + }, + limited_data_use: { + label: 'Limited Data Use', + type: 'boolean', + description: + 'Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970).', + default: { + '@path': '$.properties.limited_data_use' + } + }, + test_event_code: { + label: 'Test Event Code', + type: 'string', + description: + 'Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You\'ll want to remove your Test Event Code when sending real traffic through this integration.' + } +} diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/formatter.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/formatter.ts new file mode 100644 index 0000000000..d3ef9351b3 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/formatter.ts @@ -0,0 +1,56 @@ +import { createHash } from 'crypto' + +/** + * Convert emails to lower case, and hash in SHA256. + */ +export const formatEmails = (email_addresses: string[] | undefined): string[] => { + const result: string[] = [] + if (email_addresses) { + email_addresses.forEach((email: string) => { + result.push(hashAndEncode(email.toLowerCase())) + }) + } + return result +} + +/** + * Convert string to match E.164 phone number pattern (e.g. +1234567890) + * Note it is up to the advertiser to pass only valid phone numbers and formats. + * This function assumes the input is a correctly formatted phone number maximum of 14 characters long with country code included in the input. + */ +export const formatPhones = (phone_numbers: string[] | undefined): string[] => { + const result: string[] = [] + if (!phone_numbers) return result + + phone_numbers.forEach((phone: string) => { + const validatedPhone = phone.match(/[0-9]{0,14}/g) + if (validatedPhone === null) { + throw new Error(`${phone} is not a valid E.164 phone number.`) + } + // Remove spaces and non-digits; append + to the beginning + const formattedPhone = `+${phone.replace(/[^0-9]/g, '')}` + // Limit length to 15 characters + result.push(hashAndEncode(formattedPhone.substring(0, 15))) + }) + + return result +} + +/** + * + * @param userId + * @returns Leading/Trailing spaces are trimmed and then userId is hashed. + */ +export function formatUserIds(userIds: string[] | undefined): string[] { + const result: string[] = [] + if (userIds) { + userIds.forEach((userId: string) => { + result.push(hashAndEncode(userId.toLowerCase())) + }) + } + return result +} + +function hashAndEncode(property: string) { + return createHash('sha256').update(property).digest('hex') +} diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/generated-types.ts new file mode 100644 index 0000000000..17ab755d83 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your TikTok Access Token. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1701890979375106) for information on how to generate an access token via the TikTok Ads Manager or API. + */ + accessToken: string + /** + * Your TikTok Pixel ID. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1701890979375106) for information on how to find this value. + */ + pixelCode: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/index.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/index.ts new file mode 100644 index 0000000000..7b2a7f9932 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/index.ts @@ -0,0 +1,257 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import { defaultValues } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import reportWebEvent from './reportWebEvent' + +const productProperties = { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } +} + +const singleProductContents = { + ...defaultValues(reportWebEvent.fields), + contents: { + '@arrayPath': [ + '$.properties', + { + ...productProperties + } + ] + } +} + +const multiProductContents = { + ...defaultValues(reportWebEvent.fields), + contents: { + '@arrayPath': [ + '$.properties.products', + { + ...productProperties + } + ] + } +} + +const destination: DestinationDefinition = { + // Need to leave this Destination Name as "Tiktok" since it was registered with a lower case t. + // The name here needs to match the value at creation time. + // In Partner Portal, the name is changed to "TikTok" so it is spelled correctly in the catalog. + name: 'Tiktok Conversions Sandbox', + slug: 'tiktok-conversions-sandbox', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + accessToken: { + label: 'Access Token', + description: + 'Your TikTok Access Token. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1701890979375106) for information on how to generate an access token via the TikTok Ads Manager or API.', + type: 'string', + required: true + }, + pixelCode: { + label: 'Pixel Code', + type: 'string', + description: + 'Your TikTok Pixel ID. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1701890979375106) for information on how to find this value.', + required: true + } + }, + testAuthentication: (request, { settings }) => { + // Return a request that tests/validates the user's credentials. + // Send a blank event to events API. + return request('https://business-api.tiktok.com/open_api/v1.3/pixel/track/', { + method: 'post', + json: { + pixel_code: settings.pixelCode, + event: 'Test Event', + timestamp: '', + context: {} + } + }) + } + }, + extendRequest({ settings }) { + return { + headers: { + 'Access-Token': settings.accessToken, + 'Content-Type': 'application/json' + } + } + }, + presets: [ + { + name: 'Complete Payment', + subscribe: 'event = "Order Completed"', + partnerAction: 'reportWebEvent', + mapping: { + ...multiProductContents, + event: 'CompletePayment' + }, + type: 'automatic' + }, + { + name: 'Contact', + subscribe: 'event = "Callback Started"', + partnerAction: 'reportWebEvent', + mapping: { + ...defaultValues(reportWebEvent.fields), + event: 'Contact' + }, + type: 'automatic' + }, + { + name: 'Subscribe', + subscribe: 'event = "Subscription Created"', + partnerAction: 'reportWebEvent', + mapping: { + ...defaultValues(reportWebEvent.fields), + event: 'Subscribe' + }, + type: 'automatic' + }, + { + name: 'Submit Form', + subscribe: 'event = "Form Submitted"', + partnerAction: 'reportWebEvent', + mapping: { + ...defaultValues(reportWebEvent.fields), + event: 'SubmitForm' + }, + type: 'automatic' + }, + { + name: 'Page View', // is it ok to change preset name that is used by live version? + subscribe: 'type="page"', + partnerAction: 'reportWebEvent', + mapping: { + ...multiProductContents, + event: 'PageView' + }, + type: 'automatic' + }, + { + name: 'View Content', + subscribe: 'event = "Product Viewed"', + partnerAction: 'reportWebEvent', + mapping: { + ...singleProductContents, + event: 'ViewContent' + }, + type: 'automatic' + }, + { + name: 'Click Button', + subscribe: 'event = "Product Clicked"', + partnerAction: 'reportWebEvent', + mapping: { + ...singleProductContents, + event: 'ClickButton' + }, + type: 'automatic' + }, + { + name: 'Search', + subscribe: 'event = "Products Searched"', + partnerAction: 'reportWebEvent', + mapping: { + ...singleProductContents, + event: 'Search' + }, + type: 'automatic' + }, + { + name: 'Add to Wishlist', + subscribe: 'event = "Product Added to Wishlist"', + partnerAction: 'reportWebEvent', + mapping: { + ...singleProductContents, + event: 'AddToWishlist' + }, + type: 'automatic' + }, + { + name: 'Add to Cart', + subscribe: 'event = "Product Added"', + partnerAction: 'reportWebEvent', + mapping: { + ...singleProductContents, + event: 'AddToCart' + }, + type: 'automatic' + }, + { + name: 'Initiate Checkout', + subscribe: 'event = "Checkout Started"', + partnerAction: 'reportWebEvent', + mapping: { + ...multiProductContents, + event: 'InitiateCheckout' + }, + type: 'automatic' + }, + { + name: 'Add Payment Info', + subscribe: 'event = "Payment Info Entered"', + partnerAction: 'reportWebEvent', + mapping: { + ...multiProductContents, + event: 'AddPaymentInfo' + }, + type: 'automatic' + }, + { + name: 'Place an Order', + subscribe: 'event = "Order Placed"', + partnerAction: 'reportWebEvent', + mapping: { + ...multiProductContents, + event: 'PlaceAnOrder' + }, + type: 'automatic' + }, + { + name: 'Download', + subscribe: 'event = "Download Link Clicked"', + partnerAction: 'reportWebEvent', + mapping: { + ...defaultValues(reportWebEvent.fields), + event: 'Download' + }, + type: 'automatic' + }, + { + name: 'Complete Registration', + subscribe: 'event = "Signed Up"', + partnerAction: 'reportWebEvent', + mapping: { + ...defaultValues(reportWebEvent.fields), + event: 'CompleteRegistration' + }, + type: 'automatic' + } + ], + actions: { + reportWebEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/reportWebEvent/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/reportWebEvent/generated-types.ts new file mode 100644 index 0000000000..d4ef45bdae --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/reportWebEvent/generated-types.ts @@ -0,0 +1,125 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Conversion event name. Please refer to the "Supported Web Events" section on in TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1701890979375106) for accepted event names. + */ + event: string + /** + * Any hashed ID that can identify a unique user/session. + */ + event_id?: string + /** + * Timestamp that the event took place, in ISO 8601 format. + */ + timestamp?: string + /** + * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. e.g. +14150000000. Segment will hash this value before sending to TikTok. + */ + phone_number?: string[] + /** + * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. + */ + email?: string[] + /** + * Order ID of the transaction. + */ + order_id?: string + /** + * Shop ID of the transaction. + */ + shop_id?: string + /** + * Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Conversions Destination supports both string and string[] types for sending external ID(s). + */ + external_id?: string[] + /** + * The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details. + */ + ttclid?: string + /** + * TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`). + */ + ttp?: string + /** + * ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability + */ + lead_id?: string + /** + * The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). + */ + locale?: string + /** + * The page URL where the conversion event took place. + */ + url?: string + /** + * The page referrer. + */ + referrer?: string + /** + * IP address of the browser. + */ + ip?: string + /** + * User agent from the user’s device. + */ + user_agent?: string + /** + * Related item details for the event. + */ + contents?: { + /** + * Price of the item. + */ + price?: number + /** + * Number of items. + */ + quantity?: number + /** + * Category of the product item. + */ + content_category?: string + /** + * ID of the product item. + */ + content_id?: string + /** + * Name of the product item. + */ + content_name?: string + /** + * Brand name of the product item. + */ + brand?: string + }[] + /** + * Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`. + */ + content_type?: string + /** + * Currency for the value specified as ISO 4217 code. + */ + currency?: string + /** + * Value of the order or items sold. + */ + value?: number + /** + * A string description of the web event. + */ + description?: string + /** + * The text string that was searched for. + */ + query?: string + /** + * Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970). + */ + limited_data_use?: boolean + /** + * Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You'll want to remove your Test Event Code when sending real traffic through this integration. + */ + test_event_code?: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/reportWebEvent/index.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/reportWebEvent/index.ts new file mode 100644 index 0000000000..28ea5bcb71 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/reportWebEvent/index.ts @@ -0,0 +1,19 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common_fields' +import { performWebEvent } from '../utils' + +const action: ActionDefinition = { + title: 'Report Web Event', + description: + 'Report Web events directly to TikTok. Data shared can power TikTok solutions like dynamic product ads, custom targeting, campaign optimization and attribution.', + fields: { + ...commonFields + }, + perform: (request, { payload, settings }) => { + return performWebEvent(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/utils.ts b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/utils.ts new file mode 100644 index 0000000000..c4d5f574cd --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-conversions-sandbox/utils.ts @@ -0,0 +1,66 @@ +import { RequestClient } from '@segment/actions-core' +import { Settings } from './generated-types' +import { Payload } from './reportWebEvent/generated-types' +import { formatEmails, formatPhones, formatUserIds } from './formatter' + +export function performWebEvent(request: RequestClient, settings: Settings, payload: Payload) { + const phone_numbers = formatPhones(payload.phone_number) + const emails = formatEmails(payload.email) + const userIds = formatUserIds(payload.external_id) + + let payloadUrl, urlTtclid + if (payload.url) { + try { + payloadUrl = new URL(payload.url) + } catch (error) { + // invalid url + } + } + + if (payloadUrl) urlTtclid = payloadUrl.searchParams.get('ttclid') + + return request('https://business-api.tiktok.com/open_api/v1.3/event/track/', { + method: 'post', + json: { + event_source: 'web', + event_source_id: settings.pixelCode, + partner_name: 'Segment', + data: [ + { + event: payload.event, + event_time: payload.timestamp + ? Math.floor(new Date(payload.timestamp).getTime() / 1000) + : Math.floor(new Date().getTime() / 1000), + event_id: payload.event_id ? `${payload.event_id}` : undefined, + user: { + ttclid: payload.ttclid ? payload.ttclid : urlTtclid ? urlTtclid : undefined, + external_id: userIds, + phone: phone_numbers, + email: emails, + lead_id: payload.lead_id ? payload.lead_id : undefined, + ttp: payload.ttp ? payload.ttp : undefined, + ip: payload.ip ? payload.ip : undefined, + user_agent: payload.user_agent ? payload.user_agent : undefined, + locale: payload.locale ? payload.locale : undefined + }, + properties: { + contents: payload.contents ? payload.contents : [], + content_type: payload.content_type ? payload.content_type : undefined, + currency: payload.currency ? payload.currency : undefined, + value: payload.value ? payload.value : undefined, + query: payload.query ? payload.query : undefined, + description: payload.description ? payload.description : undefined, + order_id: payload.order_id ? payload.order_id : undefined, + shop_id: payload.shop_id ? payload.shop_id : undefined + }, + page: { + url: payload.url ? payload.url : undefined, + referrer: payload.referrer ? payload.referrer : undefined + }, + limited_data_use: payload.limited_data_use ? payload.limited_data_use : false, + test_event_code: payload.test_event_code ? payload.test_event_code : undefined + } + ] + } + }) +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..cbdd8d1172 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,541 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - all fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "ncANLMBwQ]L^fN", + "url": "ncANLMBwQ]L^fN", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "ncANLMBwQ]L^fN", + "content_category": "ncANLMBwQ]L^fN", + "content_id": "ncANLMBwQ]L^fN", + "content_name": "ncANLMBwQ]L^fN", + "price": 16868157612359.68, + "quantity": 16868157612359.68, + }, + ], + "currency": "SPL", + "description": "ncANLMBwQ]L^fN", + "order_id": "ncANLMBwQ]L^fN", + "query": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + "value": 16868157612359.68, + }, + "test_event_code": "ncANLMBwQ]L^fN", + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "ip": "ncANLMBwQ]L^fN", + "lead_id": "ncANLMBwQ]L^fN", + "locale": "ncANLMBwQ]L^fN", + "phone": Array [ + "a318c24216defe206feeb73ef5be00033fa9c4a74d0b967f6532a26ca5906d3b", + ], + "ttclid": "ncANLMBwQ]L^fN", + "ttp": "ncANLMBwQ]L^fN", + "user_agent": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - all fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "ncANLMBwQ]L^fN", + "url": "ncANLMBwQ]L^fN", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "ncANLMBwQ]L^fN", + "content_category": "ncANLMBwQ]L^fN", + "content_id": "ncANLMBwQ]L^fN", + "content_name": "ncANLMBwQ]L^fN", + "price": 16868157612359.68, + "quantity": 16868157612359.68, + }, + ], + "currency": "SPL", + "description": "ncANLMBwQ]L^fN", + "order_id": "ncANLMBwQ]L^fN", + "query": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + "value": 16868157612359.68, + }, + "test_event_code": "ncANLMBwQ]L^fN", + "user": Object { + "email": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "ip": "ncANLMBwQ]L^fN", + "lead_id": "ncANLMBwQ]L^fN", + "locale": "ncANLMBwQ]L^fN", + "phone": Array [ + "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", + ], + "ttclid": "ncANLMBwQ]L^fN", + "ttp": "ncANLMBwQ]L^fN", + "user_agent": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - required fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + }, + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "lead_id": "ncANLMBwQ]L^fN", + "phone": Array [], + "ttclid": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - required fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + }, + "user": Object { + "email": Array [], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "lead_id": "ncANLMBwQ]L^fN", + "phone": Array [ + "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", + ], + "ttclid": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - all fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "fQ92^RLkQyhJ8TU3nYnh", + "url": "fQ92^RLkQyhJ8TU3nYnh", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "fQ92^RLkQyhJ8TU3nYnh", + "content_category": "fQ92^RLkQyhJ8TU3nYnh", + "content_id": "fQ92^RLkQyhJ8TU3nYnh", + "content_name": "fQ92^RLkQyhJ8TU3nYnh", + "price": 80779872997212.16, + "quantity": 80779872997212.16, + }, + ], + "currency": "FKP", + "description": "fQ92^RLkQyhJ8TU3nYnh", + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "query": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + "value": 80779872997212.16, + }, + "test_event_code": "fQ92^RLkQyhJ8TU3nYnh", + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "ip": "fQ92^RLkQyhJ8TU3nYnh", + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "locale": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [ + "f337ffab71e9bf94c3cb7811bd7d6d7a3bee15022d27b5726b24224fc24e01a0", + ], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + "ttp": "fQ92^RLkQyhJ8TU3nYnh", + "user_agent": "fQ92^RLkQyhJ8TU3nYnh", + }, + }, + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - all fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "fQ92^RLkQyhJ8TU3nYnh", + "url": "fQ92^RLkQyhJ8TU3nYnh", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "fQ92^RLkQyhJ8TU3nYnh", + "content_category": "fQ92^RLkQyhJ8TU3nYnh", + "content_id": "fQ92^RLkQyhJ8TU3nYnh", + "content_name": "fQ92^RLkQyhJ8TU3nYnh", + "price": 80779872997212.16, + "quantity": 80779872997212.16, + }, + ], + "currency": "FKP", + "description": "fQ92^RLkQyhJ8TU3nYnh", + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "query": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + "value": 80779872997212.16, + }, + "test_event_code": "fQ92^RLkQyhJ8TU3nYnh", + "user": Object { + "email": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "ip": "fQ92^RLkQyhJ8TU3nYnh", + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "locale": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [ + "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", + ], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + "ttp": "fQ92^RLkQyhJ8TU3nYnh", + "user_agent": "fQ92^RLkQyhJ8TU3nYnh", + }, + }, + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - required fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + }, + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + }, + }, + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - required fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + }, + "user": Object { + "email": Array [], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [ + "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", + ], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + }, + }, + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - all fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": true, + "page": Object { + "referrer": "BkRZ5", + "url": "BkRZ5", + }, + "properties": Object { + "content_type": "product", + "contents": Array [ + Object { + "brand": "BkRZ5", + "content_category": "BkRZ5", + "content_id": "BkRZ5", + "content_name": "BkRZ5", + "price": -79287355210465.28, + "quantity": -79287355210465.28, + }, + ], + "currency": "DZD", + "description": "BkRZ5", + "order_id": "BkRZ5", + "query": "BkRZ5", + "shop_id": "BkRZ5", + "value": -79287355210465.28, + }, + "test_event_code": "BkRZ5", + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "ip": "BkRZ5", + "lead_id": "BkRZ5", + "locale": "BkRZ5", + "phone": Array [ + "03dec1b6379a35cbe10edb6ca30cf987b00116202c3825dece1d98c7a0718a09", + ], + "ttclid": "BkRZ5", + "ttp": "BkRZ5", + "user_agent": "BkRZ5", + }, + }, + ], + "event_source": "offline", + "event_source_id": "BkRZ5", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - all fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": true, + "page": Object { + "referrer": "BkRZ5", + "url": "BkRZ5", + }, + "properties": Object { + "content_type": "product", + "contents": Array [ + Object { + "brand": "BkRZ5", + "content_category": "BkRZ5", + "content_id": "BkRZ5", + "content_name": "BkRZ5", + "price": -79287355210465.28, + "quantity": -79287355210465.28, + }, + ], + "currency": "DZD", + "description": "BkRZ5", + "order_id": "BkRZ5", + "query": "BkRZ5", + "shop_id": "BkRZ5", + "value": -79287355210465.28, + }, + "test_event_code": "BkRZ5", + "user": Object { + "email": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "ip": "BkRZ5", + "lead_id": "BkRZ5", + "locale": "BkRZ5", + "phone": Array [ + "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", + ], + "ttclid": "BkRZ5", + "ttp": "BkRZ5", + "user_agent": "BkRZ5", + }, + }, + ], + "event_source": "offline", + "event_source_id": "BkRZ5", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - required fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "BkRZ5", + "shop_id": "BkRZ5", + }, + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "lead_id": "BkRZ5", + "phone": Array [], + "ttclid": "BkRZ5", + }, + }, + ], + "event_source": "offline", + "event_source_id": "BkRZ5", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - required fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "BkRZ5", + "shop_id": "BkRZ5", + }, + "user": Object { + "email": Array [], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "lead_id": "BkRZ5", + "phone": Array [ + "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", + ], + "ttclid": "BkRZ5", + }, + }, + ], + "event_source": "offline", + "event_source_id": "BkRZ5", + "partner_name": "Segment", +} +`; diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/index.test.ts new file mode 100644 index 0000000000..038d410b2e --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/index.test.ts @@ -0,0 +1,356 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Definition from '../index' +import { Settings } from '../generated-types' + +const testDestination = createTestIntegration(Definition) +const timestamp = '2024-01-08T13:52:50.212Z' +const settings: Settings = { + accessToken: 'test-token', + eventSetID: 'test-event-set-id' +} + +describe('TikTok Offline Conversions', () => { + describe('testTrackNonPaymentOfflineConversion', () => { + it("should send a successful 'Contact' event to 'trackNonPaymentOfflineConversion'", async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'User Contacted Call Center', + messageId: 'test-message-id-contact', + type: 'track', + properties: { + email: ['testsegmentintegration1@tiktok.com', 'testsegmentintegration2@tiktok.com'], + phone: ['+1555-555-5555', '+1555-555-5556'], + ttclid: 'test-ttclid-contact', + order_id: 'test-order-id-contact', + shop_id: 'test-shop-id-contact', + event_channel: 'in_store' + }, + userId: 'testId123-contact' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) + + const responses = await testDestination.testAction('trackNonPaymentOfflineConversion', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'Contact' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + event_source: 'offline', + event_source_id: settings.eventSetID, + partner_name: 'Segment', + data: [ + { + event: 'Contact', + event_time: 1704721970, + event_id: 'test-message-id-contact', + user: { + ttclid: 'test-ttclid-contact', + external_id: ['f18c018187c833dc00fb68f0517a135356fd947df08b0d22eaa145f623edc13e'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [], + content_type: 'product', + currency: undefined, + value: undefined, + query: undefined, + description: undefined, + order_id: 'test-order-id-contact', + shop_id: 'test-shop-id-contact' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined + } + ] + }) + }) + + it("should send a successful 'Subscribe' event to 'trackNonPaymentOfflineConversion'", async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'User Subscribed In Store', + messageId: 'test-message-id-subscribe', + type: 'track', + properties: { + email: ['testsegmentintegration1@tiktok.com', 'testsegmentintegration2@tiktok.com'], + phone: ['+1555-555-5555', '+1555-555-5556'], + ttclid: 'test-ttclid-subscribe', + order_id: 'test-order-id-subscribe', + shop_id: 'test-shop-id-subscribe', + event_channel: 'in_store' + }, + userId: 'testId123-subscribe' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) + + const responses = await testDestination.testAction('trackNonPaymentOfflineConversion', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'Subscribe' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + event_source: 'offline', + event_source_id: settings.eventSetID, + partner_name: 'Segment', + data: [ + { + event: 'Subscribe', + event_time: 1704721970, + event_id: 'test-message-id-subscribe', + user: { + ttclid: 'test-ttclid-subscribe', + external_id: ['e3b83f59446a2f66722aa4947be585da59b37072dd76edfee189422417db5879'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [], + content_type: 'product', + currency: undefined, + value: undefined, + query: undefined, + description: undefined, + order_id: 'test-order-id-subscribe', + shop_id: 'test-shop-id-subscribe' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined + } + ] + }) + }) + + it("should send a successful 'SubmitForm' event to 'trackNonPaymentOfflineConversion'", async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Form Submitted', + messageId: 'test-message-id-submit-form', + type: 'track', + properties: { + email: ['testsegmentintegration1@tiktok.com', 'testsegmentintegration2@tiktok.com'], + phone: ['+1555-555-5555', '+1555-555-5556'], + order_id: 'test-order-id-submit-form', + shop_id: 'test-shop-id-submit-form', + event_channel: 'in_store' + }, + userId: 'testId123-submit-form' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) + + const responses = await testDestination.testAction('trackNonPaymentOfflineConversion', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'SubmitForm' + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + event_source: 'offline', + event_source_id: settings.eventSetID, + partner_name: 'Segment', + data: [ + { + event: 'SubmitForm', + event_time: 1704721970, + event_id: 'test-message-id-submit-form', + user: { + ttclid: undefined, + external_id: ['ad1d0a79ae249b682fa21961d26120ee17b89aec332fee649002cd387742bd97'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [], + content_type: 'product', + currency: undefined, + value: undefined, + query: undefined, + description: undefined, + order_id: 'test-order-id-submit-form', + shop_id: 'test-shop-id-submit-form' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined + } + ] + }) + }) + }) + + describe('testTrackPaymentOfflineConversion', () => { + it("should send a successful 'CompletePayment' event to 'trackPaymentOfflineConversion' from array of products", async () => { + const event = createTestEvent({ + timestamp: timestamp, + event: 'Order Completed', + messageId: 'test-message-id-complete-payment', + type: 'track', + properties: { + email: ['testsegmentintegration1@tiktok.com', 'testsegmentintegration2@tiktok.com'], + phone: ['+1555-555-5555', '+1555-555-5556'], + order_id: 'test-order-id-complete-payment', + shop_id: 'test-shop-id-complete-payment', + event_channel: 'in_store', + currency: 'USD', + value: 100, + query: 'shoes', + products: [{ price: 100, quantity: 2, category: 'Air Force One (Size S)', product_id: 'abc123' }] + }, + userId: 'testId123-complete-payment' + }) + + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) + + const responses = await testDestination.testAction('trackPaymentOfflineConversion', { + event, + settings, + useDefaultMappings: true, + mapping: { + event: 'CompletePayment', + contents: { + '@arrayPath': [ + '$.properties.products', + { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_type: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + } + } + ] + } + } + }) + + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + expect(responses[0].options.json).toMatchObject({ + event_source: 'offline', + event_source_id: settings.eventSetID, + partner_name: 'Segment', + data: [ + { + event: 'CompletePayment', + event_time: 1704721970, + event_id: 'test-message-id-complete-payment', + user: { + ttclid: undefined, + external_id: ['5da716ea2a24e8d05cea64167903ed983a273f897e3befc875cde15e9a8b5145'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [ + { + content_id: 'abc123', + price: 100, + quantity: 2 + } + ], + content_type: 'product', + currency: 'USD', + value: 100, + query: 'shoes', + description: undefined, + order_id: 'test-order-id-complete-payment', + shop_id: 'test-shop-id-complete-payment' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined + } + ] + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..56da594d42 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/__tests__/snapshot.test.ts @@ -0,0 +1,177 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-tiktok-offline-conversions' + +const timestamp = '2024-01-08T13:52:50.212Z' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields with email`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + timestamp: timestamp, + properties: { + ...eventData, + email: 'test@test.com' + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { + ...event.properties, + email_addresses: { '@path': 'properties.email' }, + timestamp: { '@path': 'timestamp' } + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - required fields with phone`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + timestamp: timestamp, + properties: { + ...eventData, + phone: '+353858764535' + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { + ...event.properties, + phone_numbers: { '@path': 'properties.phone' }, + timestamp: { '@path': 'timestamp' } + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields with email`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + timestamp: timestamp, + properties: { + ...eventData, + email: 'test@test.com' + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { + ...event.properties, + email_addresses: { '@path': 'properties.email' }, + timestamp: { '@path': 'timestamp' } + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + + it(`${actionSlug} action - all fields with phone`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + timestamp: timestamp, + properties: { + ...eventData, + phone: '+3538587346' + } + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: { + ...event.properties, + phone_numbers: { '@path': 'properties.phone' }, + timestamp: { '@path': 'timestamp' } + }, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/common_fields.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/common_fields.ts new file mode 100644 index 0000000000..4c946d0859 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/common_fields.ts @@ -0,0 +1,253 @@ +import { InputField } from '@segment/actions-core' + +export const commonFields: Record = { + event: { + label: 'Event Name', + type: 'string', + required: true, + description: + 'Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names.' + }, + event_id: { + label: 'Event ID', + type: 'string', + description: 'Any hashed ID that can identify a unique user/session.', + default: { + '@path': '$.messageId' + } + }, + timestamp: { + label: 'Event Timestamp', + type: 'string', + description: 'Timestamp that the event took place, in ISO 8601 format.', + default: { + '@path': '$.timestamp' + } + }, + phone_numbers: { + label: 'Phone Number', + description: + 'A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty.', + type: 'string', + multiple: true, + default: { + '@if': { + exists: { '@path': '$.properties.phone' }, + then: { '@path': '$.properties.phone' }, + else: { '@path': '$.context.traits.phone' } + } + } + }, + email_addresses: { + label: 'Email', + description: + 'A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty.', + type: 'string', + multiple: true, + default: { + '@if': { + exists: { '@path': '$.properties.email' }, + then: { '@path': '$.properties.email' }, + else: { '@path': '$.context.traits.email' } + } + } + }, + order_id: { + label: 'Order ID', + type: 'string', + description: 'Order ID of the transaction.', + default: { + '@path': '$.properties.order_id' + } + }, + shop_id: { + label: 'Shop ID', + type: 'string', + description: 'Shop ID of the transaction.', + default: { + '@path': '$.properties.shop_id' + } + }, + external_ids: { + label: 'External ID', + description: + 'Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty.', + type: 'string', + multiple: true, + default: { + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + ttclid: { + label: 'TikTok Click ID', + description: + 'The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.ttclid' }, + then: { '@path': '$.properties.ttclid' }, + else: { '@path': '$.integrations.TikTok Offline Conversions.ttclid' } + } + } + }, + ttp: { + label: 'TikTok Cookie ID', + description: + 'TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`).', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.ttp' }, + then: { '@path': '$.properties.ttp' }, + else: { '@path': '$.integrations.TikTok Offline Conversions.ttp' } + } + } + }, + lead_id: { + label: 'TikTok Lead ID', + description: + 'ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability', + type: 'string', + default: { '@path': '$.properties.lead_id' } + }, + locale: { + label: 'Locale', + description: + 'The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt).', + type: 'string', + default: { + '@path': '$.context.locale' + } + }, + url: { + label: 'Page URL', + type: 'string', + description: 'The page URL where the conversion event took place.', + default: { + '@path': '$.context.page.url' + } + }, + referrer: { + label: 'Page Referrer', + type: 'string', + description: 'The page referrer.', + default: { + '@path': '$.context.page.referrer' + } + }, + ip: { + label: 'IP Address', + type: 'string', + description: 'IP address of the browser.', + default: { + '@path': '$.context.ip' + } + }, + user_agent: { + label: 'User Agent', + type: 'string', + description: 'User agent from the user’s device.', + default: { + '@path': '$.context.userAgent' + } + }, + contents: { + label: 'Contents', + type: 'object', + multiple: true, + description: 'Related item details for the event.', + properties: { + price: { + label: 'Price', + description: 'Price of the item.', + type: 'number' + }, + quantity: { + label: 'Quantity', + description: 'Number of items.', + type: 'number' + }, + content_category: { + label: 'Content Category', + description: 'Category of the product item.', + type: 'string' + }, + content_id: { + label: 'Content ID', + description: 'ID of the product item.', + type: 'string' + }, + content_name: { + label: 'Content Name', + description: 'Name of the product item.', + type: 'string' + }, + brand: { + label: 'Brand', + description: 'Brand name of the product item.', + type: 'string' + } + } + }, + content_type: { + label: 'Content Type', + description: + 'Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`.', + type: 'string', + choices: [ { label: 'product', value: 'product' }, { label: 'product_group', value: 'product_group' }], + default: 'product' + }, + currency: { + label: 'Currency', + type: 'string', + description: 'Currency for the value specified as ISO 4217 code.', + default: { + '@path': '$.properties.currency' + } + }, + value: { + label: 'Value', + type: 'number', + description: 'Value of the order or items sold.', + default: { + '@if': { + exists: { '@path': '$.properties.value' }, + then: { '@path': '$.properties.value' }, + else: { '@path': '$.properties.revenue' } + } + } + }, + description: { + label: 'Description', + type: 'string', + description: 'A string description of the web event.' + }, + query: { + label: 'Query', + type: 'string', + description: 'The text string that was searched for.', + default: { + '@path': '$.properties.query' + } + }, + limited_data_use: { + label: 'Limited Data Use', + type: 'boolean', + description: + 'Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970).', + default: { + '@path': '$.properties.limited_data_use' + } + }, + test_event_code: { + label: 'Test Event Code', + type: 'string', + description: + 'Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You\'ll want to remove your Test Event Code when sending real traffic through this integration.' + } +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/formatter.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/formatter.ts new file mode 100644 index 0000000000..d3ef9351b3 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/formatter.ts @@ -0,0 +1,56 @@ +import { createHash } from 'crypto' + +/** + * Convert emails to lower case, and hash in SHA256. + */ +export const formatEmails = (email_addresses: string[] | undefined): string[] => { + const result: string[] = [] + if (email_addresses) { + email_addresses.forEach((email: string) => { + result.push(hashAndEncode(email.toLowerCase())) + }) + } + return result +} + +/** + * Convert string to match E.164 phone number pattern (e.g. +1234567890) + * Note it is up to the advertiser to pass only valid phone numbers and formats. + * This function assumes the input is a correctly formatted phone number maximum of 14 characters long with country code included in the input. + */ +export const formatPhones = (phone_numbers: string[] | undefined): string[] => { + const result: string[] = [] + if (!phone_numbers) return result + + phone_numbers.forEach((phone: string) => { + const validatedPhone = phone.match(/[0-9]{0,14}/g) + if (validatedPhone === null) { + throw new Error(`${phone} is not a valid E.164 phone number.`) + } + // Remove spaces and non-digits; append + to the beginning + const formattedPhone = `+${phone.replace(/[^0-9]/g, '')}` + // Limit length to 15 characters + result.push(hashAndEncode(formattedPhone.substring(0, 15))) + }) + + return result +} + +/** + * + * @param userId + * @returns Leading/Trailing spaces are trimmed and then userId is hashed. + */ +export function formatUserIds(userIds: string[] | undefined): string[] { + const result: string[] = [] + if (userIds) { + userIds.forEach((userId: string) => { + result.push(hashAndEncode(userId.toLowerCase())) + }) + } + return result +} + +function hashAndEncode(property: string) { + return createHash('sha256').update(property).digest('hex') +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/generated-types.ts new file mode 100644 index 0000000000..ddb194e54f --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/generated-types.ts @@ -0,0 +1,12 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your TikTok Access Token. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101130925058) for information on how to generate an access token via the TikTok Ads Manager or API. + */ + accessToken: string + /** + * Your TikTok Offline Event Set ID. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101027431425) for information on how to find this value. + */ + eventSetID: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/index.ts new file mode 100644 index 0000000000..4d0fd69670 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/index.ts @@ -0,0 +1,254 @@ +import { defaultValues, DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import trackPaymentOfflineConversion from './trackPaymentOfflineConversion' +import trackNonPaymentOfflineConversion from './trackNonPaymentOfflineConversion' +import reportOfflineEvent from './reportOfflineEvent' + +const productProperties = { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } +} + +const singleProductContents = { + ...defaultValues(reportOfflineEvent.fields), + contents: { + '@arrayPath': [ + '$.properties', + { + ...productProperties + } + ] + } +} + +const multiProductContents = { + ...defaultValues(reportOfflineEvent.fields), + contents: { + '@arrayPath': [ + '$.properties.products', + { + ...productProperties + } + ] + } +} + +const destination: DestinationDefinition = { + name: 'TikTok Offline Conversions Sandbox', + slug: 'actions-tiktok-offline-conversions-sandbox', + mode: 'cloud', + + authentication: { + scheme: 'custom', + fields: { + accessToken: { + label: 'Access Token', + description: + 'Your TikTok Access Token. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101130925058) for information on how to generate an access token via the TikTok Ads Manager or API.', + type: 'string', + required: true + }, + eventSetID: { + label: 'Event Set ID', + type: 'string', + description: + 'Your TikTok Offline Event Set ID. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101027431425) for information on how to find this value.', + required: true + } + }, + testAuthentication: (request, { settings }) => { + return request('https://business-api.tiktok.com/open_api/v1.3/offline/track/', { + method: 'post', + json: { + event_set_id: settings.eventSetID, + event: 'Test Event', + timestamp: '', + context: {} + } + }) + } + }, + extendRequest({ settings }) { + return { + headers: { + 'Access-Token': settings.accessToken, + 'Content-Type': 'application/json' + } + } + }, + presets: [ + { + name: 'Complete Payment', + subscribe: 'event = "Order Completed"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'CompletePayment' + }, + type: 'automatic' + }, + { + name: 'Contact', + subscribe: 'event = "Callback Started"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...defaultValues(reportOfflineEvent.fields), + event: 'Contact' + }, + type: 'automatic' + }, + { + name: 'Subscribe', + subscribe: 'event = "Subscription Created"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...defaultValues(reportOfflineEvent.fields), + event: 'Subscribe' + }, + type: 'automatic' + }, + { + name: 'Submit Form', + subscribe: 'event = "Form Submitted"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...defaultValues(reportOfflineEvent.fields), + event: 'SubmitForm' + }, + type: 'automatic' + }, + { + name: 'Page View', + subscribe: 'type="page"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'PageView' + }, + type: 'automatic' + }, + { + name: 'View Content', + subscribe: 'event = "Product Viewed"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'ViewContent' + }, + type: 'automatic' + }, + { + name: 'Click Button', + subscribe: 'event = "Product Clicked"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'ClickButton' + }, + type: 'automatic' + }, + { + name: 'Search', + subscribe: 'event = "Products Searched"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'Search' + }, + type: 'automatic' + }, + { + name: 'Add to Wishlist', + subscribe: 'event = "Product Added to Wishlist"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'AddToWishlist' + }, + type: 'automatic' + }, + { + name: 'Add to Cart', + subscribe: 'event = "Product Added"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'AddToCart' + }, + type: 'automatic' + }, + { + name: 'Initiate Checkout', + subscribe: 'event = "Checkout Started"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'InitiateCheckout' + }, + type: 'automatic' + }, + { + name: 'Add Payment Info', + subscribe: 'event = "Payment Info Entered"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'AddPaymentInfo' + }, + type: 'automatic' + }, + { + name: 'Place an Order', + subscribe: 'event = "Order Placed"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'PlaceAnOrder' + }, + type: 'automatic' + }, + { + name: 'Download', + subscribe: 'event = "Download Link Clicked"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...defaultValues(reportOfflineEvent.fields), + event: 'Download' + }, + type: 'automatic' + }, + { + name: 'Complete Registration', + subscribe: 'event = "Signed Up"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...defaultValues(reportOfflineEvent.fields), + event: 'CompleteRegistration' + }, + type: 'automatic' + } + ], + actions: { + trackPaymentOfflineConversion, + trackNonPaymentOfflineConversion, + reportOfflineEvent + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/reportOfflineEvent/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/reportOfflineEvent/generated-types.ts new file mode 100644 index 0000000000..ac9476c6ac --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/reportOfflineEvent/generated-types.ts @@ -0,0 +1,125 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names. + */ + event: string + /** + * Any hashed ID that can identify a unique user/session. + */ + event_id?: string + /** + * Timestamp that the event took place, in ISO 8601 format. + */ + timestamp?: string + /** + * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty. + */ + phone_numbers?: string[] + /** + * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty. + */ + email_addresses?: string[] + /** + * Order ID of the transaction. + */ + order_id?: string + /** + * Shop ID of the transaction. + */ + shop_id?: string + /** + * Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty. + */ + external_ids?: string[] + /** + * The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details. + */ + ttclid?: string + /** + * TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`). + */ + ttp?: string + /** + * ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability + */ + lead_id?: string + /** + * The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). + */ + locale?: string + /** + * The page URL where the conversion event took place. + */ + url?: string + /** + * The page referrer. + */ + referrer?: string + /** + * IP address of the browser. + */ + ip?: string + /** + * User agent from the user’s device. + */ + user_agent?: string + /** + * Related item details for the event. + */ + contents?: { + /** + * Price of the item. + */ + price?: number + /** + * Number of items. + */ + quantity?: number + /** + * Category of the product item. + */ + content_category?: string + /** + * ID of the product item. + */ + content_id?: string + /** + * Name of the product item. + */ + content_name?: string + /** + * Brand name of the product item. + */ + brand?: string + }[] + /** + * Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`. + */ + content_type?: string + /** + * Currency for the value specified as ISO 4217 code. + */ + currency?: string + /** + * Value of the order or items sold. + */ + value?: number + /** + * A string description of the web event. + */ + description?: string + /** + * The text string that was searched for. + */ + query?: string + /** + * Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970). + */ + limited_data_use?: boolean + /** + * Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You'll want to remove your Test Event Code when sending real traffic through this integration. + */ + test_event_code?: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/reportOfflineEvent/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/reportOfflineEvent/index.ts new file mode 100644 index 0000000000..5a02119ef7 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/reportOfflineEvent/index.ts @@ -0,0 +1,18 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common_fields' +import { performOfflineEvent } from '../utils' + +const action: ActionDefinition = { + title: 'Track Offline Conversion', + description: 'Send details of an in-store purchase or console purchase to the Tiktok Offline Events API', + fields: { + ...commonFields + }, + perform: (request, { payload, settings }) => { + return performOfflineEvent(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackNonPaymentOfflineConversion/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackNonPaymentOfflineConversion/generated-types.ts new file mode 100644 index 0000000000..ac9476c6ac --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackNonPaymentOfflineConversion/generated-types.ts @@ -0,0 +1,125 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names. + */ + event: string + /** + * Any hashed ID that can identify a unique user/session. + */ + event_id?: string + /** + * Timestamp that the event took place, in ISO 8601 format. + */ + timestamp?: string + /** + * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty. + */ + phone_numbers?: string[] + /** + * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty. + */ + email_addresses?: string[] + /** + * Order ID of the transaction. + */ + order_id?: string + /** + * Shop ID of the transaction. + */ + shop_id?: string + /** + * Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty. + */ + external_ids?: string[] + /** + * The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details. + */ + ttclid?: string + /** + * TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`). + */ + ttp?: string + /** + * ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability + */ + lead_id?: string + /** + * The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). + */ + locale?: string + /** + * The page URL where the conversion event took place. + */ + url?: string + /** + * The page referrer. + */ + referrer?: string + /** + * IP address of the browser. + */ + ip?: string + /** + * User agent from the user’s device. + */ + user_agent?: string + /** + * Related item details for the event. + */ + contents?: { + /** + * Price of the item. + */ + price?: number + /** + * Number of items. + */ + quantity?: number + /** + * Category of the product item. + */ + content_category?: string + /** + * ID of the product item. + */ + content_id?: string + /** + * Name of the product item. + */ + content_name?: string + /** + * Brand name of the product item. + */ + brand?: string + }[] + /** + * Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`. + */ + content_type?: string + /** + * Currency for the value specified as ISO 4217 code. + */ + currency?: string + /** + * Value of the order or items sold. + */ + value?: number + /** + * A string description of the web event. + */ + description?: string + /** + * The text string that was searched for. + */ + query?: string + /** + * Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970). + */ + limited_data_use?: boolean + /** + * Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You'll want to remove your Test Event Code when sending real traffic through this integration. + */ + test_event_code?: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackNonPaymentOfflineConversion/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackNonPaymentOfflineConversion/index.ts new file mode 100644 index 0000000000..61293f749f --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackNonPaymentOfflineConversion/index.ts @@ -0,0 +1,19 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common_fields' +import { performOfflineEvent } from '../utils' + +const action: ActionDefinition = { + title: '[Deprecated] Track Non Payment Offline Conversion', + description: + "[Deprecated] Send a non payment related event to the TikTok Offline Conversions API. This Action has been Deprecated. Please use the 'Track Payment Offline Conversion' Action instead", + fields: { + ...commonFields + }, + perform: (request, { payload, settings }) => { + return performOfflineEvent(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackPaymentOfflineConversion/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackPaymentOfflineConversion/generated-types.ts new file mode 100644 index 0000000000..ac9476c6ac --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackPaymentOfflineConversion/generated-types.ts @@ -0,0 +1,125 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names. + */ + event: string + /** + * Any hashed ID that can identify a unique user/session. + */ + event_id?: string + /** + * Timestamp that the event took place, in ISO 8601 format. + */ + timestamp?: string + /** + * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty. + */ + phone_numbers?: string[] + /** + * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty. + */ + email_addresses?: string[] + /** + * Order ID of the transaction. + */ + order_id?: string + /** + * Shop ID of the transaction. + */ + shop_id?: string + /** + * Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty. + */ + external_ids?: string[] + /** + * The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details. + */ + ttclid?: string + /** + * TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`). + */ + ttp?: string + /** + * ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability + */ + lead_id?: string + /** + * The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). + */ + locale?: string + /** + * The page URL where the conversion event took place. + */ + url?: string + /** + * The page referrer. + */ + referrer?: string + /** + * IP address of the browser. + */ + ip?: string + /** + * User agent from the user’s device. + */ + user_agent?: string + /** + * Related item details for the event. + */ + contents?: { + /** + * Price of the item. + */ + price?: number + /** + * Number of items. + */ + quantity?: number + /** + * Category of the product item. + */ + content_category?: string + /** + * ID of the product item. + */ + content_id?: string + /** + * Name of the product item. + */ + content_name?: string + /** + * Brand name of the product item. + */ + brand?: string + }[] + /** + * Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`. + */ + content_type?: string + /** + * Currency for the value specified as ISO 4217 code. + */ + currency?: string + /** + * Value of the order or items sold. + */ + value?: number + /** + * A string description of the web event. + */ + description?: string + /** + * The text string that was searched for. + */ + query?: string + /** + * Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970). + */ + limited_data_use?: boolean + /** + * Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You'll want to remove your Test Event Code when sending real traffic through this integration. + */ + test_event_code?: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackPaymentOfflineConversion/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackPaymentOfflineConversion/index.ts new file mode 100644 index 0000000000..5d6fbb9d76 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/trackPaymentOfflineConversion/index.ts @@ -0,0 +1,19 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common_fields' +import { performOfflineEvent } from '../utils' + +const action: ActionDefinition = { + title: '[Deprecated] Track Payment Offline Conversion', + description: + "[Deprecated] Send details of an in-store purchase or console purchase to the Tiktok Offline Events API. This Action has been Deprecated. Please use the 'Track Payment Offline Conversion' Action instead", + fields: { + ...commonFields + }, + perform: (request, { payload, settings }) => { + return performOfflineEvent(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/utils.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/utils.ts new file mode 100644 index 0000000000..55dc000e5c --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions-sandbox/utils.ts @@ -0,0 +1,78 @@ +import { RequestClient, PayloadValidationError } from '@segment/actions-core' +import { Settings } from './generated-types' +import { Payload as ReportOfflineEventPayload } from './reportOfflineEvent/generated-types' +import { Payload as TrackNonPaymentOfflineConversionPayload } from './trackNonPaymentOfflineConversion/generated-types' +import { Payload as TrackPaymentOfflineConversionPayload } from './trackPaymentOfflineConversion/generated-types' +import { formatEmails, formatPhones, formatUserIds } from './formatter' + +type OfflineEventPayload = + | ReportOfflineEventPayload + | TrackNonPaymentOfflineConversionPayload + | TrackPaymentOfflineConversionPayload + +export function performOfflineEvent(request: RequestClient, settings: Settings, payload: OfflineEventPayload) { + const phone_numbers = formatPhones(payload.phone_numbers) + const emails = formatEmails(payload.email_addresses) + const userIds = formatUserIds(payload.external_ids) + + if (phone_numbers.length < 1 && emails.length < 1 && userIds.length < 1) + throw new PayloadValidationError( + 'TikTok Offline Conversions API requires an email address and/or phone number and or a userId' + ) + + let payloadUrl, urlTtclid + if (payload.url) { + try { + payloadUrl = new URL(payload.url) + } catch (error) { + // invalid url + } + } + + if (payloadUrl) urlTtclid = payloadUrl.searchParams.get('ttclid') + + return request('https://business-api.tiktok.com/open_api/v1.3/event/track/', { + method: 'post', + json: { + event_source: 'offline', + event_source_id: settings.eventSetID, + partner_name: 'Segment', + data: [ + { + event: payload.event, + event_time: payload.timestamp + ? Math.floor(new Date(payload.timestamp).getTime() / 1000) + : Math.floor(new Date().getTime() / 1000), + event_id: payload.event_id ? `${payload.event_id}` : undefined, + user: { + ttclid: payload.ttclid ? payload.ttclid : urlTtclid ? urlTtclid : undefined, + external_id: userIds, + phone: phone_numbers, + email: emails, + lead_id: payload.lead_id ? payload.lead_id : undefined, + ttp: payload.ttp ? payload.ttp : undefined, + ip: payload.ip ? payload.ip : undefined, + user_agent: payload.user_agent ? payload.user_agent : undefined, + locale: payload.locale ? payload.locale : undefined + }, + properties: { + contents: payload.contents ? payload.contents : [], + content_type: payload.content_type ? payload.content_type : undefined, + currency: payload.currency ? payload.currency : undefined, + value: payload.value ? payload.value : undefined, + query: payload.query ? payload.query : undefined, + description: payload.description ? payload.description : undefined, + order_id: payload.order_id ? payload.order_id : undefined, + shop_id: payload.shop_id ? payload.shop_id : undefined + }, + page: { + url: payload.url ? payload.url : undefined, + referrer: payload.referrer ? payload.referrer : undefined + }, + limited_data_use: payload.limited_data_use ? payload.limited_data_use : false, + test_event_code: payload.test_event_code ? payload.test_event_code : undefined + } + ] + } + }) +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/__snapshots__/snapshot.test.ts.snap index c47797d597..cbdd8d1172 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/__snapshots__/snapshot.test.ts.snap +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/__snapshots__/snapshot.test.ts.snap @@ -1,215 +1,541 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - all fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "ncANLMBwQ]L^fN", + "url": "ncANLMBwQ]L^fN", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "ncANLMBwQ]L^fN", + "content_category": "ncANLMBwQ]L^fN", + "content_id": "ncANLMBwQ]L^fN", + "content_name": "ncANLMBwQ]L^fN", + "price": 16868157612359.68, + "quantity": 16868157612359.68, + }, + ], + "currency": "SPL", + "description": "ncANLMBwQ]L^fN", + "order_id": "ncANLMBwQ]L^fN", + "query": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + "value": 16868157612359.68, + }, + "test_event_code": "ncANLMBwQ]L^fN", + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "ip": "ncANLMBwQ]L^fN", + "lead_id": "ncANLMBwQ]L^fN", + "locale": "ncANLMBwQ]L^fN", + "phone": Array [ + "a318c24216defe206feeb73ef5be00033fa9c4a74d0b967f6532a26ca5906d3b", + ], + "ttclid": "ncANLMBwQ]L^fN", + "ttp": "ncANLMBwQ]L^fN", + "user_agent": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - all fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "ncANLMBwQ]L^fN", + "url": "ncANLMBwQ]L^fN", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "ncANLMBwQ]L^fN", + "content_category": "ncANLMBwQ]L^fN", + "content_id": "ncANLMBwQ]L^fN", + "content_name": "ncANLMBwQ]L^fN", + "price": 16868157612359.68, + "quantity": 16868157612359.68, + }, + ], + "currency": "SPL", + "description": "ncANLMBwQ]L^fN", + "order_id": "ncANLMBwQ]L^fN", + "query": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + "value": 16868157612359.68, + }, + "test_event_code": "ncANLMBwQ]L^fN", + "user": Object { + "email": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "ip": "ncANLMBwQ]L^fN", + "lead_id": "ncANLMBwQ]L^fN", + "locale": "ncANLMBwQ]L^fN", + "phone": Array [ + "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", + ], + "ttclid": "ncANLMBwQ]L^fN", + "ttp": "ncANLMBwQ]L^fN", + "user_agent": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - required fields with email 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + }, + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "lead_id": "ncANLMBwQ]L^fN", + "phone": Array [], + "ttclid": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + +exports[`Testing snapshot for actions-tiktok-offline-conversions destination: reportOfflineEvent action - required fields with phone 1`] = ` +Object { + "data": Array [ + Object { + "event": "ncANLMBwQ]L^fN", + "event_id": "ncANLMBwQ]L^fN", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "ncANLMBwQ]L^fN", + "shop_id": "ncANLMBwQ]L^fN", + }, + "user": Object { + "email": Array [], + "external_id": Array [ + "4aa8d801e7341adb0680c77e8a060d196bbe076f4cab73450791150061f267c1", + ], + "lead_id": "ncANLMBwQ]L^fN", + "phone": Array [ + "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", + ], + "ttclid": "ncANLMBwQ]L^fN", + }, + }, + ], + "event_source": "offline", + "event_source_id": "ncANLMBwQ]L^fN", + "partner_name": "Segment", +} +`; + exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - all fields with email 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [ - "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", - ], - "phone_numbers": Array [ - "f337ffab71e9bf94c3cb7811bd7d6d7a3bee15022d27b5726b24224fc24e01a0", - ], + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "fQ92^RLkQyhJ8TU3nYnh", + "url": "fQ92^RLkQyhJ8TU3nYnh", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "fQ92^RLkQyhJ8TU3nYnh", + "content_category": "fQ92^RLkQyhJ8TU3nYnh", + "content_id": "fQ92^RLkQyhJ8TU3nYnh", + "content_name": "fQ92^RLkQyhJ8TU3nYnh", + "price": 80779872997212.16, + "quantity": 80779872997212.16, + }, + ], + "currency": "FKP", + "description": "fQ92^RLkQyhJ8TU3nYnh", + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "query": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + "value": 80779872997212.16, + }, + "test_event_code": "fQ92^RLkQyhJ8TU3nYnh", + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "ip": "fQ92^RLkQyhJ8TU3nYnh", + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "locale": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [ + "f337ffab71e9bf94c3cb7811bd7d6d7a3bee15022d27b5726b24224fc24e01a0", + ], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + "ttp": "fQ92^RLkQyhJ8TU3nYnh", + "user_agent": "fQ92^RLkQyhJ8TU3nYnh", + }, }, - }, - "event": "fQ92^RLkQyhJ8TU3nYnh", - "event_id": "fQ92^RLkQyhJ8TU3nYnh", - "event_set_id": "fQ92^RLkQyhJ8TU3nYnh", + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", "partner_name": "Segment", - "properties": Object { - "event_channel": "other", - "order_id": "fQ92^RLkQyhJ8TU3nYnh", - "shop_id": "fQ92^RLkQyhJ8TU3nYnh", - }, - "timestamp": "fQ92^RLkQyhJ8TU3nYnh", } `; exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - all fields with phone 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [ - "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", - ], - "phone_numbers": Array [ - "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", - ], + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object { + "referrer": "fQ92^RLkQyhJ8TU3nYnh", + "url": "fQ92^RLkQyhJ8TU3nYnh", + }, + "properties": Object { + "content_type": "product_group", + "contents": Array [ + Object { + "brand": "fQ92^RLkQyhJ8TU3nYnh", + "content_category": "fQ92^RLkQyhJ8TU3nYnh", + "content_id": "fQ92^RLkQyhJ8TU3nYnh", + "content_name": "fQ92^RLkQyhJ8TU3nYnh", + "price": 80779872997212.16, + "quantity": 80779872997212.16, + }, + ], + "currency": "FKP", + "description": "fQ92^RLkQyhJ8TU3nYnh", + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "query": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + "value": 80779872997212.16, + }, + "test_event_code": "fQ92^RLkQyhJ8TU3nYnh", + "user": Object { + "email": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "ip": "fQ92^RLkQyhJ8TU3nYnh", + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "locale": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [ + "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", + ], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + "ttp": "fQ92^RLkQyhJ8TU3nYnh", + "user_agent": "fQ92^RLkQyhJ8TU3nYnh", + }, }, - }, - "event": "fQ92^RLkQyhJ8TU3nYnh", - "event_id": "fQ92^RLkQyhJ8TU3nYnh", - "event_set_id": "fQ92^RLkQyhJ8TU3nYnh", + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", "partner_name": "Segment", - "properties": Object { - "event_channel": "other", - "order_id": "fQ92^RLkQyhJ8TU3nYnh", - "shop_id": "fQ92^RLkQyhJ8TU3nYnh", - }, - "timestamp": "fQ92^RLkQyhJ8TU3nYnh", } `; exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - required fields with email 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [ - "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", - ], - "phone_numbers": Array [], + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + }, + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + }, }, - }, - "event": "fQ92^RLkQyhJ8TU3nYnh", - "event_id": "fQ92^RLkQyhJ8TU3nYnh", - "event_set_id": "fQ92^RLkQyhJ8TU3nYnh", + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", "partner_name": "Segment", - "properties": Object { - "order_id": "fQ92^RLkQyhJ8TU3nYnh", - "shop_id": "fQ92^RLkQyhJ8TU3nYnh", - }, - "timestamp": "fQ92^RLkQyhJ8TU3nYnh", } `; exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackNonPaymentOfflineConversion action - required fields with phone 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [], - "phone_numbers": Array [ - "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", - ], + "data": Array [ + Object { + "event": "fQ92^RLkQyhJ8TU3nYnh", + "event_id": "fQ92^RLkQyhJ8TU3nYnh", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "fQ92^RLkQyhJ8TU3nYnh", + "shop_id": "fQ92^RLkQyhJ8TU3nYnh", + }, + "user": Object { + "email": Array [], + "external_id": Array [ + "bd2c3e18a82a7b6aa1b4fb99a52d0d1abf03dc1fde2e0bfde9d87106162a0cf5", + ], + "lead_id": "fQ92^RLkQyhJ8TU3nYnh", + "phone": Array [ + "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", + ], + "ttclid": "fQ92^RLkQyhJ8TU3nYnh", + }, }, - }, - "event": "fQ92^RLkQyhJ8TU3nYnh", - "event_id": "fQ92^RLkQyhJ8TU3nYnh", - "event_set_id": "fQ92^RLkQyhJ8TU3nYnh", + ], + "event_source": "offline", + "event_source_id": "fQ92^RLkQyhJ8TU3nYnh", "partner_name": "Segment", - "properties": Object { - "order_id": "fQ92^RLkQyhJ8TU3nYnh", - "shop_id": "fQ92^RLkQyhJ8TU3nYnh", - }, - "timestamp": "fQ92^RLkQyhJ8TU3nYnh", } `; exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - all fields with email 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [ - "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", - ], - "phone_numbers": Array [ - "03dec1b6379a35cbe10edb6ca30cf987b00116202c3825dece1d98c7a0718a09", - ], + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": true, + "page": Object { + "referrer": "BkRZ5", + "url": "BkRZ5", + }, + "properties": Object { + "content_type": "product", + "contents": Array [ + Object { + "brand": "BkRZ5", + "content_category": "BkRZ5", + "content_id": "BkRZ5", + "content_name": "BkRZ5", + "price": -79287355210465.28, + "quantity": -79287355210465.28, + }, + ], + "currency": "DZD", + "description": "BkRZ5", + "order_id": "BkRZ5", + "query": "BkRZ5", + "shop_id": "BkRZ5", + "value": -79287355210465.28, + }, + "test_event_code": "BkRZ5", + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "ip": "BkRZ5", + "lead_id": "BkRZ5", + "locale": "BkRZ5", + "phone": Array [ + "03dec1b6379a35cbe10edb6ca30cf987b00116202c3825dece1d98c7a0718a09", + ], + "ttclid": "BkRZ5", + "ttp": "BkRZ5", + "user_agent": "BkRZ5", + }, }, - }, - "event": "BkRZ5", - "event_id": "BkRZ5", - "event_set_id": "BkRZ5", + ], + "event_source": "offline", + "event_source_id": "BkRZ5", "partner_name": "Segment", - "properties": Object { - "contents": Array [ - Object { - "content_category": "BkRZ5", - "content_id": "BkRZ5", - "content_name": "BkRZ5", - "content_type": "BkRZ5", - "price": -79287355210465.28, - "quantity": -79287355210465.28, - }, - ], - "currency": "DZD", - "event_channel": "email", - "order_id": "BkRZ5", - "shop_id": "BkRZ5", - "value": -79287355210465.28, - }, - "timestamp": "BkRZ5", } `; exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - all fields with phone 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [ - "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", - ], - "phone_numbers": Array [ - "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", - ], + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": true, + "page": Object { + "referrer": "BkRZ5", + "url": "BkRZ5", + }, + "properties": Object { + "content_type": "product", + "contents": Array [ + Object { + "brand": "BkRZ5", + "content_category": "BkRZ5", + "content_id": "BkRZ5", + "content_name": "BkRZ5", + "price": -79287355210465.28, + "quantity": -79287355210465.28, + }, + ], + "currency": "DZD", + "description": "BkRZ5", + "order_id": "BkRZ5", + "query": "BkRZ5", + "shop_id": "BkRZ5", + "value": -79287355210465.28, + }, + "test_event_code": "BkRZ5", + "user": Object { + "email": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "ip": "BkRZ5", + "lead_id": "BkRZ5", + "locale": "BkRZ5", + "phone": Array [ + "e6ec7c951f23c74bc97e0b5adb342c4859a51005e9724b0c184914a94e1b2502", + ], + "ttclid": "BkRZ5", + "ttp": "BkRZ5", + "user_agent": "BkRZ5", + }, }, - }, - "event": "BkRZ5", - "event_id": "BkRZ5", - "event_set_id": "BkRZ5", + ], + "event_source": "offline", + "event_source_id": "BkRZ5", "partner_name": "Segment", - "properties": Object { - "contents": Array [ - Object { - "content_category": "BkRZ5", - "content_id": "BkRZ5", - "content_name": "BkRZ5", - "content_type": "BkRZ5", - "price": -79287355210465.28, - "quantity": -79287355210465.28, - }, - ], - "currency": "DZD", - "event_channel": "email", - "order_id": "BkRZ5", - "shop_id": "BkRZ5", - "value": -79287355210465.28, - }, - "timestamp": "BkRZ5", } `; exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - required fields with email 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [ - "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", - ], - "phone_numbers": Array [], + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "BkRZ5", + "shop_id": "BkRZ5", + }, + "user": Object { + "email": Array [ + "f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a", + ], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "lead_id": "BkRZ5", + "phone": Array [], + "ttclid": "BkRZ5", + }, }, - }, - "event": "BkRZ5", - "event_id": "BkRZ5", - "event_set_id": "BkRZ5", + ], + "event_source": "offline", + "event_source_id": "BkRZ5", "partner_name": "Segment", - "properties": Object { - "currency": "DZD", - "order_id": "BkRZ5", - "shop_id": "BkRZ5", - "value": -79287355210465.28, - }, } `; exports[`Testing snapshot for actions-tiktok-offline-conversions destination: trackPaymentOfflineConversion action - required fields with phone 1`] = ` Object { - "context": Object { - "user": Object { - "emails": Array [], - "phone_numbers": Array [ - "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", - ], + "data": Array [ + Object { + "event": "BkRZ5", + "event_id": "BkRZ5", + "event_time": 1704721970, + "limited_data_use": false, + "page": Object {}, + "properties": Object { + "contents": Array [], + "order_id": "BkRZ5", + "shop_id": "BkRZ5", + }, + "user": Object { + "email": Array [], + "external_id": Array [ + "6888d19c4f09018b86ec3aadede889119878797af81c69f2d34ec02d80f9c29e", + ], + "lead_id": "BkRZ5", + "phone": Array [ + "5cdba0fbbbb18f19a4eb0d83b274c81aebc746dcae87b9e3ad99f3a170a4735b", + ], + "ttclid": "BkRZ5", + }, }, - }, - "event": "BkRZ5", - "event_id": "BkRZ5", - "event_set_id": "BkRZ5", + ], + "event_source": "offline", + "event_source_id": "BkRZ5", "partner_name": "Segment", - "properties": Object { - "currency": "DZD", - "order_id": "BkRZ5", - "shop_id": "BkRZ5", - "value": -79287355210465.28, - }, } `; diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/index.test.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/index.test.ts index 7822c3586e..038d410b2e 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/index.test.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/index.test.ts @@ -4,7 +4,7 @@ import Definition from '../index' import { Settings } from '../generated-types' const testDestination = createTestIntegration(Definition) -const timestamp = '2023-04-17T15:21:15.449Z' +const timestamp = '2024-01-08T13:52:50.212Z' const settings: Settings = { accessToken: 'test-token', eventSetID: 'test-event-set-id' @@ -29,7 +29,7 @@ describe('TikTok Offline Conversions', () => { userId: 'testId123-contact' }) - nock('https://business-api.tiktok.com/open_api/v1.3/offline/track/').post('/').reply(200, {}) + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) const responses = await testDestination.testAction('trackNonPaymentOfflineConversion', { event, @@ -43,28 +43,50 @@ describe('TikTok Offline Conversions', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(responses[0].options.json).toMatchObject({ - event_set_id: settings.eventSetID, - event: 'Contact', - event_id: event.messageId, - timestamp: timestamp, + event_source: 'offline', + event_source_id: settings.eventSetID, partner_name: 'Segment', - context: { - user: { - emails: [ - '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', - 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' - ], - phone_numbers: [ - '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', - '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' - ] + data: [ + { + event: 'Contact', + event_time: 1704721970, + event_id: 'test-message-id-contact', + user: { + ttclid: 'test-ttclid-contact', + external_id: ['f18c018187c833dc00fb68f0517a135356fd947df08b0d22eaa145f623edc13e'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [], + content_type: 'product', + currency: undefined, + value: undefined, + query: undefined, + description: undefined, + order_id: 'test-order-id-contact', + shop_id: 'test-shop-id-contact' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined } - }, - properties: { - order_id: 'test-order-id-contact', - shop_id: 'test-shop-id-contact', - event_channel: 'in_store' - } + ] }) }) @@ -85,7 +107,7 @@ describe('TikTok Offline Conversions', () => { userId: 'testId123-subscribe' }) - nock('https://business-api.tiktok.com/open_api/v1.3/offline/track/').post('/').reply(200, {}) + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) const responses = await testDestination.testAction('trackNonPaymentOfflineConversion', { event, @@ -99,28 +121,50 @@ describe('TikTok Offline Conversions', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(responses[0].options.json).toMatchObject({ - event_set_id: settings.eventSetID, - event: 'Subscribe', - event_id: event.messageId, - timestamp: timestamp, + event_source: 'offline', + event_source_id: settings.eventSetID, partner_name: 'Segment', - context: { - user: { - emails: [ - '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', - 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' - ], - phone_numbers: [ - '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', - '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' - ] + data: [ + { + event: 'Subscribe', + event_time: 1704721970, + event_id: 'test-message-id-subscribe', + user: { + ttclid: 'test-ttclid-subscribe', + external_id: ['e3b83f59446a2f66722aa4947be585da59b37072dd76edfee189422417db5879'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [], + content_type: 'product', + currency: undefined, + value: undefined, + query: undefined, + description: undefined, + order_id: 'test-order-id-subscribe', + shop_id: 'test-shop-id-subscribe' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined } - }, - properties: { - order_id: 'test-order-id-subscribe', - shop_id: 'test-shop-id-subscribe', - event_channel: 'in_store' - } + ] }) }) @@ -140,7 +184,7 @@ describe('TikTok Offline Conversions', () => { userId: 'testId123-submit-form' }) - nock('https://business-api.tiktok.com/open_api/v1.3/offline/track/').post('/').reply(200, {}) + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) const responses = await testDestination.testAction('trackNonPaymentOfflineConversion', { event, @@ -154,28 +198,50 @@ describe('TikTok Offline Conversions', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(responses[0].options.json).toMatchObject({ - event_set_id: settings.eventSetID, - event: 'SubmitForm', - event_id: event.messageId, - timestamp: timestamp, + event_source: 'offline', + event_source_id: settings.eventSetID, partner_name: 'Segment', - context: { - user: { - emails: [ - '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', - 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' - ], - phone_numbers: [ - '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', - '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' - ] + data: [ + { + event: 'SubmitForm', + event_time: 1704721970, + event_id: 'test-message-id-submit-form', + user: { + ttclid: undefined, + external_id: ['ad1d0a79ae249b682fa21961d26120ee17b89aec332fee649002cd387742bd97'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [], + content_type: 'product', + currency: undefined, + value: undefined, + query: undefined, + description: undefined, + order_id: 'test-order-id-submit-form', + shop_id: 'test-shop-id-submit-form' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined } - }, - properties: { - order_id: 'test-order-id-submit-form', - shop_id: 'test-shop-id-submit-form', - event_channel: 'in_store' - } + ] }) }) }) @@ -201,7 +267,7 @@ describe('TikTok Offline Conversions', () => { userId: 'testId123-complete-payment' }) - nock('https://business-api.tiktok.com/open_api/v1.3/offline/track/').post('/').reply(200, {}) + nock('https://business-api.tiktok.com/open_api/v1.3/event/track/').post('/').reply(200, {}) const responses = await testDestination.testAction('trackPaymentOfflineConversion', { event, @@ -234,38 +300,56 @@ describe('TikTok Offline Conversions', () => { expect(responses.length).toBe(1) expect(responses[0].status).toBe(200) expect(responses[0].options.json).toMatchObject({ - event_set_id: settings.eventSetID, - event: 'CompletePayment', - event_id: event.messageId, - timestamp: timestamp, + event_source: 'offline', + event_source_id: settings.eventSetID, partner_name: 'Segment', - context: { - user: { - emails: [ - '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', - 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' - ], - phone_numbers: [ - '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', - '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' - ] + data: [ + { + event: 'CompletePayment', + event_time: 1704721970, + event_id: 'test-message-id-complete-payment', + user: { + ttclid: undefined, + external_id: ['5da716ea2a24e8d05cea64167903ed983a273f897e3befc875cde15e9a8b5145'], + email: [ + '522a233963af49ceac13a2f68719d86a0b4cfb306b9a7959db697e1d7a52676a', + 'c4821c6d488a9a27653e59b7c1f576e1434ed3e11cd0b6b86440fe56ea6c2d97' + ], + phone: [ + '910a625c4ba147b544e6bd2f267e130ae14c591b6ba9c25cb8573322dedbebd0', + '46563a86074ccb92653d9f0666885030f5e921563bfa19c423b60a8c9ef7f85e' + ], + lead_id: undefined, + ttp: undefined, + ip: '8.8.8.8', + user_agent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + locale: 'en-US' + }, + properties: { + contents: [ + { + content_id: 'abc123', + price: 100, + quantity: 2 + } + ], + content_type: 'product', + currency: 'USD', + value: 100, + query: 'shoes', + description: undefined, + order_id: 'test-order-id-complete-payment', + shop_id: 'test-shop-id-complete-payment' + }, + page: { + url: 'https://segment.com/academy/', + referrer: undefined + }, + limited_data_use: false, + test_event_code: undefined } - }, - properties: { - order_id: 'test-order-id-complete-payment', - shop_id: 'test-shop-id-complete-payment', - event_channel: 'in_store', - contents: [ - { - price: 100, - quantity: 2, - content_type: 'Air Force One (Size S)', - content_id: 'abc123' - } - ], - currency: 'USD', - value: 100 - } + ] }) }) }) diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/snapshot.test.ts index 34e0c8d5d7..56da594d42 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/snapshot.test.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/__tests__/snapshot.test.ts @@ -6,6 +6,8 @@ import nock from 'nock' const testDestination = createTestIntegration(destination) const destinationSlug = 'actions-tiktok-offline-conversions' +const timestamp = '2024-01-08T13:52:50.212Z' + describe(`Testing snapshot for ${destinationSlug} destination:`, () => { for (const actionSlug in destination.actions) { it(`${actionSlug} action - required fields with email`, async () => { @@ -18,6 +20,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ + timestamp: timestamp, properties: { ...eventData, email: 'test@test.com' @@ -26,7 +29,11 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const responses = await testDestination.testAction(actionSlug, { event: event, - mapping: { ...event.properties, email_addresses: { '@path': 'properties.email' } }, + mapping: { + ...event.properties, + email_addresses: { '@path': 'properties.email' }, + timestamp: { '@path': 'timestamp' } + }, settings: settingsData, auth: undefined }) @@ -55,6 +62,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ + timestamp: timestamp, properties: { ...eventData, phone: '+353858764535' @@ -63,7 +71,11 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const responses = await testDestination.testAction(actionSlug, { event: event, - mapping: { ...event.properties, phone_numbers: { '@path': 'properties.phone' } }, + mapping: { + ...event.properties, + phone_numbers: { '@path': 'properties.phone' }, + timestamp: { '@path': 'timestamp' } + }, settings: settingsData, auth: undefined }) @@ -92,6 +104,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ + timestamp: timestamp, properties: { ...eventData, email: 'test@test.com' @@ -100,7 +113,11 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const responses = await testDestination.testAction(actionSlug, { event: event, - mapping: { ...event.properties, email_addresses: { '@path': 'properties.email' } }, + mapping: { + ...event.properties, + email_addresses: { '@path': 'properties.email' }, + timestamp: { '@path': 'timestamp' } + }, settings: settingsData, auth: undefined }) @@ -127,6 +144,7 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { nock(/.*/).persist().put(/.*/).reply(200) const event = createTestEvent({ + timestamp: timestamp, properties: { ...eventData, phone: '+3538587346' @@ -135,7 +153,11 @@ describe(`Testing snapshot for ${destinationSlug} destination:`, () => { const responses = await testDestination.testAction(actionSlug, { event: event, - mapping: { ...event.properties, phone_numbers: { '@path': 'properties.phone' } }, + mapping: { + ...event.properties, + phone_numbers: { '@path': 'properties.phone' }, + timestamp: { '@path': 'timestamp' } + }, settings: settingsData, auth: undefined }) diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/common_fields.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/common_fields.ts index 46413237a0..4c946d0859 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/common_fields.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/common_fields.ts @@ -6,13 +6,12 @@ export const commonFields: Record = { type: 'string', required: true, description: - 'Conversion event name. Please refer to the "Supported Offline Events" section on in TikTok’s [Offline Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1758053486938113) for accepted event names.' + 'Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names.' }, event_id: { label: 'Event ID', type: 'string', - description: - 'A unique value for each event. This ID can be used to match data between partner and TikTok. We suggest it is a String of 32 characters, including numeric digits (0-9), uppercase letters (A-Z), and lowercase letters (a-z).', + description: 'Any hashed ID that can identify a unique user/session.', default: { '@path': '$.messageId' } @@ -20,44 +19,43 @@ export const commonFields: Record = { timestamp: { label: 'Event Timestamp', type: 'string', - required: true, - description: 'Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z', + description: 'Timestamp that the event took place, in ISO 8601 format.', default: { '@path': '$.timestamp' } }, phone_numbers: { - label: 'Phone Numbers', + label: 'Phone Number', description: - 'A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number is required if no value is provided in the Emails field.', + 'A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty.', type: 'string', multiple: true, default: { '@if': { exists: { '@path': '$.properties.phone' }, then: { '@path': '$.properties.phone' }, - else: { '@path': '$.traits.phone' } + else: { '@path': '$.context.traits.phone' } } } }, email_addresses: { - label: 'Emails', + label: 'Email', description: - 'A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email is required if no value is provided in the Phone Numbers field.', + 'A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty.', type: 'string', multiple: true, default: { '@if': { exists: { '@path': '$.properties.email' }, then: { '@path': '$.properties.email' }, - else: { '@path': '$.traits.email' } + else: { '@path': '$.context.traits.email' } } } }, order_id: { label: 'Order ID', type: 'string', - description: 'The order id', + description: 'Order ID of the transaction.', default: { '@path': '$.properties.order_id' } @@ -65,24 +63,191 @@ export const commonFields: Record = { shop_id: { label: 'Shop ID', type: 'string', - description: 'The shop id', + description: 'Shop ID of the transaction.', default: { '@path': '$.properties.shop_id' } }, - event_channel: { - label: 'Event channel', + external_ids: { + label: 'External ID', + description: + 'Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty.', + type: 'string', + multiple: true, + default: { + '@if': { + exists: { '@path': '$.userId' }, + then: { '@path': '$.userId' }, + else: { '@path': '$.anonymousId' } + } + } + }, + ttclid: { + label: 'TikTok Click ID', + description: + 'The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details.', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.ttclid' }, + then: { '@path': '$.properties.ttclid' }, + else: { '@path': '$.integrations.TikTok Offline Conversions.ttclid' } + } + } + }, + ttp: { + label: 'TikTok Cookie ID', + description: + 'TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`).', + type: 'string', + default: { + '@if': { + exists: { '@path': '$.properties.ttp' }, + then: { '@path': '$.properties.ttp' }, + else: { '@path': '$.integrations.TikTok Offline Conversions.ttp' } + } + } + }, + lead_id: { + label: 'TikTok Lead ID', + description: + 'ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability', + type: 'string', + default: { '@path': '$.properties.lead_id' } + }, + locale: { + label: 'Locale', + description: + 'The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt).', + type: 'string', + default: { + '@path': '$.context.locale' + } + }, + url: { + label: 'Page URL', + type: 'string', + description: 'The page URL where the conversion event took place.', + default: { + '@path': '$.context.page.url' + } + }, + referrer: { + label: 'Page Referrer', + type: 'string', + description: 'The page referrer.', + default: { + '@path': '$.context.page.referrer' + } + }, + ip: { + label: 'IP Address', + type: 'string', + description: 'IP address of the browser.', + default: { + '@path': '$.context.ip' + } + }, + user_agent: { + label: 'User Agent', + type: 'string', + description: 'User agent from the user’s device.', + default: { + '@path': '$.context.userAgent' + } + }, + contents: { + label: 'Contents', + type: 'object', + multiple: true, + description: 'Related item details for the event.', + properties: { + price: { + label: 'Price', + description: 'Price of the item.', + type: 'number' + }, + quantity: { + label: 'Quantity', + description: 'Number of items.', + type: 'number' + }, + content_category: { + label: 'Content Category', + description: 'Category of the product item.', + type: 'string' + }, + content_id: { + label: 'Content ID', + description: 'ID of the product item.', + type: 'string' + }, + content_name: { + label: 'Content Name', + description: 'Name of the product item.', + type: 'string' + }, + brand: { + label: 'Brand', + description: 'Brand name of the product item.', + type: 'string' + } + } + }, + content_type: { + label: 'Content Type', + description: + 'Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`.', + type: 'string', + choices: [ { label: 'product', value: 'product' }, { label: 'product_group', value: 'product_group' }], + default: 'product' + }, + currency: { + label: 'Currency', + type: 'string', + description: 'Currency for the value specified as ISO 4217 code.', + default: { + '@path': '$.properties.currency' + } + }, + value: { + label: 'Value', + type: 'number', + description: 'Value of the order or items sold.', + default: { + '@if': { + exists: { '@path': '$.properties.value' }, + then: { '@path': '$.properties.value' }, + else: { '@path': '$.properties.revenue' } + } + } + }, + description: { + label: 'Description', + type: 'string', + description: 'A string description of the web event.' + }, + query: { + label: 'Query', + type: 'string', + description: 'The text string that was searched for.', + default: { + '@path': '$.properties.query' + } + }, + limited_data_use: { + label: 'Limited Data Use', + type: 'boolean', + description: + 'Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970).', + default: { + '@path': '$.properties.limited_data_use' + } + }, + test_event_code: { + label: 'Test Event Code', type: 'string', description: - 'Event channel of the offline conversion event. Accepted values are: email, website, phone_call, in_store, crm, other. Any other value will be rejected', - choices: [ - { label: 'Email', value: 'email' }, - { label: 'Website', value: 'website' }, - { label: 'Phone call', value: 'phone_call' }, - { label: 'In store', value: 'in_store' }, - { label: 'CRM', value: 'crm' }, - { label: 'Other', value: 'other' } - ], - default: 'in_store' + 'Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You\'ll want to remove your Test Event Code when sending real traffic through this integration.' } } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/formatter.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/formatter.ts index f64f124b54..d3ef9351b3 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/formatter.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/formatter.ts @@ -36,6 +36,21 @@ export const formatPhones = (phone_numbers: string[] | undefined): string[] => { return result } +/** + * + * @param userId + * @returns Leading/Trailing spaces are trimmed and then userId is hashed. + */ +export function formatUserIds(userIds: string[] | undefined): string[] { + const result: string[] = [] + if (userIds) { + userIds.forEach((userId: string) => { + result.push(hashAndEncode(userId.toLowerCase())) + }) + } + return result +} + function hashAndEncode(property: string) { return createHash('sha256').update(property).digest('hex') } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/generated-types.ts index d5bfe76c62..ddb194e54f 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/generated-types.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/generated-types.ts @@ -2,11 +2,11 @@ export interface Settings { /** - * Your TikTok Access Token. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?rid=mcxl4tclmfa&id=1758051319816193) for information on how to generate an access token via the TikTok Ads Manager or API. + * Your TikTok Access Token. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101130925058) for information on how to generate an access token via the TikTok Ads Manager or API. */ accessToken: string /** - * Your TikTok Offline Event Set ID. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?rid=mcxl4tclmfa&id=1758051319816193) for information on how to find this value. + * Your TikTok Offline Event Set ID. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101027431425) for information on how to find this value. */ eventSetID: string } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/index.ts index 46ca215a19..2a215ae5f3 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/index.ts @@ -2,6 +2,52 @@ import { defaultValues, DestinationDefinition } from '@segment/actions-core' import type { Settings } from './generated-types' import trackPaymentOfflineConversion from './trackPaymentOfflineConversion' import trackNonPaymentOfflineConversion from './trackNonPaymentOfflineConversion' +import reportOfflineEvent from './reportOfflineEvent' + +const productProperties = { + price: { + '@path': '$.price' + }, + quantity: { + '@path': '$.quantity' + }, + content_category: { + '@path': '$.category' + }, + content_id: { + '@path': '$.product_id' + }, + content_name: { + '@path': '$.name' + }, + brand: { + '@path': '$.brand' + } +} + +const singleProductContents = { + ...defaultValues(reportOfflineEvent.fields), + contents: { + '@arrayPath': [ + '$.properties', + { + ...productProperties + } + ] + } +} + +const multiProductContents = { + ...defaultValues(reportOfflineEvent.fields), + contents: { + '@arrayPath': [ + '$.properties.products', + { + ...productProperties + } + ] + } +} const destination: DestinationDefinition = { name: 'TikTok Offline Conversions', @@ -14,7 +60,7 @@ const destination: DestinationDefinition = { accessToken: { label: 'Access Token', description: - 'Your TikTok Access Token. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?rid=mcxl4tclmfa&id=1758051319816193) for information on how to generate an access token via the TikTok Ads Manager or API.', + 'Your TikTok Access Token. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101130925058) for information on how to generate an access token via the TikTok Ads Manager or API.', type: 'string', required: true }, @@ -22,7 +68,7 @@ const destination: DestinationDefinition = { label: 'Event Set ID', type: 'string', description: - 'Your TikTok Offline Event Set ID. Please see TikTok’s [Events API documentation](https://ads.tiktok.com/marketing_api/docs?rid=mcxl4tclmfa&id=1758051319816193) for information on how to find this value.', + 'Your TikTok Offline Event Set ID. Please see TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101027431425) for information on how to find this value.', required: true } }, @@ -49,48 +95,159 @@ const destination: DestinationDefinition = { presets: [ { name: 'Complete Payment', - subscribe: 'type = "track" and event = "Order Completed"', - partnerAction: 'trackPaymentOfflineConversion', + subscribe: 'event = "Order Completed"', + partnerAction: 'reportOfflineEvent', mapping: { - ...defaultValues(trackPaymentOfflineConversion.fields), + ...multiProductContents, event: 'CompletePayment' }, type: 'automatic' }, { name: 'Contact', - subscribe: 'type = "track" and event = "User Contacted Call Center"', - partnerAction: 'trackNonPaymentOfflineConversion', + subscribe: 'event = "Callback Started"', + partnerAction: 'reportOfflineEvent', mapping: { - ...defaultValues(trackNonPaymentOfflineConversion.fields), + ...defaultValues(reportOfflineEvent.fields), event: 'Contact' }, type: 'automatic' }, { name: 'Subscribe', - subscribe: 'type = "track" and event = "User Subscribed In Store"', - partnerAction: 'trackNonPaymentOfflineConversion', + subscribe: 'event = "Subscription Created"', + partnerAction: 'reportOfflineEvent', mapping: { - ...defaultValues(trackNonPaymentOfflineConversion.fields), + ...defaultValues(reportOfflineEvent.fields), event: 'Subscribe' }, type: 'automatic' }, { name: 'Submit Form', - subscribe: 'type = "track" and event = "Form Submitted"', - partnerAction: 'trackNonPaymentOfflineConversion', + subscribe: 'event = "Form Submitted"', + partnerAction: 'reportOfflineEvent', mapping: { - ...defaultValues(trackNonPaymentOfflineConversion.fields), + ...defaultValues(reportOfflineEvent.fields), event: 'SubmitForm' }, type: 'automatic' + }, + { + name: 'Page View', + subscribe: 'type="page"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'PageView' + }, + type: 'automatic' + }, + { + name: 'View Content', + subscribe: 'event = "Product Viewed"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'ViewContent' + }, + type: 'automatic' + }, + { + name: 'Click Button', + subscribe: 'event = "Product Clicked"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'ClickButton' + }, + type: 'automatic' + }, + { + name: 'Search', + subscribe: 'event = "Products Searched"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'Search' + }, + type: 'automatic' + }, + { + name: 'Add to Wishlist', + subscribe: 'event = "Product Added to Wishlist"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'AddToWishlist' + }, + type: 'automatic' + }, + { + name: 'Add to Cart', + subscribe: 'event = "Product Added"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...singleProductContents, + event: 'AddToCart' + }, + type: 'automatic' + }, + { + name: 'Initiate Checkout', + subscribe: 'event = "Checkout Started"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'InitiateCheckout' + }, + type: 'automatic' + }, + { + name: 'Add Payment Info', + subscribe: 'event = "Payment Info Entered"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'AddPaymentInfo' + }, + type: 'automatic' + }, + { + name: 'Place an Order', + subscribe: 'event = "Order Placed"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...multiProductContents, + event: 'PlaceAnOrder' + }, + type: 'automatic' + }, + { + name: 'Download', + subscribe: 'event = "Download Link Clicked"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...defaultValues(reportOfflineEvent.fields), + event: 'Download' + }, + type: 'automatic' + }, + { + name: 'Complete Registration', + subscribe: 'event = "Signed Up"', + partnerAction: 'reportOfflineEvent', + mapping: { + ...defaultValues(reportOfflineEvent.fields), + event: 'CompleteRegistration' + }, + type: 'automatic' } ], actions: { trackPaymentOfflineConversion, - trackNonPaymentOfflineConversion + trackNonPaymentOfflineConversion, + reportOfflineEvent } } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/reportOfflineEvent/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/reportOfflineEvent/generated-types.ts new file mode 100644 index 0000000000..ac9476c6ac --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/reportOfflineEvent/generated-types.ts @@ -0,0 +1,125 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names. + */ + event: string + /** + * Any hashed ID that can identify a unique user/session. + */ + event_id?: string + /** + * Timestamp that the event took place, in ISO 8601 format. + */ + timestamp?: string + /** + * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty. + */ + phone_numbers?: string[] + /** + * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty. + */ + email_addresses?: string[] + /** + * Order ID of the transaction. + */ + order_id?: string + /** + * Shop ID of the transaction. + */ + shop_id?: string + /** + * Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty. + */ + external_ids?: string[] + /** + * The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details. + */ + ttclid?: string + /** + * TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`). + */ + ttp?: string + /** + * ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability + */ + lead_id?: string + /** + * The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). + */ + locale?: string + /** + * The page URL where the conversion event took place. + */ + url?: string + /** + * The page referrer. + */ + referrer?: string + /** + * IP address of the browser. + */ + ip?: string + /** + * User agent from the user’s device. + */ + user_agent?: string + /** + * Related item details for the event. + */ + contents?: { + /** + * Price of the item. + */ + price?: number + /** + * Number of items. + */ + quantity?: number + /** + * Category of the product item. + */ + content_category?: string + /** + * ID of the product item. + */ + content_id?: string + /** + * Name of the product item. + */ + content_name?: string + /** + * Brand name of the product item. + */ + brand?: string + }[] + /** + * Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`. + */ + content_type?: string + /** + * Currency for the value specified as ISO 4217 code. + */ + currency?: string + /** + * Value of the order or items sold. + */ + value?: number + /** + * A string description of the web event. + */ + description?: string + /** + * The text string that was searched for. + */ + query?: string + /** + * Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970). + */ + limited_data_use?: boolean + /** + * Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You'll want to remove your Test Event Code when sending real traffic through this integration. + */ + test_event_code?: string +} diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/reportOfflineEvent/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/reportOfflineEvent/index.ts new file mode 100644 index 0000000000..5a02119ef7 --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/reportOfflineEvent/index.ts @@ -0,0 +1,18 @@ +import { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { commonFields } from '../common_fields' +import { performOfflineEvent } from '../utils' + +const action: ActionDefinition = { + title: 'Track Offline Conversion', + description: 'Send details of an in-store purchase or console purchase to the Tiktok Offline Events API', + fields: { + ...commonFields + }, + perform: (request, { payload, settings }) => { + return performOfflineEvent(request, settings, payload) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/generated-types.ts index 5091a79fe7..ac9476c6ac 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/generated-types.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/generated-types.ts @@ -2,35 +2,124 @@ export interface Payload { /** - * Conversion event name. Please refer to the "Supported Offline Events" section on in TikTok’s [Offline Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1758053486938113) for accepted event names. + * Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names. */ event: string /** - * A unique value for each event. This ID can be used to match data between partner and TikTok. We suggest it is a String of 32 characters, including numeric digits (0-9), uppercase letters (A-Z), and lowercase letters (a-z). + * Any hashed ID that can identify a unique user/session. */ event_id?: string /** - * Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z + * Timestamp that the event took place, in ISO 8601 format. */ - timestamp: string + timestamp?: string /** - * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number is required if no value is provided in the Emails field. + * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty. */ phone_numbers?: string[] /** - * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email is required if no value is provided in the Phone Numbers field. + * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty. */ email_addresses?: string[] /** - * The order id + * Order ID of the transaction. */ order_id?: string /** - * The shop id + * Shop ID of the transaction. */ shop_id?: string /** - * Event channel of the offline conversion event. Accepted values are: email, website, phone_call, in_store, crm, other. Any other value will be rejected + * Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty. */ - event_channel?: string + external_ids?: string[] + /** + * The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details. + */ + ttclid?: string + /** + * TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`). + */ + ttp?: string + /** + * ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability + */ + lead_id?: string + /** + * The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). + */ + locale?: string + /** + * The page URL where the conversion event took place. + */ + url?: string + /** + * The page referrer. + */ + referrer?: string + /** + * IP address of the browser. + */ + ip?: string + /** + * User agent from the user’s device. + */ + user_agent?: string + /** + * Related item details for the event. + */ + contents?: { + /** + * Price of the item. + */ + price?: number + /** + * Number of items. + */ + quantity?: number + /** + * Category of the product item. + */ + content_category?: string + /** + * ID of the product item. + */ + content_id?: string + /** + * Name of the product item. + */ + content_name?: string + /** + * Brand name of the product item. + */ + brand?: string + }[] + /** + * Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`. + */ + content_type?: string + /** + * Currency for the value specified as ISO 4217 code. + */ + currency?: string + /** + * Value of the order or items sold. + */ + value?: number + /** + * A string description of the web event. + */ + description?: string + /** + * The text string that was searched for. + */ + query?: string + /** + * Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970). + */ + limited_data_use?: boolean + /** + * Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You'll want to remove your Test Event Code when sending real traffic through this integration. + */ + test_event_code?: string } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/index.ts index 9a24e201f5..61293f749f 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackNonPaymentOfflineConversion/index.ts @@ -1,43 +1,18 @@ -import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { commonFields } from '../common_fields' -import { formatEmails, formatPhones } from '../formatter' +import { performOfflineEvent } from '../utils' const action: ActionDefinition = { - title: 'Track Non Payment Offline Conversion', - description: 'Send a non payment related event to the TikTok Offline Conversions API', + title: '[Deprecated] Track Non Payment Offline Conversion', + description: + "[Deprecated] Send a non payment related event to the TikTok Offline Conversions API. This Action has been Deprecated. Please use the 'Track Payment Offline Conversion' Action instead", fields: { ...commonFields }, perform: (request, { payload, settings }) => { - const phone_numbers = formatPhones(payload.phone_numbers) - const emails = formatEmails(payload.email_addresses) - - if (phone_numbers.length < 1 && emails.length < 1) - throw new PayloadValidationError('TikTok Offline Conversions API requires an email address and/or phone number') - - return request('https://business-api.tiktok.com/open_api/v1.3/offline/track/', { - method: 'post', - json: { - event_set_id: settings.eventSetID, - event: payload.event, - event_id: payload.event_id ? `${payload.event_id}` : undefined, - timestamp: payload.timestamp, - context: { - user: { - phone_numbers, - emails - } - }, - properties: { - order_id: payload.order_id, - shop_id: payload.shop_id, - event_channel: payload.event_channel - }, - partner_name: 'Segment' - } - }) + return performOfflineEvent(request, settings, payload) } } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/generated-types.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/generated-types.ts index d4f3beba1f..ac9476c6ac 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/generated-types.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/generated-types.ts @@ -2,72 +2,124 @@ export interface Payload { /** - * Conversion event name. Please refer to the "Supported Offline Events" section on in TikTok’s [Offline Events API documentation](https://ads.tiktok.com/marketing_api/docs?id=1758053486938113) for accepted event names. + * Conversion event name. Please refer to the "Offline Standard Events" section on in TikTok’s [Events API 2.0 documentation](https://business-api.tiktok.com/portal/docs?id=1771101186666498) for accepted event names. */ event: string /** - * A unique value for each event. This ID can be used to match data between partner and TikTok. We suggest it is a String of 32 characters, including numeric digits (0-9), uppercase letters (A-Z), and lowercase letters (a-z). + * Any hashed ID that can identify a unique user/session. */ event_id?: string /** - * Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z + * Timestamp that the event took place, in ISO 8601 format. */ timestamp?: string /** - * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number is required if no value is provided in the Emails field. + * A single phone number or array of phone numbers in E.164 standard format. Segment will hash this value before sending to TikTok. At least one phone number value is required if both Email and External ID fields are empty. */ phone_numbers?: string[] /** - * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email is required if no value is provided in the Phone Numbers field. + * A single email address or an array of email addresses. Segment will hash this value before sending to TikTok. At least one email value is required if both Phone Number and External ID fields are empty. */ email_addresses?: string[] /** - * The order id + * Order ID of the transaction. */ order_id?: string /** - * The shop id + * Shop ID of the transaction. */ shop_id?: string /** - * Event channel of the offline conversion event. Accepted values are: email, website, phone_call, in_store, crm, other. Any other value will be rejected + * Uniquely identifies the user who triggered the conversion event. Segment will hash this value before sending to TikTok. TikTok Offline Conversions Destination supports both string and string[] types for sending external ID(s). At least one external ID value is required if both Email and Phone Number fields are empty. */ - event_channel?: string + external_ids?: string[] /** - * Array of product or content items for the offline event. + * The value of the ttclid used to match website visitor events with TikTok ads. The ttclid is valid for 7 days. See [Set up ttclid](https://ads.tiktok.com/marketing_api/docs?rid=4eezrhr6lg4&id=1681728034437121) for details. + */ + ttclid?: string + /** + * TikTok Cookie ID. If you also use Pixel SDK and have enabled cookies, Pixel SDK automatically saves a unique identifier in the `_ttp` cookie. The value of `_ttp` is used to match website visitor events with TikTok ads. You can extract the value of `_ttp` and attach the value here. To learn more about the `ttp` parameter, refer to [Events API 2.0 - Send TikTok Cookie](https://ads.tiktok.com/marketing_api/docs?id=%201771100936446977) (`_ttp`). + */ + ttp?: string + /** + * ID of TikTok leads. Every lead will have its own lead_id when exported from TikTok. This feature is in Beta. Please contact your TikTok representative to inquire regarding availability + */ + lead_id?: string + /** + * The BCP 47 language identifier. For reference, refer to the [IETF BCP 47 standardized code](https://www.rfc-editor.org/rfc/bcp/bcp47.txt). + */ + locale?: string + /** + * The page URL where the conversion event took place. + */ + url?: string + /** + * The page referrer. + */ + referrer?: string + /** + * IP address of the browser. + */ + ip?: string + /** + * User agent from the user’s device. + */ + user_agent?: string + /** + * Related item details for the event. */ contents?: { /** - * Price of the product or content item. Price is a required field for all content items. + * Price of the item. */ price?: number /** - * Quantity of this product ot item in the offline event. Quantity is a required field for all content items. + * Number of items. */ quantity?: number /** - * Product type + * Category of the product item. */ - content_type?: string + content_category?: string /** - * Product or content item identifier. Content ID is a required field for all product or content items. + * ID of the product item. */ content_id?: string /** - * Name of the product or content item. + * Name of the product item. */ content_name?: string /** - * Category of the product or content item. + * Brand name of the product item. */ - content_category?: string + brand?: string }[] /** - * ISO 4217 code. Required for revenue reporting. Example: "USD".List of currencies currently supported: AED, ARS, AUD, BDT, BHD, BIF, BOB, BRL, CAD, CHF, CLP, CNY, COP, CRC, CZK, DKK, DZD, EGP, EUR, GBP, GTQ, HKD, HNL, HUF, IDR, ILS, INR, ISK, JPY, KES, KHR, KRW, KWD, KZT, MAD, MOP, MXN, MYR, NGN, NIO, NOK, NZD, OMR, PEN, PHP, PHP, PKR, PLN, PYG, QAR, RON, RUB, SAR, SEK, SGD, THB, TRY, TWD, UAH, USD, VES, VND, ZAR. + * Type of the product item. When the `content_id` in the `Contents` field is specified as a `sku_id`, set this field to `product`. When the `content_id` in the `Contents` field is specified as an `item_group_id`, set this field to `product_group`. + */ + content_type?: string + /** + * Currency for the value specified as ISO 4217 code. + */ + currency?: string + /** + * Value of the order or items sold. + */ + value?: number + /** + * A string description of the web event. + */ + description?: string + /** + * The text string that was searched for. + */ + query?: string + /** + * Use this field to flag an event for limited data processing. TikTok will recognize this parameter as a request for limited data processing, and will limit its processing activities accordingly if the event shared occurred in an eligible location. To learn more about the Limited Data Use feature, refer to [Events API 2.0 - Limited Data Use](https://ads.tiktok.com/marketing_api/docs?id=1771101204435970). */ - currency: string + limited_data_use?: boolean /** - * Revenue of total products or content items. Required for revenue reporting. Must be a number. e.g. 101.99 and not "101.99 USD" + * Use this field to specify that events should be test events rather than actual traffic. You can find your Test Event Code in your TikTok Events Manager under the "Test Event" tab. You'll want to remove your Test Event Code when sending real traffic through this integration. */ - value: number + test_event_code?: string } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/index.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/index.ts index 0eb6c47e0e..5d6fbb9d76 100644 --- a/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/index.ts +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/trackPaymentOfflineConversion/index.ts @@ -1,143 +1,18 @@ -import { ActionDefinition, PayloadValidationError } from '@segment/actions-core' +import { ActionDefinition } from '@segment/actions-core' import type { Settings } from '../generated-types' import type { Payload } from './generated-types' import { commonFields } from '../common_fields' -import { formatEmails, formatPhones } from '../formatter' +import { performOfflineEvent } from '../utils' const action: ActionDefinition = { - title: 'Track Payment Offline Conversion', - description: 'Send details of an in-store purchase or console purchase to the Tiktok Offline Events API', + title: '[Deprecated] Track Payment Offline Conversion', + description: + "[Deprecated] Send details of an in-store purchase or console purchase to the Tiktok Offline Events API. This Action has been Deprecated. Please use the 'Track Payment Offline Conversion' Action instead", fields: { - ...commonFields, - timestamp: { - label: 'Event Timestamp', - type: 'string', - description: 'Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z', - default: { - '@path': '$.timestamp' - } - }, - contents: { - label: 'Contents', - type: 'object', - multiple: true, - description: 'Array of product or content items for the offline event.', - properties: { - price: { - label: 'Price', - description: 'Price of the product or content item. Price is a required field for all content items.', - type: 'number' - }, - quantity: { - label: 'Quantity', - description: - 'Quantity of this product ot item in the offline event. Quantity is a required field for all content items.', - type: 'number' - }, - content_type: { - label: 'Content Type', - description: 'Product type', - type: 'string' - }, - content_id: { - label: 'Content ID', - description: - 'Product or content item identifier. Content ID is a required field for all product or content items.', - type: 'string' - }, - content_name: { - label: 'Content Name', - description: 'Name of the product or content item.', - type: 'string' - }, - content_category: { - label: 'Content Category', - description: 'Category of the product or content item.', - type: 'string' - } - }, - default: { - '@arrayPath': [ - '$.properties.products', - { - price: { - '@path': 'price' - }, - quantity: { - '@path': 'quantity' - }, - content_type: { - '@path': 'type' - }, - content_id: { - '@path': 'product_id' - }, - content_name: { - '@path': 'name' - }, - content_category: { - '@path': 'category' - } - } - ] - } - }, - currency: { - label: 'Currency', - type: 'string', - required: true, - description: - 'ISO 4217 code. Required for revenue reporting. Example: "USD".List of currencies currently supported: AED, ARS, AUD, BDT, BHD, BIF, BOB, BRL, CAD, CHF, CLP, CNY, COP, CRC, CZK, DKK, DZD, EGP, EUR, GBP, GTQ, HKD, HNL, HUF, IDR, ILS, INR, ISK, JPY, KES, KHR, KRW, KWD, KZT, MAD, MOP, MXN, MYR, NGN, NIO, NOK, NZD, OMR, PEN, PHP, PHP, PKR, PLN, PYG, QAR, RON, RUB, SAR, SEK, SGD, THB, TRY, TWD, UAH, USD, VES, VND, ZAR.', - default: { - '@path': '$.properties.currency' - } - }, - value: { - label: 'Value', - type: 'number', - required: true, - description: - 'Revenue of total products or content items. Required for revenue reporting. Must be a number. e.g. 101.99 and not "101.99 USD"', - default: { - '@if': { - exists: { '@path': '$.properties.value' }, - then: { '@path': '$.properties.value' }, - else: { '@path': '$.properties.revenue' } - } - } - } + ...commonFields }, perform: (request, { payload, settings }) => { - const phone_numbers = formatPhones(payload.phone_numbers) - const emails = formatEmails(payload.email_addresses) - - if (phone_numbers.length < 1 && emails.length < 1) - throw new PayloadValidationError('TikTok Offline Conversions API requires an email address and/or phone number') - - return request('https://business-api.tiktok.com/open_api/v1.3/offline/track/', { - method: 'post', - json: { - event_set_id: settings.eventSetID, - event: payload.event, - event_id: payload.event_id ? `${payload.event_id}` : undefined, - timestamp: payload.timestamp, - context: { - user: { - phone_numbers, - emails - } - }, - properties: { - order_id: payload.order_id, - shop_id: payload.shop_id, - contents: payload.contents, - currency: payload.currency, - value: payload.value, - event_channel: payload.event_channel - }, - partner_name: 'Segment' - } - }) + return performOfflineEvent(request, settings, payload) } } diff --git a/packages/destination-actions/src/destinations/tiktok-offline-conversions/utils.ts b/packages/destination-actions/src/destinations/tiktok-offline-conversions/utils.ts new file mode 100644 index 0000000000..55dc000e5c --- /dev/null +++ b/packages/destination-actions/src/destinations/tiktok-offline-conversions/utils.ts @@ -0,0 +1,78 @@ +import { RequestClient, PayloadValidationError } from '@segment/actions-core' +import { Settings } from './generated-types' +import { Payload as ReportOfflineEventPayload } from './reportOfflineEvent/generated-types' +import { Payload as TrackNonPaymentOfflineConversionPayload } from './trackNonPaymentOfflineConversion/generated-types' +import { Payload as TrackPaymentOfflineConversionPayload } from './trackPaymentOfflineConversion/generated-types' +import { formatEmails, formatPhones, formatUserIds } from './formatter' + +type OfflineEventPayload = + | ReportOfflineEventPayload + | TrackNonPaymentOfflineConversionPayload + | TrackPaymentOfflineConversionPayload + +export function performOfflineEvent(request: RequestClient, settings: Settings, payload: OfflineEventPayload) { + const phone_numbers = formatPhones(payload.phone_numbers) + const emails = formatEmails(payload.email_addresses) + const userIds = formatUserIds(payload.external_ids) + + if (phone_numbers.length < 1 && emails.length < 1 && userIds.length < 1) + throw new PayloadValidationError( + 'TikTok Offline Conversions API requires an email address and/or phone number and or a userId' + ) + + let payloadUrl, urlTtclid + if (payload.url) { + try { + payloadUrl = new URL(payload.url) + } catch (error) { + // invalid url + } + } + + if (payloadUrl) urlTtclid = payloadUrl.searchParams.get('ttclid') + + return request('https://business-api.tiktok.com/open_api/v1.3/event/track/', { + method: 'post', + json: { + event_source: 'offline', + event_source_id: settings.eventSetID, + partner_name: 'Segment', + data: [ + { + event: payload.event, + event_time: payload.timestamp + ? Math.floor(new Date(payload.timestamp).getTime() / 1000) + : Math.floor(new Date().getTime() / 1000), + event_id: payload.event_id ? `${payload.event_id}` : undefined, + user: { + ttclid: payload.ttclid ? payload.ttclid : urlTtclid ? urlTtclid : undefined, + external_id: userIds, + phone: phone_numbers, + email: emails, + lead_id: payload.lead_id ? payload.lead_id : undefined, + ttp: payload.ttp ? payload.ttp : undefined, + ip: payload.ip ? payload.ip : undefined, + user_agent: payload.user_agent ? payload.user_agent : undefined, + locale: payload.locale ? payload.locale : undefined + }, + properties: { + contents: payload.contents ? payload.contents : [], + content_type: payload.content_type ? payload.content_type : undefined, + currency: payload.currency ? payload.currency : undefined, + value: payload.value ? payload.value : undefined, + query: payload.query ? payload.query : undefined, + description: payload.description ? payload.description : undefined, + order_id: payload.order_id ? payload.order_id : undefined, + shop_id: payload.shop_id ? payload.shop_id : undefined + }, + page: { + url: payload.url ? payload.url : undefined, + referrer: payload.referrer ? payload.referrer : undefined + }, + limited_data_use: payload.limited_data_use ? payload.limited_data_use : false, + test_event_code: payload.test_event_code ? payload.test_event_code : undefined + } + ] + } + }) +} diff --git a/packages/destination-actions/src/destinations/trackey/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/trackey/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..3d430f7052 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-trackey destination: group action - all fields 1`] = ` +Object { + "groupId": "^Vvf$jtvC", + "messageId": "^Vvf$jtvC", + "timestamp": "^Vvf$jtvC", + "traits": Object { + "testType": "^Vvf$jtvC", + }, + "userId": "^Vvf$jtvC", +} +`; + +exports[`Testing snapshot for actions-trackey destination: group action - required fields 1`] = ` +Object { + "groupId": "^Vvf$jtvC", + "timestamp": "^Vvf$jtvC", + "userId": "^Vvf$jtvC", +} +`; + +exports[`Testing snapshot for actions-trackey destination: identify action - all fields 1`] = ` +Object { + "messageId": "6aniX&1", + "timestamp": "6aniX&1", + "traits": Object { + "testType": "6aniX&1", + }, + "userId": "6aniX&1", +} +`; + +exports[`Testing snapshot for actions-trackey destination: identify action - required fields 1`] = ` +Object { + "timestamp": "6aniX&1", + "userId": "6aniX&1", +} +`; + +exports[`Testing snapshot for actions-trackey destination: track action - all fields 1`] = ` +Object { + "event": "eT[K8ft@uBryp", + "groupId": Object { + "testType": "eT[K8ft@uBryp", + }, + "messageId": "eT[K8ft@uBryp", + "properties": Object { + "testType": "eT[K8ft@uBryp", + }, + "timestamp": "eT[K8ft@uBryp", + "userId": "eT[K8ft@uBryp", +} +`; + +exports[`Testing snapshot for actions-trackey destination: track action - required fields 1`] = ` +Object { + "event": "eT[K8ft@uBryp", + "timestamp": "eT[K8ft@uBryp", + "userId": "eT[K8ft@uBryp", +} +`; diff --git a/packages/destination-actions/src/destinations/trackey/__tests__/index.test.ts b/packages/destination-actions/src/destinations/trackey/__tests__/index.test.ts new file mode 100644 index 0000000000..0cba6ea5be --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/__tests__/index.test.ts @@ -0,0 +1,24 @@ +import { createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import { baseUrl } from '../constants' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Trackey', () => { + describe('testAuthentication', () => { + it('should validate api key', async () => { + nock(baseUrl).get('/auth/me').reply(200, { + status: 'SUCCESS', + data: 'Test client' + }) + + // This should match your authentication.fields + const authData = { + apiKey: 'test-api-key' + } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/trackey/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/trackey/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..143b803168 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-trackey' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/trackey/constants.ts b/packages/destination-actions/src/destinations/trackey/constants.ts new file mode 100644 index 0000000000..01456b08d6 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/constants.ts @@ -0,0 +1,2 @@ +const base = 'https://app.trackey.io' +export const baseUrl = base + '/public-api/integrations/segment/webhook' diff --git a/packages/destination-actions/src/destinations/trackey/generated-types.ts b/packages/destination-actions/src/destinations/trackey/generated-types.ts new file mode 100644 index 0000000000..0935a87f13 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your Trackey API Key + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/trackey/group/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/trackey/group/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..270a9d4483 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/group/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Trackey's group destination action: all fields 1`] = ` +Object { + "groupId": "IF@l7Hj&FhM", + "messageId": "IF@l7Hj&FhM", + "timestamp": "IF@l7Hj&FhM", + "traits": Object { + "testType": "IF@l7Hj&FhM", + }, + "userId": "IF@l7Hj&FhM", +} +`; + +exports[`Testing snapshot for Trackey's group destination action: required fields 1`] = ` +Object { + "groupId": "IF@l7Hj&FhM", + "timestamp": "IF@l7Hj&FhM", + "userId": "IF@l7Hj&FhM", +} +`; diff --git a/packages/destination-actions/src/destinations/trackey/group/__tests__/index.test.ts b/packages/destination-actions/src/destinations/trackey/group/__tests__/index.test.ts new file mode 100644 index 0000000000..98cab8a59c --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/group/__tests__/index.test.ts @@ -0,0 +1,45 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import { baseUrl } from '../../constants' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const timestamp = '2023-02-22T15:21:15.449Z' + +describe('Trackey.group', () => { + it('Sends company data correctly', async () => { + const event = createTestEvent({ + type: 'group', + userId: 'test-user-id', + timestamp, + groupId: 'test-group-id', + traits: { 'company-property-1': 'test-value', 'company-property-2': 'test-value-2' } + }) + + nock(baseUrl) + .post('') + .reply(202, { + status: 'SUCCESS', + data: { + message: 'Account registered' + } + }) + + const response = await testDestination.testAction('group', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + + expect(response[0].status).toBe(202) + expect(response[0].data).toMatchObject({ + status: 'SUCCESS', + data: { + message: 'Account registered' + } + }) + expect(response.length).toBe(1) + }) +}) diff --git a/packages/destination-actions/src/destinations/trackey/group/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/trackey/group/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..3e4f160705 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/group/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'group' +const destinationSlug = 'Trackey' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/trackey/group/generated-types.ts b/packages/destination-actions/src/destinations/trackey/group/generated-types.ts new file mode 100644 index 0000000000..0afa1cabbb --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/group/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user identifier to associate the event with + */ + userId: string + /** + * A unique value for each event. + */ + messageId?: string + /** + * Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z + */ + timestamp: string + /** + * Company profile information + */ + traits?: { + [k: string]: unknown + } + /** + * Company ID associated with the event + */ + groupId: string +} diff --git a/packages/destination-actions/src/destinations/trackey/group/index.ts b/packages/destination-actions/src/destinations/trackey/group/index.ts new file mode 100644 index 0000000000..ee7e52ec9e --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/group/index.ts @@ -0,0 +1,62 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { baseUrl } from '../constants' + +const action: ActionDefinition = { + title: 'Register Company', + description: 'Register a user in a company', + defaultSubscription: 'type = "group"', + fields: { + userId: { + label: 'User ID', + type: 'string', + required: true, + description: 'The user identifier to associate the event with', + default: { '@path': '$.userId' } + }, + messageId: { + label: 'Message ID', + type: 'string', + description: 'A unique value for each event.', + default: { + '@path': '$.messageId' + } + }, + timestamp: { + label: 'Event Timestamp', + type: 'string', + required: true, + description: 'Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z', + default: { '@path': '$.timestamp' } + }, + traits: { + label: 'COmpany Traits', + type: 'object', + required: false, + description: 'Company profile information', + default: { '@path': '$.traits' } + }, + groupId: { + label: 'Group ID', + type: 'string', + required: true, + description: 'Company ID associated with the event', + default: { '@path': '$.groupId' } + } + }, + perform: (request, { payload }) => { + return request(baseUrl, { + method: 'POST', + json: { + userId: payload.userId, + messageId: payload.messageId, + timestamp: payload.timestamp, + traits: payload.traits, + groupId: payload.groupId + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/trackey/identify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/trackey/identify/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..96e4b60fcf --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/identify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Trackey's identify destination action: all fields 1`] = ` +Object { + "messageId": "&RuBnhp", + "timestamp": "&RuBnhp", + "traits": Object { + "testType": "&RuBnhp", + }, + "userId": "&RuBnhp", +} +`; + +exports[`Testing snapshot for Trackey's identify destination action: required fields 1`] = ` +Object { + "timestamp": "&RuBnhp", + "userId": "&RuBnhp", +} +`; diff --git a/packages/destination-actions/src/destinations/trackey/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/trackey/identify/__tests__/index.test.ts new file mode 100644 index 0000000000..e2ae88f5c2 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/identify/__tests__/index.test.ts @@ -0,0 +1,44 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import { baseUrl } from '../../constants' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const timestamp = '2023-02-22T15:21:15.449Z' + +describe('Trackey.identify', () => { + it('Sends an account profile succesfully', async () => { + const event = createTestEvent({ + type: 'identify', + userId: 'test-user-id', + timestamp, + traits: { 'test-property': 'test-value', 'test-property-2': 'test-value-2' } + }) + + nock(baseUrl) + .post('') + .reply(202, { + status: 'SUCCESS', + data: { + message: 'User identified' + } + }) + + const response = await testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + + expect(response[0].status).toBe(202) + expect(response[0].data).toMatchObject({ + status: 'SUCCESS', + data: { + message: 'User identified' + } + }) + expect(response.length).toBe(1) + }) +}) diff --git a/packages/destination-actions/src/destinations/trackey/identify/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/trackey/identify/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..f508e32619 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/identify/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'identify' +const destinationSlug = 'Trackey' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/trackey/identify/generated-types.ts b/packages/destination-actions/src/destinations/trackey/identify/generated-types.ts new file mode 100644 index 0000000000..8abe8607ec --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/identify/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user identifier to associate the event with + */ + userId: string + /** + * A unique value for each event. + */ + messageId?: string + /** + * Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z + */ + timestamp: string + /** + * User profile information + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/trackey/identify/index.ts b/packages/destination-actions/src/destinations/trackey/identify/index.ts new file mode 100644 index 0000000000..2850dc1be0 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/identify/index.ts @@ -0,0 +1,54 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { baseUrl } from '../constants' + +const action: ActionDefinition = { + title: 'Identify User', + description: 'Identify a user', + defaultSubscription: 'type = "identify"', + fields: { + userId: { + label: 'User ID', + type: 'string', + required: true, + description: 'The user identifier to associate the event with', + default: { '@path': '$.userId' } + }, + messageId: { + label: 'Message ID', + type: 'string', + description: 'A unique value for each event.', + default: { + '@path': '$.messageId' + } + }, + timestamp: { + label: 'Event Timestamp', + type: 'string', + required: true, + description: 'Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z', + default: { '@path': '$.timestamp' } + }, + traits: { + label: 'User Traits', + type: 'object', + required: false, + description: 'User profile information', + default: { '@path': '$.traits' } + } + }, + perform: (request, { payload }) => { + return request(baseUrl, { + method: 'POST', + json: { + userId: payload.userId, + messageId: payload.messageId, + timestamp: payload.timestamp, + traits: payload.traits + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/trackey/index.ts b/packages/destination-actions/src/destinations/trackey/index.ts new file mode 100644 index 0000000000..099ca92ec2 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/index.ts @@ -0,0 +1,38 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import identify from './identify' +import track from './track' +import group from './group' + +const destination: DestinationDefinition = { + name: 'Trackey', + slug: 'actions-trackey', + mode: 'cloud', + description: 'Send Segment events to Trackey', + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Your Trackey API Key', + type: 'string', + required: true + } + } + }, + extendRequest: ({ settings }) => { + return { + headers: { + api_key: settings.apiKey, + 'Content-Type': 'application/json' + } + } + }, + actions: { + identify, + track, + group + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/trackey/track/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/trackey/track/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..20b8c7c287 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/track/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Trackey's track destination action: all fields 1`] = ` +Object { + "event": "Z8LFHc1gCw9y*5", + "groupId": Object { + "testType": "Z8LFHc1gCw9y*5", + }, + "messageId": "Z8LFHc1gCw9y*5", + "properties": Object { + "testType": "Z8LFHc1gCw9y*5", + }, + "timestamp": "Z8LFHc1gCw9y*5", + "userId": "Z8LFHc1gCw9y*5", +} +`; + +exports[`Testing snapshot for Trackey's track destination action: required fields 1`] = ` +Object { + "event": "Z8LFHc1gCw9y*5", + "timestamp": "Z8LFHc1gCw9y*5", + "userId": "Z8LFHc1gCw9y*5", +} +`; diff --git a/packages/destination-actions/src/destinations/trackey/track/__tests__/index.test.ts b/packages/destination-actions/src/destinations/trackey/track/__tests__/index.test.ts new file mode 100644 index 0000000000..4d47bfb2c4 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/track/__tests__/index.test.ts @@ -0,0 +1,46 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import nock from 'nock' +import { baseUrl } from '../../constants' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +const timestamp = '2023-02-22T15:21:15.449Z' + +describe('Trackey.track', () => { + it('Sends the tracked events data correctly', async () => { + const event = createTestEvent({ + type: 'track', + userId: 'test-user-id', + event: 'test-event', + timestamp, + groupId: 'test-group-id', + properties: { 'event-prop-1': 'test-value', 'event-prop-2': 'test-value-2' } + }) + + nock(baseUrl) + .post('') + .reply(202, { + status: 'SUCCESS', + data: { + message: 'Event tracked' + } + }) + + const response = await testDestination.testAction('group', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + + expect(response[0].status).toBe(202) + expect(response[0].data).toMatchObject({ + status: 'SUCCESS', + data: { + message: 'Event tracked' + } + }) + expect(response.length).toBe(1) + }) +}) diff --git a/packages/destination-actions/src/destinations/trackey/track/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/trackey/track/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..1e2afc3ef7 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/track/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'track' +const destinationSlug = 'Trackey' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/trackey/track/generated-types.ts b/packages/destination-actions/src/destinations/trackey/track/generated-types.ts new file mode 100644 index 0000000000..85c91413d9 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/track/generated-types.ts @@ -0,0 +1,32 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The user identifier to associate the event with + */ + userId: string + /** + * Name of the Segment track() event + */ + event: string + /** + * A unique value for each event. + */ + messageId?: string + /** + * Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z + */ + timestamp: string + /** + * Additional information associated with the track() event + */ + properties?: { + [k: string]: unknown + } + /** + * Company ID associated with the event + */ + groupId?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/trackey/track/index.ts b/packages/destination-actions/src/destinations/trackey/track/index.ts new file mode 100644 index 0000000000..0693324b09 --- /dev/null +++ b/packages/destination-actions/src/destinations/trackey/track/index.ts @@ -0,0 +1,70 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { baseUrl } from '../constants' + +const action: ActionDefinition = { + title: 'Track Event', + description: 'Track an event', + defaultSubscription: 'type = "track"', + fields: { + userId: { + label: 'User ID', + type: 'string', + required: true, + description: 'The user identifier to associate the event with', + default: { '@path': '$.userId' } + }, + event: { + label: 'Event Name', + type: 'string', + required: true, + description: 'Name of the Segment track() event', + default: { '@path': '$.event' } + }, + messageId: { + label: 'Message ID', + type: 'string', + description: 'A unique value for each event.', + default: { + '@path': '$.messageId' + } + }, + timestamp: { + label: 'Event Timestamp', + type: 'string', + required: true, + description: 'Timestamp that the event took place, in ISO 8601 format. e.g. 2019-06-12T19:11:01.152Z', + default: { '@path': '$.timestamp' } + }, + properties: { + label: 'Event Properties', + type: 'object', + required: false, + description: 'Additional information associated with the track() event', + default: { '@path': '$.properties' } + }, + groupId: { + label: 'Group ID', + type: 'object', + required: false, + description: 'Company ID associated with the event', + default: { '@path': '$.context.group_id' } + } + }, + perform: (request, { payload }) => { + return request(baseUrl, { + method: 'POST', + json: { + userId: payload.userId, + event: payload.event, + messageId: payload.messageId, + timestamp: payload.timestamp, + properties: payload.properties, + groupId: payload.groupId + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/twilio-studio/triggerStudioFlow/index.ts b/packages/destination-actions/src/destinations/twilio-studio/triggerStudioFlow/index.ts index e62cc0311d..e3c9bd65fd 100644 --- a/packages/destination-actions/src/destinations/twilio-studio/triggerStudioFlow/index.ts +++ b/packages/destination-actions/src/destinations/twilio-studio/triggerStudioFlow/index.ts @@ -37,18 +37,21 @@ const action: ActionDefinition = { userId: { label: 'User ID', description: 'A Distinct User ID', - type: 'hidden', + type: 'string', + unsafe_hidden: true, default: { '@path': '$.userId' } }, anonymousId: { label: 'Anonymous ID', description: 'A Distinct External ID', - type: 'hidden', + type: 'string', + unsafe_hidden: true, default: { '@path': '$.anonymousId' } }, eventType: { label: 'Event type', - type: 'hidden', + type: 'string', + unsafe_hidden: true, description: 'The type of the event being performed.', required: true, default: { diff --git a/packages/destination-actions/src/destinations/usermaven/request-params.ts b/packages/destination-actions/src/destinations/usermaven/request-params.ts index 77e0af3bd2..0cbb358e74 100644 --- a/packages/destination-actions/src/destinations/usermaven/request-params.ts +++ b/packages/destination-actions/src/destinations/usermaven/request-params.ts @@ -86,11 +86,19 @@ export const resolveRequestPayload = (settings: Settings, payload: Record { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock(endpoint).post('/v1/verify').reply(200, {}) + + await expect(testDestination.testAuthentication({ apiKey: 'TEST' })).resolves.not.toThrowError() + }) + + it('should fail authentication inputs', async () => { + nock(endpoint).post('/v1/verify').reply(403, { + code: 'AUTH_NOT_AUTHENTICATED', + error: 'You are not logged in' + }) + + await expect(testDestination.testAuthentication({ apiKey: '000' })).rejects.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/usermotion/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/usermotion/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..fa121b1ab6 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-usermotion' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/usermotion/generated-types.ts b/packages/destination-actions/src/destinations/usermotion/generated-types.ts new file mode 100644 index 0000000000..90e55008c8 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Your UserMotion API Key + */ + apiKey: string +} diff --git a/packages/destination-actions/src/destinations/usermotion/group/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/usermotion/group/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..a85e2fde7c --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/group/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,18 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Usermotion's group destination action: all fields 1`] = ` +Object { + "id": "vd^9vzD#xh*", + "properties": Object { + "testType": "vd^9vzD#xh*", + "website": "vd^9vzD#xh*", + }, +} +`; + +exports[`Testing snapshot for Usermotion's group destination action: required fields 1`] = ` +Object { + "id": "vd^9vzD#xh*", + "properties": Object {}, +} +`; diff --git a/packages/destination-actions/src/destinations/usermotion/group/__tests__/index.test.ts b/packages/destination-actions/src/destinations/usermotion/group/__tests__/index.test.ts new file mode 100644 index 0000000000..b53cf8b491 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/group/__tests__/index.test.ts @@ -0,0 +1,51 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const endpoint = ' https://api.usermotion.com' + +describe('Usermotion.group', () => { + test('should map groupId and traits and pass them into UserMotion.group', async () => { + nock(`${endpoint}`).post(`/v1/group`).reply(200, {}) + + const event = createTestEvent({ + groupId: '1453', + traits: { website: 'usermotion.com' } + }) + + const responses = await testDestination.testAction('group', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.body).toBe(JSON.stringify({ id: '1453', properties: { website: 'usermotion.com' } })) + }) + + test('should not call group if groupId is not provided', async () => { + nock(`${endpoint}`).post(`/v1/group`).reply(200, {}) + + const event = createTestEvent({ + type: 'group', + groupId: null, + traits: { + website: 'usermotion.com' + } + }) + + await expect( + testDestination.testAction('group', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + ).rejects.toThrowError("The root value is missing the required field 'groupId'.") + }) +}) diff --git a/packages/destination-actions/src/destinations/usermotion/group/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/usermotion/group/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..bcc364f02b --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/group/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'group' +const destinationSlug = 'Usermotion' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/usermotion/group/generated-types.ts b/packages/destination-actions/src/destinations/usermotion/group/generated-types.ts new file mode 100644 index 0000000000..a007f5b022 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/group/generated-types.ts @@ -0,0 +1,18 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * A identifier for a known company. + */ + groupId: string + /** + * The website address of the identified company + */ + website?: string + /** + * Traits to associate with the company + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/usermotion/group/index.ts b/packages/destination-actions/src/destinations/usermotion/group/index.ts new file mode 100644 index 0000000000..b5a6ff8a25 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/group/index.ts @@ -0,0 +1,50 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Identify Company', + description: 'Create or update a company in UserMotion', + defaultSubscription: 'type = "group"', + fields: { + groupId: { + type: 'string', + description: 'A identifier for a known company.', + label: 'Group ID', + required: true, + default: { '@path': '$.groupId' } + }, + website: { + type: 'string', + label: 'Website', + description: 'The website address of the identified company', + default: { + '@if': { + exists: { '@path': '$.traits.website' }, + then: { '@path': '$.traits.website' }, + else: { '@path': '$.properties.website' } + } + } + }, + traits: { + type: 'object', + label: 'Traits', + description: 'Traits to associate with the company', + default: { '@path': '$.traits' } + } + }, + perform: (request, { payload }) => { + return request('https://api.usermotion.com/v1/group', { + method: 'post', + json: { + id: payload.groupId, + properties: { + ...payload.traits, + website: payload.website + } + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/usermotion/identify/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/usermotion/identify/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..d48b9d72f9 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/identify/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Usermotion's identify destination action: all fields 1`] = ` +Object { + "id": "m5@snjS^ncZB*", + "properties": Object { + "anonymousId": "m5@snjS^ncZB*", + "email": "gi@kigot.bh", + "testType": "m5@snjS^ncZB*", + }, +} +`; + +exports[`Testing snapshot for Usermotion's identify destination action: required fields 1`] = ` +Object { + "id": "m5@snjS^ncZB*", + "properties": Object { + "email": "gi@kigot.bh", + }, +} +`; diff --git a/packages/destination-actions/src/destinations/usermotion/identify/__tests__/index.test.ts b/packages/destination-actions/src/destinations/usermotion/identify/__tests__/index.test.ts new file mode 100644 index 0000000000..00e00b84f1 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/identify/__tests__/index.test.ts @@ -0,0 +1,73 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const endpoint = ' https://api.usermotion.com' + +describe('Usermotion.identify', () => { + test('should map userId and traits and pass them into UserMotion.identify', async () => { + nock(`${endpoint}`).post(`/v1/identify`).reply(200, {}) + + const event = createTestEvent({ + userId: '1453', + anonymousId: 'test-anonymous-id', + traits: { email: 'amirali@usermotion.com' } + }) + + const responses = await testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.body).toBe( + JSON.stringify({ id: '1453', properties: { email: 'amirali@usermotion.com', anonymousId: 'test-anonymous-id' } }) + ) + }) + + test('should not call identify if userId is not provided', async () => { + nock(`${endpoint}`).post(`/v1/identify`).reply(200, {}) + + const event = createTestEvent({ + type: 'identify', + userId: null, + traits: { + email: 'amirali@usermotion.com' + } + }) + + await expect( + testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + ).rejects.toThrowError("The root value is missing the required field 'userId'.") + }) + + test('should not call identify if email is not provided', async () => { + nock(`${endpoint}`).post(`/v1/identify`).reply(200, {}) + const event = createTestEvent({ + type: 'identify', + userId: '1453', + traits: {} + }) + + await expect( + testDestination.testAction('identify', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + ).rejects.toThrowError("The root value is missing the required field 'email'.") + }) +}) diff --git a/packages/destination-actions/src/destinations/usermotion/identify/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/usermotion/identify/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..ae2bfc5c07 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/identify/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'identify' +const destinationSlug = 'Usermotion' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/usermotion/identify/generated-types.ts b/packages/destination-actions/src/destinations/usermotion/identify/generated-types.ts new file mode 100644 index 0000000000..2edf2821f0 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/identify/generated-types.ts @@ -0,0 +1,22 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * A identifier for a known user. + */ + userId: string + /** + * An identifier for an anonymous user + */ + anonymousId?: string + /** + * The email address of the identified user + */ + email: string + /** + * Traits to associate with the user + */ + traits?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/usermotion/identify/index.ts b/packages/destination-actions/src/destinations/usermotion/identify/index.ts new file mode 100644 index 0000000000..2f3815fb9b --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/identify/index.ts @@ -0,0 +1,59 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Identify', + description: 'Identify user in UserMotion', + defaultSubscription: 'type = "identify"', + fields: { + userId: { + type: 'string', + description: 'A identifier for a known user.', + label: 'User ID', + required: true, + default: { '@path': '$.userId' } + }, + anonymousId: { + type: 'string', + required: false, + description: 'An identifier for an anonymous user', + label: 'Anonymous ID', + default: { '@path': '$.anonymousId' } + }, + email: { + type: 'string', + required: true, + label: 'Email', + description: 'The email address of the identified user', + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.email' } + } + } + }, + traits: { + type: 'object', + label: 'Traits', + description: 'Traits to associate with the user', + default: { '@path': '$.traits' } + } + }, + perform: (request, { payload }) => { + return request('https://api.usermotion.com/v1/identify', { + method: 'post', + json: { + id: payload.userId, + properties: { + ...payload.traits, + email: payload.email, + anonymousId: payload.anonymousId + } + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/usermotion/index.ts b/packages/destination-actions/src/destinations/usermotion/index.ts new file mode 100644 index 0000000000..d1e567aa9d --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/index.ts @@ -0,0 +1,67 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' +import { defaultValues } from '@segment/actions-core' + +import identify from './identify' + +import group from './group' + +import track from './track' + +const presets: DestinationDefinition['presets'] = [ + { + name: 'Identify User', + subscribe: 'type = "identify"', + partnerAction: 'identify', + mapping: defaultValues(identify.fields), + type: 'automatic' + }, + { + name: 'Identify Group', + subscribe: 'type = "group"', + partnerAction: 'group', + mapping: defaultValues(group.fields), + type: 'automatic' + }, + { + name: 'Track Analytics Event', + subscribe: 'type = "track" or type = "page"', + partnerAction: 'track', + mapping: defaultValues(track.fields), + type: 'automatic' + } +] + +const destination: DestinationDefinition = { + name: 'UserMotion (Actions)', + slug: 'actions-usermotion', + mode: 'cloud', + description: 'Send server-side events to the UserMotion REST API.', + extendRequest: ({ settings }) => { + return { + headers: { Authorization: `Basic ${settings.apiKey}`, 'Content-Type': 'application/json' } + } + }, + authentication: { + scheme: 'custom', + fields: { + apiKey: { + label: 'API Key', + description: 'Your UserMotion API Key', + type: 'string', + required: true + } + }, + testAuthentication: (request) => { + return request('https://api.usermotion.com/v1/verify', { method: 'POST' }) + } + }, + presets, + actions: { + identify, + group, + track + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/usermotion/track/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/usermotion/track/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..b7c2f9fd03 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/track/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Usermotion's track destination action: all fields 1`] = ` +Object { + "anonymousId": "D7eiUOIRWs#EuRzn@d)n", + "email": "riknonolu@refe.sa", + "event": "D7eiUOIRWs#EuRzn@d)n", + "properties": Object { + "testType": "D7eiUOIRWs#EuRzn@d)n", + }, + "userId": "D7eiUOIRWs#EuRzn@d)n", +} +`; + +exports[`Testing snapshot for Usermotion's track destination action: required fields 1`] = ` +Object { + "event": "D7eiUOIRWs#EuRzn@d)n", + "properties": Object {}, + "userId": "D7eiUOIRWs#EuRzn@d)n", +} +`; diff --git a/packages/destination-actions/src/destinations/usermotion/track/__tests__/index.test.ts b/packages/destination-actions/src/destinations/usermotion/track/__tests__/index.test.ts new file mode 100644 index 0000000000..8cf92fd4d6 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/track/__tests__/index.test.ts @@ -0,0 +1,100 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) + +const endpoint = ' https://api.usermotion.com' + +describe('Usermotion.track', () => { + test('should map userId and traits and pass them into UserMotion.track', async () => { + nock(`${endpoint}`).post(`/v1/track`).reply(200, {}) + + const event = createTestEvent({ + type: 'track', + properties: { clickedButton: true }, + userId: '1453', + anonymousId: null, + event: 'Test Event' + }) + + const responses = await testDestination.testAction('track', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.body).toBe( + JSON.stringify({ event: 'Test Event', userId: '1453', properties: { clickedButton: true } }) + ) + }) + + test('should map userId and traits and pass them into UserMotion.pageview', async () => { + nock(`${endpoint}`).post(`/v1/track`).reply(200, {}) + + const event = createTestEvent({ + type: 'page', + properties: { clickedButton: true }, + userId: '1453', + anonymousId: null, + event: 'Page View' + }) + + const responses = await testDestination.testAction('track', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + + expect(responses[0].status).toBe(200) + expect(responses[0].options.body).toBe( + JSON.stringify({ event: 'Page View', userId: '1453', properties: { clickedButton: true } }) + ) + }) + + test('should not call track if userId is not provided', async () => { + nock(`${endpoint}`).post(`/v1/track`).reply(200, {}) + + const event = createTestEvent({ + type: 'track', + userId: null, + traits: { + email: 'amirali@usermotion.com' + } + }) + + await expect( + testDestination.testAction('track', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + ).rejects.toThrowError("The root value is missing the required field 'userId'.") + }) + + test('should not call track if eventName is not provided', async () => { + nock(`${endpoint}`).post(`/v1/track`).reply(200, {}) + const event = createTestEvent({ + type: 'track', + userId: '1453', + event: '' + }) + + await expect( + testDestination.testAction('track', { + event, + useDefaultMappings: true, + settings: { + apiKey: 'test-api-key' + } + }) + ).rejects.toThrowError("The root value is missing the required field 'eventName'.") + }) +}) diff --git a/packages/destination-actions/src/destinations/usermotion/track/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/usermotion/track/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..13204f0a6d --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/track/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'track' +const destinationSlug = 'Usermotion' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/usermotion/track/generated-types.ts b/packages/destination-actions/src/destinations/usermotion/track/generated-types.ts new file mode 100644 index 0000000000..cc6619e578 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/track/generated-types.ts @@ -0,0 +1,26 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * A identifier for a known user. + */ + userId: string + /** + * An identifier for an anonymous user + */ + anonymousId?: string + /** + * The email address for the user + */ + email?: string + /** + * The name of the track() event or page() event + */ + eventName: string + /** + * Properties to send with the event. + */ + properties?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/usermotion/track/index.ts b/packages/destination-actions/src/destinations/usermotion/track/index.ts new file mode 100644 index 0000000000..361b5dcb33 --- /dev/null +++ b/packages/destination-actions/src/destinations/usermotion/track/index.ts @@ -0,0 +1,74 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +const action: ActionDefinition = { + title: 'Track Analytics Event', + description: 'Send user and page events to UserMotion', + defaultSubscription: 'type = "track" or type = "page"', + fields: { + userId: { + type: 'string', + required: true, + description: 'A identifier for a known user.', + label: 'User ID', + default: { '@path': '$.userId' } + }, + anonymousId: { + type: 'string', + required: false, + description: 'An identifier for an anonymous user', + label: 'Anonymous ID', + default: { '@path': '$.anonymousId' } + }, + email: { + type: 'string', + required: false, + description: 'The email address for the user', + label: 'Email address', + default: { + '@if': { + exists: { '@path': '$.context.traits.email' }, + then: { '@path': '$.context.traits.email' }, + else: { '@path': '$.properties.email' } + } + } + }, + eventName: { + type: 'string', + required: true, + description: 'The name of the track() event or page() event', + label: 'Event Name', + default: { + '@if': { + exists: { '@path': '$.event' }, + then: { '@path': '$.event' }, + else: { '@path': '$.name' } + } + } + }, + properties: { + type: 'object', + required: false, + description: 'Properties to send with the event.', + label: 'Event Properties', + default: { '@path': '$.properties' } + } + }, + perform: (request, { payload }) => { + return request('https://api.usermotion.com/v1/track', { + method: 'post', + json: { + event: payload.eventName, + userId: payload.userId, + email: payload.email, + anonymousId: payload.anonymousId, + properties: { + ...payload.properties + } + } + }) + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/__tests__/index.test.ts b/packages/destination-actions/src/destinations/yahoo-audiences/__tests__/index.test.ts new file mode 100644 index 0000000000..fb117fa6cb --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/__tests__/index.test.ts @@ -0,0 +1,131 @@ +import nock from 'nock' +import { IntegrationError, createTestIntegration } from '@segment/actions-core' + +import Destination from '../index' +import { gen_update_segment_payload } from '../utils-rt' +import { Payload } from '../updateSegment/generated-types' + +const AUDIENCE_ID = 'aud_123456789012345678901234567' // References audienceSettings.audience_id +const AUDIENCE_KEY = 'sneakers_buyers' // References audienceSettings.audience_key +const ENGAGE_SPACE_ID = 'acme_corp_engage_space' // References settings.engage_space_id +const MDM_ID = 'mdm 123' // References settings.mdm_id +const CUST_DESC = 'ACME Corp' // References settings.customer_desc + +const createAudienceInput = { + settings: { + engage_space_id: ENGAGE_SPACE_ID, + mdm_id: MDM_ID, + customer_desc: CUST_DESC + }, + audienceName: '', + audienceSettings: { + personas: { + computation_key: AUDIENCE_KEY, + computation_id: AUDIENCE_ID + } + } +} + +describe('Yahoo Audiences', () => { + describe('createAudience() function', () => { + let testDestination: any + const OLD_ENV = process.env + beforeEach(() => { + jest.resetModules() // Most important - it clears the cache + process.env = { ...OLD_ENV } // Make a copy + process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_SECRET = 'yoda' + process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_ID = 'luke' + testDestination = createTestIntegration(Destination) + }) + + afterAll(() => { + process.env = OLD_ENV // Restore old environment + }) + + describe('Success cases', () => { + it('It should create the audience successfully', async () => { + nock('https://datax.yahooapis.com').put(`/v1/taxonomy/append/${ENGAGE_SPACE_ID}`).reply(202, { + anything: '123' + }) + + //createAudienceInput.audienceSettings.identifier = 'anything' + const result = await testDestination.createAudience(createAudienceInput) + expect(result.externalId).toBe(AUDIENCE_ID) + }) + }) + describe('Failure cases', () => { + it('should throw an error when engage_space_id setting is missing', async () => { + createAudienceInput.settings.engage_space_id = '' + await expect(testDestination.createAudience(createAudienceInput)).rejects.toThrowError(IntegrationError) + }) + }) + }) + + describe('gen_update_segment_payload() function', () => { + describe('Success cases', () => { + it('trivial', () => { + // Given + const payloads: Payload[] = [{} as Payload] + + // When + const result = gen_update_segment_payload(payloads) + + // Then + expect(result).toBeTruthy() + }) + + it('should group multiple payloads from the same user into one Yahoo event payload', () => { + // Given + const payloads: Payload[] = [ + { + gdpr_flag: false, + segment_audience_id: 'aud_123', + segment_audience_key: 'sneakers_buyers', + segment_computation_action: 'enter', + email: 'bugsbunny@warnerbros.com', + advertising_id: '', + phone: '', + event_attributes: { + sneakers_buyers: true + }, + identifier: 'email' + } as Payload, + { + gdpr_flag: false, + segment_audience_id: 'aud_234', + segment_audience_key: 'sneakers_buyers', + segment_computation_action: 'enter', + email: 'bugsbunny@warnerbros.com', + advertising_id: '', + phone: '', + event_attributes: { + sneakers_buyers: true + }, + identifier: 'email' + } as Payload, + { + gdpr_flag: false, + segment_audience_id: 'aud_123', + segment_audience_key: 'sneakers_buyers', + segment_computation_action: 'enter', + email: 'daffyduck@warnerbros.com', + advertising_id: '', + phone: '', + event_attributes: { + sneakers_buyers: true + }, + identifier: 'email' + } as Payload + ] + + // When + const result = gen_update_segment_payload(payloads) + + // Then + expect(result).toBeTruthy() + expect(result.data.length).toBe(2) + expect((result.data as any)[0][4]).toContain(';') + }) + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/constants.ts b/packages/destination-actions/src/destinations/yahoo-audiences/constants.ts new file mode 100644 index 0000000000..7e3698eb8c --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/constants.ts @@ -0,0 +1,2 @@ +export const TAXONOMY_BASE_URL = 'https://datax.yahooapis.com' +export const REALTIME_BASE_URL = 'https://dataxonline.yahoo.com/online/audience' diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/generated-types.ts b/packages/destination-actions/src/destinations/yahoo-audiences/generated-types.ts new file mode 100644 index 0000000000..fe1b01a1fb --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/generated-types.ts @@ -0,0 +1,24 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * Yahoo MDM ID provided by Yahoo representative + */ + mdm_id: string + /** + * Engage Space Id found in Unify > Settings > API Access + */ + engage_space_id: string + /** + * Engage space name and description + */ + customer_desc?: string +} +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface AudienceSettings { + /** + * Placeholder field to allow the audience to be created. Do not change this + */ + placeholder?: boolean +} diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/index.ts b/packages/destination-actions/src/destinations/yahoo-audiences/index.ts new file mode 100644 index 0000000000..9bd5de134b --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/index.ts @@ -0,0 +1,174 @@ +import type { AudienceDestinationDefinition, ModifiedResponse } from '@segment/actions-core' +import { IntegrationError } from '@segment/actions-core' +import type { Settings, AudienceSettings } from './generated-types' +import { generate_jwt } from './utils-rt' +import updateSegment from './updateSegment' +import { gen_customer_taxonomy_payload, gen_segment_subtaxonomy_payload, update_taxonomy } from './utils-tax' +type PersonasSettings = { + computation_id: string + computation_key: string + parent_id: string +} +interface RefreshTokenResponse { + access_token: string +} + +const destination: AudienceDestinationDefinition = { + name: 'Yahoo Audiences', + slug: 'actions-yahoo-audiences', + mode: 'cloud', + description: 'Sync Segment Engage Audiences to Yahoo Ads', + authentication: { + scheme: 'oauth2', + fields: { + mdm_id: { + label: 'MDM ID', + description: 'Yahoo MDM ID provided by Yahoo representative', + type: 'string', + required: true + }, + engage_space_id: { + label: 'Engage Space Id', + description: 'Engage Space Id found in Unify > Settings > API Access', + type: 'string', + required: true + }, + customer_desc: { + label: 'Customer Description', + description: 'Engage space name and description', + type: 'string', + required: false + } + }, + testAuthentication: async (request, { settings }) => { + if (!process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_SECRET) { + throw new IntegrationError('Missing Taxonomy API client secret', 'MISSING_REQUIRED_FIELD', 400) + } + if (!process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_ID) { + throw new IntegrationError('Missing Taxonomy API client Id', 'MISSING_REQUIRED_FIELD', 400) + } + // Used to create top-level customer node + const tx_creds = { + tx_client_key: process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_ID, + tx_client_secret: process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_SECRET + } + + const body_form_data = gen_customer_taxonomy_payload(settings) + // Throw error if engage_space_id contains special characters other then [a-zA-Z0-9] and "_" (underscore) + // This is to prevent the user from creating a customer node with a name that is not allowed by Yahoo + if (!/^[A-Za-z0-9_]+$/.test(settings.engage_space_id)) { + throw new IntegrationError( + 'Invalid Engage Space Id setting. Engage Space Id can be located in Unify > Settings > API Access', + 'INVALID_GLOBAL_SETTING', + 400 + ) + } else { + // The last 2 params are undefined because statsContext.statsClient and statsContext.tags are not available testAuthentication() + return await update_taxonomy('', tx_creds, request, body_form_data, undefined, undefined) + } + }, + refreshAccessToken: async (request, { auth }) => { + // Refresh Realtime API token (Oauth2 client_credentials) + let rt_client_key = '' + let rt_client_secret = '' + // Added try-catch in a case we don't update the vault + try { + rt_client_key = JSON.parse(auth.clientId)['rt_api'] + rt_client_secret = JSON.parse(auth.clientSecret)['rt_api'] + } catch (err) { + rt_client_key = auth.clientId + rt_client_secret = auth.clientSecret + } + + const jwt = generate_jwt(rt_client_key, rt_client_secret) + const res: ModifiedResponse = await request( + 'https://id.b2b.yahooinc.com/identity/oauth2/access_token', + { + method: 'POST', + body: new URLSearchParams({ + client_assertion: jwt, + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + grant_type: 'client_credentials', + scope: 'audience', + realm: 'dataxonline' + }) + } + ) + const rt_access_token = res.data.access_token + return { accessToken: rt_access_token } + } + }, + audienceFields: { + placeholder: { + type: 'boolean', + label: 'Placeholder Setting', + description: 'Placeholder field to allow the audience to be created. Do not change this', + default: true + } + // This is a required object, but we don't need to define any fields + // Placeholder setting will be removed once we make AudienceSettings optional + }, + audienceConfig: { + mode: { + type: 'synced', // Indicates that the audience is synced on some schedule + full_audience_sync: false // If true, we send the entire audience. If false, we just send the delta. + }, + + async createAudience(request, createAudienceInput) { + const audienceSettings = createAudienceInput.audienceSettings + // @ts-ignore type is not defined, and we will define it later + const personas = audienceSettings.personas as PersonasSettings + if (!personas) { + throw new IntegrationError('Missing computation parameters: Id and Key', 'MISSING_REQUIRED_FIELD', 400) + } + + const engage_space_id = createAudienceInput.settings?.engage_space_id + const audience_id = personas.computation_id + const audience_key = personas.computation_key + + const statsClient = createAudienceInput?.statsContext?.statsClient + const statsTags = createAudienceInput?.statsContext?.tags + + if (!engage_space_id) { + throw new IntegrationError('Create Audience: missing setting "Engage space Id" ', 'MISSING_REQUIRED_FIELD', 400) + } + + if (!process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_SECRET) { + throw new IntegrationError('Missing Taxonomy API client secret', 'MISSING_REQUIRED_FIELD', 400) + } + if (!process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_ID) { + throw new IntegrationError('Missing Taxonomy API client Id', 'MISSING_REQUIRED_FIELD', 400) + } + + const input = { + segment_audience_id: audience_id, + segment_audience_key: audience_key, + engage_space_id: engage_space_id + } + + const body_form_data = gen_segment_subtaxonomy_payload(input) + + const tx_creds = { + tx_client_key: process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_ID, + tx_client_secret: process.env.ACTIONS_YAHOO_AUDIENCES_TAXONOMY_CLIENT_SECRET + } + + await update_taxonomy(engage_space_id, tx_creds, request, body_form_data, statsClient, statsTags) + + return { externalId: audience_id } + }, + async getAudience(_, getAudienceInput) { + // getAudienceInput.externalId represents audience ID that was created in createAudience + const audience_id = getAudienceInput.externalId + if (!audience_id) { + throw new IntegrationError('Missing audience_id value', 'MISSING_REQUIRED_FIELD', 400) + } + return { externalId: audience_id } + } + }, + + actions: { + updateSegment + } +} +export default destination diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/types.ts b/packages/destination-actions/src/destinations/yahoo-audiences/types.ts new file mode 100644 index 0000000000..95dd8a7321 --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/types.ts @@ -0,0 +1,34 @@ +export interface CredsObj { + tx_client_key: string + tx_client_secret: string +} + +export interface TaxonomyObject { + id: string + name: string + description: string + users: { + include: [string] + } + subTaxonomy: [ + { + id: string + name: string + type: string + } + ] +} + +export interface YahooPayload { + schema: Array + data: Array + gdpr: boolean + gdpr_euconsent?: string | undefined +} + +export interface YahooSubTaxonomy { + segment_audience_id: string + segment_audience_key: string + engage_space_id: string + //identifier: string +} diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/__tests__/index.test.ts b/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/__tests__/index.test.ts new file mode 100644 index 0000000000..8fe159117a --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/__tests__/index.test.ts @@ -0,0 +1,192 @@ +import nock from 'nock' +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import Destination from '../../index' + +const testDestination = createTestIntegration(Destination) +interface AuthTokens { + accessToken: string + refreshToken: string +} + +const auth: AuthTokens = { + accessToken: 'test', + refreshToken: '' +} + +const AUDIENCE_ID = 'aud_12345' // References audienceSettings.audience_id +const AUDIENCE_KEY = 'sneakers_buyers' // References audienceSettings.audience_key +const ADVERTISING_ID = 'foobar' // References device.advertisingId +const ENGAGE_SPACE_ID = 'acme_corp_engage_space' // References settings.engage_space_id +const MDM_ID = 'mdm 123' // References settings.mdm_id +const CUST_DESC = 'ACME Corp' // References settings.customer_desc + +const bad_event = createTestEvent({ + type: 'identify', + traits: { + sneakers_buyers: true + }, + context: { + traits: { + sneakers_buyers: true + }, + personas: { + audience_settings: { + audience_id: AUDIENCE_ID, + audience_key: AUDIENCE_KEY + }, + computation_id: AUDIENCE_ID, + computation_key: AUDIENCE_KEY, + computation_class: 'audience' + } + } +}) + +describe('YahooAudiences.updateSegment', () => { + describe('Success cases', () => { + it('should not throw an error if event includes email / maid', async () => { + nock(`https://dataxonline.yahoo.com`).post('/online/audience/').reply(200) + + const good_event = createTestEvent({ + type: 'identify', + context: { + device: { + advertisingId: ADVERTISING_ID + }, + personas: { + audience_settings: { + audience_id: AUDIENCE_ID, + audience_key: AUDIENCE_KEY + }, + computation_id: AUDIENCE_ID, + computation_key: AUDIENCE_KEY, + computation_class: 'audience' + } + }, + traits: { + email: 'testing@testing.com', + sneakers_buyers: true + } + }) + + const responses = await testDestination.testAction('updateSegment', { + auth, + event: good_event, + mapping: { + identifier: 'email_maid' + }, + useDefaultMappings: true, + settings: { + engage_space_id: ENGAGE_SPACE_ID, + mdm_id: MDM_ID, + customer_desc: CUST_DESC + } + }) + + // Then + expect(responses.length).toBe(1) + expect(responses[0].status).toBe(200) + }) + }) + + describe('Failure cases', () => { + /* + it('should fail if credentials are incorrect', async () => { + nock(`https://dataxonline.yahoo.com`).post('/online/audience/').reply(401) + const response = await testDestination.testAction('updateSegment', { + event: good_event, + mapping: {}, + useDefaultMappings: true, + settings: { + engage_space_id: '123', + mdm_id: '234', + taxonomy_client_key: '345', + taxonomy_client_secret: '456', + customer_desc: 'Spacely Sprockets' + } + }) + + // Then + expect(response).toHaveLength(1) + expect(response[0].status).toBe(401) + }) + */ + it('should throw an error if audience event missing mandatory "computation_class" field', async () => { + const bad_event = createTestEvent({ + type: 'identify', + traits: { + sneakers_buyers: true + }, + context: { + traits: { + sneakers_buyers: true, + email: 'joe@doe.com' + }, + personas: { + audience_settings: { + audience_id: AUDIENCE_ID, + audience_key: AUDIENCE_KEY + }, + computation_id: AUDIENCE_ID, + computation_key: AUDIENCE_KEY + } + } + }) + await expect( + testDestination.testAction('updateSegment', { + event: bad_event, + useDefaultMappings: true + }) + ).rejects.toThrowError("The root value is missing the required field 'segment_computation_action'") + }) + + it('should throw an error if audience key does not match traits object', async () => { + const bad_event = createTestEvent({ + type: 'identify', + traits: { + sneakers_buyers: true, + email: 'joe@doe.com' + }, + context: { + personas: { + audience_settings: { + audience_id: AUDIENCE_ID, + audience_key: AUDIENCE_KEY + }, + computation_id: AUDIENCE_ID, + computation_key: 'incorrect_audience_key', + computation_class: 'audience' + } + } + }) + + nock(`https://dataxonline.yahoo.com`).post('/online/audience/').reply(400) + + await expect( + testDestination.testAction('syncAudience', { + event: bad_event, + useDefaultMappings: true + }) + ).rejects.toThrow() + }) + + it('should throw an error if event does not include email / maid', async () => { + nock(`https://dataxonline.yahoo.com`).post('/online/audience/').reply(400) + + await expect( + testDestination.testAction('updateSegment', { + auth, + event: bad_event, + mapping: { + identifier: 'email_maid' + }, + useDefaultMappings: true, + settings: { + engage_space_id: ENGAGE_SPACE_ID, + mdm_id: MDM_ID, + customer_desc: CUST_DESC + } + }) + ).rejects.toThrow('Selected identifier(s) not available in the event(s)') + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/generated-types.ts b/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/generated-types.ts new file mode 100644 index 0000000000..0dfcce07d5 --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/generated-types.ts @@ -0,0 +1,59 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * Segment Audience Id (aud_...). Maps to "Id" of a Segment node in Yahoo taxonomy + */ + segment_audience_id: string + /** + * Segment Audience Key. Maps to the "Name" of the Segment node in Yahoo taxonomy + */ + segment_audience_key: string + /** + * Event traits or properties. Do not modify this setting + */ + event_attributes: { + [k: string]: unknown + } + /** + * Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'. + */ + segment_computation_action: string + /** + * Phone number of a user + */ + phone?: string + /** + * Email address of a user + */ + email?: string + /** + * User's mobile advertising Id + */ + advertising_id?: string + /** + * User's mobile device type + */ + device_type?: string + /** + * GDPR Settings for the audience + */ + gdpr_settings?: { + /** + * Set to true to indicate that audience data is subject to GDPR regulations + */ + gdpr_flag: boolean + /** + * Required if GDPR flag is set to "true". Using IAB Purpose bit descriptions specify the following user consent attributes: "Storage and Access of Information", "Personalization" + */ + gdpr_euconsent?: string + } + /** + * If true, batch requests to Yahoo. Yahoo accepts batches of up to 1000 events. If false, send each event individually. + */ + enable_batching?: boolean + /** + * Maximum number of events to include in each batch. Actual batch sizes may be lower. + */ + batch_size?: number +} diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/index.ts b/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/index.ts new file mode 100644 index 0000000000..2fe5c71f23 --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/updateSegment/index.ts @@ -0,0 +1,190 @@ +import type { ActionDefinition, RequestClient, StatsContext } from '@segment/actions-core' +import { PayloadValidationError } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' +import { gen_update_segment_payload } from '../utils-rt' + +const action: ActionDefinition = { + title: 'Sync To Yahoo Ads Segment', + description: 'Sync Segment Audience to Yahoo Ads Segment', + defaultSubscription: 'type = "identify" or type = "track"', + fields: { + segment_audience_id: { + label: 'Segment Audience Id', // Maps to Yahoo Taxonomy Segment Id + description: 'Segment Audience Id (aud_...). Maps to "Id" of a Segment node in Yahoo taxonomy', + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_id' + } + }, + segment_audience_key: { + label: 'Segment Audience Key', + description: 'Segment Audience Key. Maps to the "Name" of the Segment node in Yahoo taxonomy', + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_key' + } + }, + // Fetch event traits or props, which will be used to determine user's membership in an audience + event_attributes: { + label: 'Event traits or properties. Do not modify this setting', + description: 'Event traits or properties. Do not modify this setting', + type: 'object', + readOnly: true, + required: true, + default: { + '@if': { + exists: { '@path': '$.properties' }, + then: { '@path': '$.properties' }, + else: { '@path': '$.traits' } + } + } + }, + segment_computation_action: { + label: 'Segment Computation Action', + description: + "Segment computation class used to determine if input event is from an Engage Audience'. Value must be = 'audience'.", + type: 'string', + unsafe_hidden: true, + required: true, + default: { + '@path': '$.context.personas.computation_class' + }, + choices: [{ label: 'audience', value: 'audience' }] + }, + phone: { + label: 'User Phone', + description: 'Phone number of a user', + type: 'string', + unsafe_hidden: false, + required: false, + default: { + '@if': { + exists: { '@path': '$.traits.phone' }, + then: { '@path': '$.traits.phone' }, // Phone is sent as identify's trait or track's property + else: { '@path': '$.properties.phone' } + } + } + }, + email: { + label: 'User Email', + description: 'Email address of a user', + type: 'string', + unsafe_hidden: false, + required: false, + default: { + '@if': { + exists: { '@path': '$.traits.email' }, + then: { '@path': '$.traits.email' }, + else: { '@path': '$.context.traits.email' } // Phone is sent as identify's trait or track's context.trait + } + } + }, + advertising_id: { + label: 'User Mobile Advertising ID', + description: "User's mobile advertising Id", + type: 'string', + unsafe_hidden: false, + required: false, + default: { + '@path': '$.context.device.advertisingId' + } + }, + device_type: { + label: 'User Mobile Device Type', // This field is required to determine the type of the advertising Id: IDFA or GAID + description: "User's mobile device type", + type: 'string', + unsafe_hidden: false, + required: false, + default: { + '@path': '$.context.device.type' + } + }, + gdpr_settings: { + label: 'GDPR Settings', + description: 'GDPR Settings for the audience', + type: 'object', + allowNull: false, + multiple: false, + properties: { + gdpr_flag: { + label: 'GDPR Flag', + type: 'boolean', + required: true, + default: false, + description: 'Set to true to indicate that audience data is subject to GDPR regulations' + }, + gdpr_euconsent: { + label: 'GDPR Consent Attributes', + type: 'string', + required: false, + description: + 'Required if GDPR flag is set to "true". Using IAB Purpose bit descriptions specify the following user consent attributes: "Storage and Access of Information", "Personalization"' + } + } + }, + enable_batching: { + label: 'Batch Data to Yahoo', + description: + 'If true, batch requests to Yahoo. Yahoo accepts batches of up to 1000 events. If false, send each event individually.', + type: 'boolean', + default: true, + unsafe_hidden: true + }, + batch_size: { + label: 'Batch Size', + description: 'Maximum number of events to include in each batch. Actual batch sizes may be lower.', + type: 'number', + default: 1000, + unsafe_hidden: true + } + }, + + perform: (request, { payload, auth, statsContext }) => { + const rt_access_token = auth?.accessToken + return process_payload(request, [payload], rt_access_token, statsContext) + }, + performBatch: (request, { payload, auth, statsContext }) => { + const rt_access_token = auth?.accessToken + return process_payload(request, payload, rt_access_token, statsContext) + } +} + +// Makes a request to Yahoo Realtime API to populate an audience +async function process_payload( + request: RequestClient, + payload: Payload[], + token: string | undefined, + statsContext: StatsContext | undefined +) { + const body = gen_update_segment_payload(payload) + const statsClient = statsContext?.statsClient + const statsTag = statsContext?.tags + // Send request to Yahoo only when all events in the batch include selected Ids + if (body.data.length > 0) { + if (statsClient && statsTag) { + statsClient?.incr('updateSegmentTriggered', 1, statsTag) + for (let i = 0; i < body.data.length; i++) { + statsClient?.incr('updateSegmentRecordsSent', 1, statsTag) + } + } + return request('https://dataxonline.yahoo.com/online/audience/', { + method: 'POST', + json: body, + headers: { + Authorization: `Bearer ${token}` + } + }) + } else { + if (statsClient && statsTag) { + statsClient?.incr('updateSegmentDiscarded', 1, statsTag) + } + throw new PayloadValidationError('Selected identifier(s) not available in the event(s)') + } +} + +export default action diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/utils-rt.ts b/packages/destination-actions/src/destinations/yahoo-audiences/utils-rt.ts new file mode 100644 index 0000000000..5f35335b2a --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/utils-rt.ts @@ -0,0 +1,173 @@ +import { createHmac, createHash } from 'crypto' +import { Payload } from './updateSegment/generated-types' +import { YahooPayload } from './types' +import { gen_random_id } from './utils-tax' + +/** + * Creates a SHA256 hash from the input + * @param input The input string + * @returns The SHA256 hash (string), or undefined if the input is undefined. + */ +export function create_hash(input: string | undefined): string | undefined { + if (input === undefined) return + return createHash('sha256').update(input).digest('hex') +} + +/** + * Generates JWT for Realtime API authentication + * @param client_id + * @param client_secret + * @returns The JWT token + */ +export function generate_jwt(client_id: string, client_secret: string): string { + const random_id = gen_random_id(24) + const current_time = Math.floor(new Date().getTime() / 1000) + const url = 'https://id.b2b.yahooinc.com/identity/oauth2/access_token' + const jwt_payload = { + iss: client_id, + sub: client_id, + aud: url + '?realm=dataxonline', + jti: random_id, + exp: current_time + 3600, + iat: current_time + } + const jwt_header = { + alg: 'HS256', + typ: 'JWT' + } + + const jwt_header_encoded = Buffer.from(JSON.stringify(jwt_header)).toString('base64') + const jwt_payload_encoded = Buffer.from(JSON.stringify(jwt_payload)).toString('base64') + const jwt_head_payload = jwt_header_encoded + '.' + jwt_payload_encoded + + const hash = createHmac('sha256', client_secret) + const signature = hash.update(jwt_head_payload).digest('base64') + const jwt = jwt_head_payload + '.' + signature + + return jwt +} + +/** + * Gets the definition to send the hashed email, phone or advertising ID. + * @param payload The payload. + * @returns {{ maid: boolean; email: boolean }} The definitions object (id_schema). + */ + +export function validate_phone(phone: string) { + /* + Phone must match E.164 format: a number up to 15 digits in length starting with a ‘+’ + - remove any non-numerical characters + - check length + - if phone doesn't match the criteria - drop the value, otherwise - return the value prepended with a '+' + */ + const phone_num = phone.replace(/\D/g, '') + if (phone_num.length <= 15 && phone_num.length >= 1) { + return '+' + phone_num + } else { + return '' + } +} + +/** + * The ID schema defines whether the payload should contain the + * hashed advertising ID for iOS or Android, or the hashed email. + * @param payloads + * @returns {YahooPayload} The Yahoo payload. + */ +export function gen_update_segment_payload(payloads: Payload[]): YahooPayload { + const data_groups: { + [hashed_email: string]: { + exp: string + seg_id: string + ts: string + }[] + } = {} + const data = [] + // + for (const event of payloads) { + let hashed_email: string | undefined = '' + if (event.email) { + hashed_email = create_hash(event.email.toLowerCase()) + } + let idfa: string | undefined = '' + let gpsaid: string | undefined = '' + if (event.advertising_id) { + if (event.device_type) { + switch (event.device_type) { + case 'ios': + idfa = event.advertising_id + break + case 'android': + gpsaid = event.advertising_id + break + } + } else { + if (event.advertising_id === event.advertising_id.toUpperCase()) { + // Apple IDFA is always uppercase + idfa = event.advertising_id + } else { + gpsaid = event.advertising_id + } + } + } + let hashed_phone: string | undefined = '' + if (event.phone) { + const phone = validate_phone(event.phone) + if (phone !== '') { + hashed_phone = create_hash(phone) + } + } + if (hashed_email === '' && idfa === '' && gpsaid === '' && hashed_phone === '') { + continue + } + const ts = Math.floor(new Date().getTime() / 1000) + const seg_key = event.segment_audience_key + let exp + // When a user enters an audience - set expiration ts to now() + 90 days + if (event.event_attributes[seg_key] === true) { + exp = ts + 90 * 24 * 60 * 60 + } + // When a user exits an audience - set expiration ts to 0 + if (event.event_attributes[seg_key] === false) { + exp = 0 + } + + const seg_id = event.segment_audience_id + + const group_key = `${hashed_email}|${idfa}|${gpsaid}|${hashed_phone}` + if (!(group_key in data_groups)) { + data_groups[group_key] = [] + } + + data_groups[group_key].push({ + exp: String(exp), + seg_id: seg_id, + ts: String(ts) + }) + } + + for (const [key, grouped_values] of Object.entries(data_groups)) { + const [hashed_email, idfa, gpsaid, hashed_phone] = key.split('|') + let action_string = '' + for (const values of grouped_values) { + action_string += 'exp=' + values.exp + '&seg_id=' + values.seg_id + '&ts=' + values.ts + ';' + } + + action_string = action_string.slice(0, -1) + data.push([hashed_email, idfa, gpsaid, hashed_phone, action_string]) + } + + const gdpr_flag = payloads[0].gdpr_settings ? payloads[0].gdpr_settings.gdpr_flag : false + + const yahoo_payload: YahooPayload = { + schema: ['SHA256EMAIL', 'IDFA', 'GPADVID', 'HASHEDID', 'SEGMENTS'], + data: data, + gdpr: gdpr_flag + } + + if (gdpr_flag) { + yahoo_payload.gdpr_euconsent = payloads[0].gdpr_settings?.gdpr_euconsent + } + + return yahoo_payload +} diff --git a/packages/destination-actions/src/destinations/yahoo-audiences/utils-tax.ts b/packages/destination-actions/src/destinations/yahoo-audiences/utils-tax.ts new file mode 100644 index 0000000000..5c2ec3fc7b --- /dev/null +++ b/packages/destination-actions/src/destinations/yahoo-audiences/utils-tax.ts @@ -0,0 +1,114 @@ +import type { Settings } from './generated-types' +import { createHmac } from 'crypto' +import { CredsObj, YahooSubTaxonomy } from './types' +import { RequestClient, IntegrationError } from '@segment/actions-core' +import { StatsClient } from '@segment/actions-core/destination-kit' + +export function gen_customer_taxonomy_payload(settings: Settings) { + const data = { + id: settings.engage_space_id, + name: settings.engage_space_id, + description: settings.customer_desc, + users: { + include: [settings.mdm_id] + } + } + // Form data must be delimited with CRLF = /r/n: RFC https://www.rfc-editor.org/rfc/rfc7578#section-4.1 + const req_body_form = `--SEGMENT-DATA\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/json;charset=UTF-8\r\n\r\n{ "description" : "${ + settings.customer_desc + }" }\r\n--SEGMENT-DATA\r\nContent-Disposition: form-data; name="data"\r\nContent-Type: application/json;charset=UTF-8\r\n\r\n${JSON.stringify( + data + )}\r\n--SEGMENT-DATA--` + return req_body_form +} + +export function gen_segment_subtaxonomy_payload(payload: YahooSubTaxonomy) { + const data = { + id: payload.segment_audience_id, + name: payload.segment_audience_key, + type: 'SEGMENT' + } + const req_body_form = `--SEGMENT-DATA\r\nContent-Disposition: form-data; name="metadata"\r\nContent-Type: application/json;charset=UTF-8\r\n\r\n{ "description" : "Create segment ${ + data.id + }" }\r\n--SEGMENT-DATA\r\nContent-Disposition: form-data; name="data"\r\nContent-Type: application/json;charset=UTF-8\r\n\r\n${JSON.stringify( + data + )}\r\n--SEGMENT-DATA--` + return req_body_form +} + +export function gen_random_id(length: number): string { + const pattern = 'abcdefghijklmnopqrstuvwxyz123456789' + const random_id: string[] = [] + for (let i = 0; i < length; i++) { + random_id.push(pattern[Math.floor(Math.random() * pattern.length)]) + } + return random_id.join('') +} + +export function gen_oauth1_signature(client_key: string, client_secret: string, method: string, url: string) { + // Following logic in #9 https://oauth.net/core/1.0a/#sig_norm_param + const timestamp = Math.floor(new Date().getTime() / 1000) + const nonce = gen_random_id(15) + + const param_string = `oauth_consumer_key=${encodeURIComponent(client_key)}&oauth_nonce=${encodeURIComponent( + nonce + )}&oauth_signature_method=${encodeURIComponent('HMAC-SHA1')}&oauth_timestamp=${encodeURIComponent( + timestamp + )}&oauth_version=${encodeURIComponent('1.0')}` + + const base_string = `${method.toUpperCase()}&${encodeURIComponent(url)}&${encodeURIComponent(param_string)}` + const encoded_client_secret = encodeURIComponent(client_secret) + const signature = encodeURIComponent( + createHmac('sha1', encoded_client_secret + '&') + .update(base_string) + .digest('base64') + ) + const oauth1_auth_string = `OAuth oauth_consumer_key="${client_key}", oauth_nonce="${nonce}", oauth_signature="${signature}", oauth_signature_method="HMAC-SHA1", oauth_timestamp="${timestamp}", oauth_version="1.0"` + return oauth1_auth_string +} + +export async function update_taxonomy( + engage_space_id: string, + tx_creds: CredsObj, + request: RequestClient, + body_form_data: string, + statsClient: StatsClient | undefined, + statsTags: string[] | undefined +) { + const tx_client_secret = tx_creds.tx_client_secret + const tx_client_key = tx_creds.tx_client_key + const url = `https://datax.yahooapis.com/v1/taxonomy/append${engage_space_id.length > 0 ? '/' + engage_space_id : ''}` + const oauth1_auth_string = gen_oauth1_signature(tx_client_key, tx_client_secret, 'PUT', url) + try { + const add_segment_node = await request(url, { + method: 'PUT', + body: body_form_data, + headers: { + Authorization: oauth1_auth_string, + 'Content-Type': 'multipart/form-data; boundary=SEGMENT-DATA' + } + }) + if (statsClient && statsTags) { + statsClient.incr('update_taxonomy.success', 1, statsTags) + } + return await add_segment_node.json() + } catch (error) { + const _error = error as { response: { data: unknown; status: string } } + if (statsClient && statsTags) { + statsClient.incr('update_taxonomy.error', 1, statsTags) + } + // If Taxonomy API returned 401, throw Integration error w/status 400 to prevent refreshAccessToken from firing + // Otherwise throw the original error + if (parseInt(_error.response.status) == 401) { + throw new IntegrationError( + `Error while updating taxonomy: ${JSON.stringify(_error.response.data)} ${ + _error.response.status + }. Validate Yahoo credentials`, + 'YAHOO_TAXONOMY_API_AUTH_ERR', + 400 + ) + } else { + throw error + } + } +} diff --git a/packages/destination-actions/src/destinations/yotpo/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/yotpo/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..e354fcb2e3 --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for actions-yotpo destination: sendData action - all fields 1`] = `""`; + +exports[`Testing snapshot for actions-yotpo destination: sendData action - required fields 1`] = `""`; + +exports[`Testing snapshot for actions-yotpo destination: sendData action - required fields 2`] = ` +Headers { + Symbol(map): Object { + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/yotpo/__tests__/index.test.ts b/packages/destination-actions/src/destinations/yotpo/__tests__/index.test.ts new file mode 100644 index 0000000000..c06f351873 --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/__tests__/index.test.ts @@ -0,0 +1,19 @@ +import nock from 'nock' +import { createTestIntegration } from '@segment/actions-core' +import { Settings } from '../generated-types' +import Definition from '../index' + +const testDestination = createTestIntegration(Definition) + +describe('Yotpo', () => { + describe('testAuthentication', () => { + it('should validate authentication inputs', async () => { + nock('https://developers.yotpo.com').get(/.*/).reply(200, {}) + + // This should match your authentication.fields + const authData = { store_id: 'store_id' } + + await expect(testDestination.testAuthentication(authData)).resolves.not.toThrowError() + }) + }) +}) diff --git a/packages/destination-actions/src/destinations/yotpo/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/yotpo/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..8a1f5fddb2 --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/__tests__/snapshot.test.ts @@ -0,0 +1,77 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../lib/test-data' +import destination from '../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const destinationSlug = 'actions-yotpo' + +describe(`Testing snapshot for ${destinationSlug} destination:`, () => { + for (const actionSlug in destination.actions) { + it(`${actionSlug} action - required fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it(`${actionSlug} action - all fields`, async () => { + const seedName = `${destinationSlug}#${actionSlug}` + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) + } +}) diff --git a/packages/destination-actions/src/destinations/yotpo/generated-types.ts b/packages/destination-actions/src/destinations/yotpo/generated-types.ts new file mode 100644 index 0000000000..7fa259204f --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/generated-types.ts @@ -0,0 +1,8 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Settings { + /** + * The store ID for your Yotpo account + */ + store_id: string +} diff --git a/packages/destination-actions/src/destinations/yotpo/index.ts b/packages/destination-actions/src/destinations/yotpo/index.ts new file mode 100644 index 0000000000..16fe00f03f --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/index.ts @@ -0,0 +1,59 @@ +import type { DestinationDefinition } from '@segment/actions-core' +import type { Settings } from './generated-types' + +import sendData from './sendData' + +interface AccessTokenResponse { + access_token: string + token_type: string +} + +const destination: DestinationDefinition = { + name: 'Yotpo', + slug: 'yotpo-actions', + mode: 'cloud', + description: 'Send data to Yotpo', + + authentication: { + scheme: 'oauth-managed', + fields: { + store_id: { + label: 'Store ID', + description: 'The store ID for your Yotpo account', + type: 'string', + required: true + } + }, + testAuthentication: (request, data) => { + return request(`https://developers.yotpo.com/v2/${data.settings.store_id}/info`, { + method: 'get' + }) + }, + refreshAccessToken: async (request, data) => { + const promise = await request(`https://developers.yotpo.com/v2/oauth/token`, { + method: 'post', + json: { + client_id: data.auth.clientId, + client_secret: data.auth.clientSecret, + grant_type: 'authorization_code' + } + }) + return { + accessToken: promise.data.access_token + } + } + }, + extendRequest({ auth }) { + return { + headers: { + 'X-Yotpo-Token': `${auth?.accessToken}` + } + } + }, + + actions: { + sendData + } +} + +export default destination diff --git a/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/__snapshots__/snapshot.test.ts.snap b/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/__snapshots__/snapshot.test.ts.snap new file mode 100644 index 0000000000..8a5fec65f5 --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/__snapshots__/snapshot.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Testing snapshot for Yotpo's sendData destination action: all fields 1`] = `""`; + +exports[`Testing snapshot for Yotpo's sendData destination action: required fields 1`] = `""`; + +exports[`Testing snapshot for Yotpo's sendData destination action: required fields 2`] = ` +Headers { + Symbol(map): Object { + "user-agent": Array [ + "Segment (Actions)", + ], + }, +} +`; diff --git a/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/index.test.ts b/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/index.test.ts new file mode 100644 index 0000000000..971b74c16b --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/index.test.ts @@ -0,0 +1,12 @@ +// import nock from 'nock' +// import { createTestEvent, createTestIntegration } from '@segment/actions-core' +// import Destination from '../../index' +// +// const testDestination = createTestIntegration(Destination) + +describe('Yotpo.sendData', () => { + // make this test pass + it('should pass', async () => { + expect(true).toBe(true) + }) +}) diff --git a/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/snapshot.test.ts b/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/snapshot.test.ts new file mode 100644 index 0000000000..c71e8ea4c5 --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/sendData/__tests__/snapshot.test.ts @@ -0,0 +1,75 @@ +import { createTestEvent, createTestIntegration } from '@segment/actions-core' +import { generateTestData } from '../../../../lib/test-data' +import destination from '../../index' +import nock from 'nock' + +const testDestination = createTestIntegration(destination) +const actionSlug = 'sendData' +const destinationSlug = 'Yotpo' +const seedName = `${destinationSlug}#${actionSlug}` + +describe(`Testing snapshot for ${destinationSlug}'s ${actionSlug} destination action:`, () => { + it('required fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, true) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + + expect(request.headers).toMatchSnapshot() + }) + + it('all fields', async () => { + const action = destination.actions[actionSlug] + const [eventData, settingsData] = generateTestData(seedName, destination, action, false) + + nock(/.*/).persist().get(/.*/).reply(200) + nock(/.*/).persist().post(/.*/).reply(200) + nock(/.*/).persist().put(/.*/).reply(200) + + const event = createTestEvent({ + properties: eventData + }) + + const responses = await testDestination.testAction(actionSlug, { + event: event, + mapping: event.properties, + settings: settingsData, + auth: undefined + }) + + const request = responses[0].request + const rawBody = await request.text() + + try { + const json = JSON.parse(rawBody) + expect(json).toMatchSnapshot() + return + } catch (err) { + expect(rawBody).toMatchSnapshot() + } + }) +}) diff --git a/packages/destination-actions/src/destinations/yotpo/sendData/generated-types.ts b/packages/destination-actions/src/destinations/yotpo/sendData/generated-types.ts new file mode 100644 index 0000000000..1d3b55671f --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/sendData/generated-types.ts @@ -0,0 +1,10 @@ +// Generated file. DO NOT MODIFY IT BY HAND. + +export interface Payload { + /** + * The data to send to Yotpo + */ + data?: { + [k: string]: unknown + } +} diff --git a/packages/destination-actions/src/destinations/yotpo/sendData/index.ts b/packages/destination-actions/src/destinations/yotpo/sendData/index.ts new file mode 100644 index 0000000000..b855d4768d --- /dev/null +++ b/packages/destination-actions/src/destinations/yotpo/sendData/index.ts @@ -0,0 +1,25 @@ +import type { ActionDefinition } from '@segment/actions-core' +import type { Settings } from '../generated-types' +import type { Payload } from './generated-types' + +// TODO: this is a test action, update it once we have better understanding of what it needs to do +const action: ActionDefinition = { + title: 'Send Data', + description: 'Send data to Yotpo', + fields: { + data: { + label: 'Data', + description: 'The data to send to Yotpo', + type: 'object', + required: false + } + }, + defaultSubscription: 'type = "track"', + perform: (request, data) => { + return request(`https://developers.yotpo.com/v2/${data.settings.store_id}/info`, { + method: 'get' + }) + } +} + +export default action diff --git a/packages/destination-subscriptions/README.md b/packages/destination-subscriptions/README.md index 74bcf286bd..e56648b674 100644 --- a/packages/destination-subscriptions/README.md +++ b/packages/destination-subscriptions/README.md @@ -6,7 +6,7 @@ Validate event payloads against an action's subscription AST. MIT License -Copyright (c) 2023 Segment +Copyright (c) 2024 Segment Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/destination-subscriptions/package.json b/packages/destination-subscriptions/package.json index 5b0ae6fe8b..c1d5030a63 100644 --- a/packages/destination-subscriptions/package.json +++ b/packages/destination-subscriptions/package.json @@ -1,6 +1,6 @@ { "name": "@segment/destination-subscriptions", - "version": "3.28.3", + "version": "3.33.0", "description": "Validate event payload using subscription AST", "license": "MIT", "repository": { @@ -9,7 +9,7 @@ "directory": "packages/destination-subscriptions" }, "scripts": { - "build": "yarn clean && yarn build:cjs && yarn build:esm", + "build": "yarn build:cjs && yarn build:esm", "build:cjs": "yarn tsc -p tsconfig.build.json -m commonjs --outDir dist/cjs", "build:esm": "yarn tsc -p tsconfig.build.json -m es2015 --outDir dist/esm", "clean": "tsc -b tsconfig.build.json --clean", @@ -31,13 +31,18 @@ "@segment/fql-ts": "^1.10.1" }, "devDependencies": { - "@size-limit/preset-small-lib": "^6.0.3", + "@size-limit/preset-small-lib": "^11.0.2", "@types/jest": "^27.0.0", "jest": "^27.3.1", "size-limit": "^6.0.3" }, "jest": { "preset": "ts-jest", + "globals": { + "ts-jest": { + "isolatedModules": true + } + }, "testEnvironment": "node", "modulePathIgnorePatterns": [ "/dist/" diff --git a/packages/destination-subscriptions/src/get.ts b/packages/destination-subscriptions/src/get.ts index b15f030df0..5355cb9229 100644 --- a/packages/destination-subscriptions/src/get.ts +++ b/packages/destination-subscriptions/src/get.ts @@ -8,7 +8,7 @@ export function get( path: string | string[], defValue?: Default ): T | undefined | Default { - // If path is not defined or it has false value + // If path is not defined or it has false value. if (!path) return defValue // Check if path is string or array. Regex : ensure that we do not have '.' and brackets. diff --git a/packages/destinations-manifest/package.json b/packages/destinations-manifest/package.json index f4333c3d36..74e0520b2a 100644 --- a/packages/destinations-manifest/package.json +++ b/packages/destinations-manifest/package.json @@ -1,6 +1,6 @@ { "name": "@segment/destinations-manifest", - "version": "1.21.0", + "version": "1.46.0", "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org" @@ -12,36 +12,44 @@ "main": "./dist/index.js", "typings": "./dist/index.d.ts", "dependencies": { - "@segment/analytics-browser-actions-adobe-target": "^1.15.0", - "@segment/analytics-browser-actions-amplitude-plugins": "^1.15.0", - "@segment/analytics-browser-actions-braze": "^1.16.0", - "@segment/analytics-browser-actions-braze-cloud-plugins": "^1.16.0", - "@segment/analytics-browser-actions-cdpresolution": "^1.2.0", - "@segment/analytics-browser-actions-commandbar": "^1.15.0", - "@segment/analytics-browser-actions-devrev": "^1.2.0", - "@segment/analytics-browser-actions-friendbuy": "^1.15.0", - "@segment/analytics-browser-actions-fullstory": "^1.16.0", - "@segment/analytics-browser-actions-google-analytics-4": "^1.19.0", - "@segment/analytics-browser-actions-google-campaign-manager": "^1.5.0", - "@segment/analytics-browser-actions-heap": "^1.15.0", - "@segment/analytics-browser-actions-hubspot": "^1.15.0", - "@segment/analytics-browser-actions-intercom": "^1.15.0", - "@segment/analytics-browser-actions-iterate": "^1.15.0", - "@segment/analytics-browser-actions-koala": "^1.15.0", - "@segment/analytics-browser-actions-logrocket": "^1.15.0", - "@segment/analytics-browser-actions-pendo-web-actions": "^1.3.0", - "@segment/analytics-browser-actions-playerzero": "^1.15.0", - "@segment/analytics-browser-actions-ripe": "^1.15.0", + "@segment/analytics-browser-actions-1flow": "^1.16.0", + "@segment/analytics-browser-actions-adobe-target": "^1.34.0", + "@segment/analytics-browser-actions-algolia-plugins": "^1.11.0", + "@segment/analytics-browser-actions-amplitude-plugins": "^1.34.0", + "@segment/analytics-browser-actions-braze": "^1.37.0", + "@segment/analytics-browser-actions-braze-cloud-plugins": "^1.37.0", + "@segment/analytics-browser-actions-bucket": "^1.14.0", + "@segment/analytics-browser-actions-cdpresolution": "^1.21.0", + "@segment/analytics-browser-actions-commandbar": "^1.34.0", + "@segment/analytics-browser-actions-devrev": "^1.21.0", + "@segment/analytics-browser-actions-friendbuy": "^1.34.0", + "@segment/analytics-browser-actions-fullstory": "^1.36.0", + "@segment/analytics-browser-actions-google-analytics-4": "^1.40.0", + "@segment/analytics-browser-actions-google-campaign-manager": "^1.24.0", + "@segment/analytics-browser-actions-heap": "^1.34.0", + "@segment/analytics-browser-actions-hubspot": "^1.34.0", + "@segment/analytics-browser-actions-intercom": "^1.34.0", + "@segment/analytics-browser-actions-iterate": "^1.34.0", + "@segment/analytics-browser-actions-jimo": "^1.22.0", + "@segment/analytics-browser-actions-koala": "^1.34.0", + "@segment/analytics-browser-actions-logrocket": "^1.34.0", + "@segment/analytics-browser-actions-pendo-web-actions": "^1.23.0", + "@segment/analytics-browser-actions-playerzero": "^1.34.0", + "@segment/analytics-browser-actions-replaybird": "^1.15.0", + "@segment/analytics-browser-actions-ripe": "^1.34.0", "@segment/analytics-browser-actions-sabil": "^1.6.0", - "@segment/analytics-browser-actions-screeb": "^1.15.0", - "@segment/analytics-browser-actions-sprig": "^1.15.0", - "@segment/analytics-browser-actions-stackadapt": "^1.15.0", - "@segment/analytics-browser-actions-tiktok-pixel": "^1.10.0", - "@segment/analytics-browser-actions-upollo": "^1.15.0", - "@segment/analytics-browser-actions-userpilot": "^1.15.0", - "@segment/analytics-browser-actions-utils": "^1.15.0", - "@segment/analytics-browser-actions-vwo": "^1.16.0", - "@segment/analytics-browser-actions-wiseops": "^1.15.0", - "@segment/browser-destination-runtime": "^1.14.0" + "@segment/analytics-browser-actions-screeb": "^1.34.0", + "@segment/analytics-browser-actions-snap-plugins": "^1.15.0", + "@segment/analytics-browser-actions-sprig": "^1.34.0", + "@segment/analytics-browser-actions-stackadapt": "^1.34.0", + "@segment/analytics-browser-actions-survicate": "^1.10.0", + "@segment/analytics-browser-actions-tiktok-pixel": "^1.31.0", + "@segment/analytics-browser-actions-upollo": "^1.34.0", + "@segment/analytics-browser-actions-userpilot": "^1.34.0", + "@segment/analytics-browser-actions-utils": "^1.34.0", + "@segment/analytics-browser-actions-vwo": "^1.35.0", + "@segment/analytics-browser-actions-wiseops": "^1.34.0", + "@segment/analytics-browser-hubble-web": "^1.20.0", + "@segment/browser-destination-runtime": "^1.33.0" } } diff --git a/packages/destinations-manifest/src/index.ts b/packages/destinations-manifest/src/index.ts index c240cde985..a4a9bdc40c 100644 --- a/packages/destinations-manifest/src/index.ts +++ b/packages/destinations-manifest/src/index.ts @@ -59,3 +59,11 @@ register('6501a4325a8a629197cdd691', '@segment/analytics-browser-actions-pendo-w register('6501a5225aa338d11164cc0f', '@segment/analytics-browser-actions-rupt') register('650c69e7f47d84b86c120b4c', '@segment/analytics-browser-actions-cdpresolution') register('649adeaa719bd3f55fe81bef', '@segment/analytics-browser-actions-devrev') +register('651aac880f2c3b5a8736e0cc', '@segment/analytics-browser-hubble-web') +register('652d4cf5e00c0147e6eaf5e7', '@segment/analytics-browser-actions-jimo') +register('6261a8b6cb4caa70e19116e8', '@segment/analytics-browser-actions-snap-plugins') +register('6554e468e280fb14fbb4433c', '@segment/analytics-browser-actions-replaybird') +register('656773f0bd79a3676ab2733d', '@segment/analytics-browser-actions-1flow') +register('656dc9330d1863a8870bacd1', '@segment/analytics-browser-actions-bucket') +register('63e52bea7747fbc311d5b872', '@segment/analytics-browser-actions-algolia-plugins') +register('65a6ac19ea6d3ced628be00b', '@segment/analytics-browser-actions-survicate') diff --git a/scripts/assert-lockfile-updated.sh b/scripts/assert-lockfile-updated.sh new file mode 100644 index 0000000000..43c4689bcf --- /dev/null +++ b/scripts/assert-lockfile-updated.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# asserts lockfile is up-to-date (https://github.com/yarnpkg/yarn/issues/5840#top). +# This can be removed when yarn is updated to a version that contains --immutable + +yarn install + +git diff yarn.lock +if ! git diff --exit-code yarn.lock; then + echo "Changes were detected in yarn.lock file after running 'yarn install', which is not expected. Please run 'yarn install' locally and commit the changes." + exit 1 +fi diff --git a/scripts/assert-types-updated.sh b/scripts/assert-types-updated.sh new file mode 100644 index 0000000000..ba916fcef9 --- /dev/null +++ b/scripts/assert-types-updated.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +echo "Checking if generated types are up-to-date" + +yarn types + +if [ -n "$(git status --porcelain | grep generated-types.ts)" ]; then + echo "Please run 'yarn types' and commit the result!" + exit 1 +fi + +echo "Generated types are up-to-date" diff --git a/scripts/compute-labels.js b/scripts/compute-labels.js new file mode 100644 index 0000000000..50ebe383f2 --- /dev/null +++ b/scripts/compute-labels.js @@ -0,0 +1,146 @@ +// This is a github action script and can be run only from github actions. To run this script locally, you need to mock the github object and context object. +module.exports = async ({ github, context, core }) => { + const authorLabels = await computeAuthorLabels(github, context, core) + const { add, remove } = await computeFileBasedLabels(github, context, core) + core.setOutput('add', [...authorLabels, ...add].join(',')) + core.setOutput('remove', remove.join(',')) + return +} + +async function computeAuthorLabels(github, context, core) { + const teamSlugs = ['build-experience-team', 'libraries-web-team', 'strategic-connections-team'] + const username = context.payload.sender.login + const organization = context.repo.owner + const SEGMENT_CORE_LABEL = 'team:segment-core' + const EXTERNAL_LABEL = 'team:external' + const SEGMENT_LABEL = 'team:segment' + + // If team member label already exists, then no need to add any labels + const existingLabels = context.payload.pull_request.labels.map((label) => label.name) + if (existingLabels.some((label) => [SEGMENT_CORE_LABEL, EXTERNAL_LABEL, SEGMENT_LABEL].includes(label))) { + return [] + } + + // check against all internal teams + const teamMembers = await Promise.all( + teamSlugs.map(async (teamSlug) => { + const team = await github.rest.teams.listMembersInOrg({ + team_slug: teamSlug, + org: organization + }) + return team.data.some((member) => member.login === username) + }) + ) + + // Add labels based on the team membership + const labels = [] + if (teamMembers.some((member) => member === true)) { + labels.push(SEGMENT_CORE_LABEL) + } else { + // check if the user is a member of the organization - eg; Engage and other internal integration devs + await github.rest.orgs + .checkMembershipForUser({ + org: organization, + username: username + }) + // if the user is not a member of the organization, then add the external label + .catch((e) => { + if (e.status === 404) { + labels.push(EXTERNAL_LABEL) + } + }) + // if the user is a member of the organization, then add the segment label + .then((data) => { + if (data && data.status === 204) { + labels.push(SEGMENT_LABEL) + } + }) + } + core.debug(`Added ${labels.join(',')} labels to PR based on the author's team membership.`) + return labels +} + +async function computeFileBasedLabels(github, context, core) { + const org = context.repo.owner + const repo = context.repo.repo + const pull_number = context.payload.pull_request.number + const labels = context.payload.pull_request.labels.map((label) => label.name) + const DEPLOY_REGISTRATION_LABEL = 'deploy:registration' + const DEPLOY_PUSH_LABEL = 'deploy:push' + const MODE_CLOUD_LABEL = 'mode:cloud' + const MODE_DEVICE_LABEL = 'mode:device' + const ACTIONS_CORE_LABEL = 'actions:core' + + const allLabels = [ + DEPLOY_REGISTRATION_LABEL, + DEPLOY_PUSH_LABEL, + MODE_CLOUD_LABEL, + MODE_DEVICE_LABEL, + ACTIONS_CORE_LABEL + ] + + const newLabels = [] + + // Get the list of files in the PR + const opts = github.rest.pulls.listFiles.endpoint.merge({ + owner: org, + repo: repo, + pull_number: pull_number + }) + + // Paginate the list of files in the PR + const files = await github.paginate(opts) + + // The following regexes are used to match the new destinations + const newCloudDestinationRegex = /packages\/destination\-actions\/src\/destinations\/[^\/]+\/index\.ts/i + const newBrowserDestinationRegex = /packages\/browser\-destinations\/destinations\/[^\/]+\/src\/index\.ts/i + const isNew = (filename) => newCloudDestinationRegex.test(filename) || newBrowserDestinationRegex.test(filename) + + // Check if the PR contains new destinations + const isNewDestination = files.some((file) => isNew(file.filename) && file.status === 'added') + if (isNewDestination) { + newLabels.push(DEPLOY_REGISTRATION_LABEL) + } + + // The following regexes are used to match the updated destinations + const updatedCloudDestinationRegex = /packages\/destination\-actions\/src\/destinations\/.*/i + const updatedBrowserDestinationRegex = /packages\/browser\-destinations\/destinations\/.*/i + const updateCorePackageRegex = /packages\/core\/.*/i + const updatedDestinationSubscription = /packages\/destination\-subscriptions\/.*/i + + // Check if the PR contains updates to browser destinations + if (files.some((file) => updatedBrowserDestinationRegex.test(file.filename))) { + newLabels.push(MODE_DEVICE_LABEL) + } + + // Check if the PR contains updates to cloud destinations + if (files.some((file) => updatedCloudDestinationRegex.test(file.filename))) { + newLabels.push(MODE_CLOUD_LABEL) + } + + // Check if the PR contains updates to core packages + if ( + files.some( + (file) => updateCorePackageRegex.test(file.filename) || updatedDestinationSubscription.test(file.filename) + ) + ) { + newLabels.push(ACTIONS_CORE_LABEL) + } + + // Check if the PR contains changes that requires a push. + const generatedTypesRegex = /packages\/.*\/generated\-types.ts/i + if (files.some((file) => generatedTypesRegex.test(file.filename))) { + newLabels.push(DEPLOY_PUSH_LABEL) + } + + // Remove the existing custom labels if they are not required anymore + const labelsToRemove = labels.filter((label) => allLabels.includes(label) && !newLabels.includes(label)) + + core.debug(`Labels to remove: ${labelsToRemove.join(',')}`) + core.debug(`Labels to add: ${newLabels.join(',')}`) + + return { + add: newLabels, + remove: labelsToRemove + } +} diff --git a/scripts/create-github-release.js b/scripts/create-github-release.js new file mode 100644 index 0000000000..a29c1389d6 --- /dev/null +++ b/scripts/create-github-release.js @@ -0,0 +1,216 @@ +// This is a github action script and can be run only from github actions. To run this script locally, you need to mock the github object and context object. +module.exports = async ({ github, context, core, exec }) => { + const { GITHUB_SHA, RELEASE_TAG } = process.env + const { data } = await github.rest.search.commits({ + q: `Publish repo:${context.repo.owner}/${context.repo.repo}`, + per_page: 2, + sort: 'committer-date' + }) + + if (data.items.length < 2) { + core.error(`No previous release commits found`) + } + + const newPublish = data.items[0] + const previousPublish = data.items[1] + const prs = await getPRsBetweenCommits(github, context, core, previousPublish, newPublish) + + const newReleaseTag = RELEASE_TAG + + // Fetch the latest github release + const latestRelease = await github.rest.repos + .getLatestRelease({ + owner: context.repo.owner, + repo: context.repo.repo + }) + .catch((e) => { + core.info(`No previous release found: ${e.message}`) + return null + }) + + const latestReleaseTag = latestRelease ? latestRelease.data.tag_name : null + + const packageTags = await extractPackageTags(GITHUB_SHA, exec, core) + const tagsContext = { currentRelease: newReleaseTag, prevRelease: latestReleaseTag, packageTags } + const changeLog = formatChangeLog(prs, tagsContext, context) + + await github.rest.repos + .createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: newReleaseTag, + name: newReleaseTag, + body: changeLog + }) + .then(() => { + core.info(`Release ${newReleaseTag} created successfully`) + }) + .catch((e) => { + core.error(`Failed to create release: ${e.message}`) + }) + + return +} + +async function extractPackageTags(sha, exec, core) { + const { stdout, stderr, exitCode } = await exec.getExecOutput('git', ['tag', '--points-at', sha]) + if (exitCode !== 0) { + core.error(`Failed to extract package tags: ${stderr}`) + } + // filter out only the tags that are related to segment packages + return stdout + .split('\n') + .filter(Boolean) + .filter((tag) => tag.includes('@segment/') && !tag.includes('staging')) +} + +async function getPRsBetweenCommits(github, context, core, lastCommit, currentCommit) { + const lastCommitDate = new Date(lastCommit.commit.committer.date) + const currentCommitDate = new Date(currentCommit.commit.committer.date) + const owner = context.repo.owner + const repo = context.repo.repo + // GraphQL query to get PRs between two commits. Assumption is the PR might not have more than 100 files and 10 labels. + // If the PR has more than 100 files or 10 labels, we might need to paginate the query. + try { + const prsMerged = await github.graphql(`{ + search(first:100, type: ISSUE, query: "repo:${owner}/${repo} is:pr merged:${lastCommitDate.toISOString()}..${currentCommitDate.toISOString()}") { + issueCount + nodes { + ... on PullRequest { + number + title + url + author { + login + } + files(first: 100) { + nodes { + path + } + } + labels(first: 10, orderBy: {direction: DESC, field: CREATED_AT}) { + nodes { + name + } + } + } + } + } + }`) + + core.info(`Found ${prsMerged.search.issueCount} PRs between commits`) + + return prsMerged.search.nodes.map((pr) => ({ + number: `[#${pr.number}](${pr.url})`, + title: pr.title, + url: pr.url, + files: pr.files.nodes.map((file) => file.path), + author: `@${pr.author.login}`, + labels: pr.labels.nodes.map((label) => label.name), + requiresPush: pr.labels.nodes.some((label) => label.name === 'deploy:push') ? 'Yes' : 'No', + requiresRegistration: pr.labels.nodes.some((label) => label.name === 'deploy:registration') ? 'Yes' : 'No' + })) + } catch (e) { + core.error(`Unable to fetch PRs between commits: ${e.message}`) + } +} + +function formatChangeLog(prs, tagsContext, context) { + const { currentRelease, prevRelease, packageTags } = tagsContext + const prsWithAffectedDestinations = prs.map(mapPRWithAffectedDestinations) + const internalPRS = prsWithAffectedDestinations.filter( + (pr) => pr.labels.includes('team:segment-core') || pr.labels.includes('team:segment') + ) + const externalPRs = prsWithAffectedDestinations.filter((pr) => pr.labels.includes('team:external')) + + const tableConfig = [ + { + label: 'PR', + value: 'number' + }, + { + label: 'Title', + value: 'title' + }, + { + label: 'Author', + value: 'author' + }, + { + label: 'Affected Destinations', + value: 'affectedDestinations' + }, + { + label: 'Requires Push', + value: 'requiresPush' + }, + { + label: 'Requires Registration', + value: 'requiresRegistration' + } + ] + + // if there is no previous release, we simply print the current release + const releaseDiff = prevRelease ? `${prevRelease}...${currentRelease}` : currentRelease + + const formattedPackageTags = packageTags + .map((tag) => `- [${tag}](https://www.npmjs.com/package/${formatNPMPackageURL(tag)})`) + .join('\n') + + const changelog = ` + # What's New + + https://github.com/${context.repo.owner}/${context.repo.repo}/compare/${releaseDiff} + + ## Packages Published + + ${formattedPackageTags || 'No packages published'} + + ## Internal PRs + + |${tableConfig.map((config) => config.label).join('|')}| + |${tableConfig.map(() => '---').join('|')}| + ${internalPRS.map((pr) => `|${tableConfig.map((config) => pr[config.value]).join('|')}|`).join('\n')} + + ## External PRs + + |${tableConfig.map((config) => config.label).join('|')}| + |${tableConfig.map(() => '---').join('|')}| + ${externalPRs.map((pr) => `|${tableConfig.map((config) => pr[config.value]).join('|')}|`).join('\n')} + ` + // trim double spaces and return the changelog + return changelog.replace(/ +/g, '') +} + +function mapPRWithAffectedDestinations(pr) { + let affectedDestinations = [] + if (pr.labels.includes('mode:cloud')) { + pr.files + .filter((file) => file.includes('packages/destination-actions/src/destinations')) + .forEach((file) => { + const match = file.match(/packages\/destination-actions\/src\/destinations\/([^\/]+)\/*/) + if (match && !affectedDestinations.includes(match[1])) { + affectedDestinations.push(match[1]) + } + }) + } + if (pr.labels.includes('mode:device')) { + pr.files + .filter((file) => file.includes('packages/browser-destinations/destinations')) + .forEach((file) => { + const match = file.match(/packages\/browser-destinations\/([^\/]+)\/*/) + if (match && !affectedDestinations.includes(match[1])) { + affectedDestinations.push(match[1]) + } + }) + } + return { + ...pr, + affectedDestinations: affectedDestinations.join(', ') + } +} + +function formatNPMPackageURL(tag) { + const [name, version] = tag.split(/@(\d.*)/) + return `[${tag}](https://www.npmjs.com/package/${name}/v/${version})` +} diff --git a/tsconfig.json b/tsconfig.json index 38a5caf050..81a9b4dddf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,10 +16,11 @@ "experimentalDecorators": true, "skipLibCheck": true }, - "exclude": ["dist", "templates"], + "exclude": ["dist", "templates", "scripts"], "references": [ { "path": "./packages/ajv-human-errors/tsconfig.build.json" }, { "path": "./packages/browser-destinations/tsconfig.build.json" }, + { "path": "./packages/browser-destination-runtime/tsconfig.build.json" }, { "path": "./packages/core/tsconfig.build.json" }, { "path": "./packages/destination-actions/tsconfig.build.json" }, { "path": "./packages/destination-subscriptions/tsconfig.build.json" }, diff --git a/yarn.lock b/yarn.lock index 7dc4740707..f2808469a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1021,16 +1021,206 @@ resolved "https://registry.yarnpkg.com/@braze/web-sdk/-/web-sdk-4.7.0.tgz#5adb930690d78dd3bc77a93dececde360a08d0f7" integrity sha512-fYdCyjlZqBswlebO8XmbPj04soLycHxnSvCQ/bWpi4OB00fz/ne34vv1LzIP3d0V5++jwjsutxdEi5mRiiMK1Q== +"@bucketco/tracking-sdk@^2.0.0": + version "2.1.6" + resolved "https://registry.yarnpkg.com/@bucketco/tracking-sdk/-/tracking-sdk-2.1.6.tgz#f6373812c5a20af037b7696c0b69598810556790" + integrity sha512-LoB32PdaIPTyzmjjrgkoDkHIXXKhEFegrCshIxpgPHG3YrHapqjsjRuDgf9hQzfIpl9QAdRfpChTLWMtDkad7w== + dependencies: + "@floating-ui/dom" "^1.4.5" + cross-fetch "^4.0.0" + is-bundling-for-browser-or-node "^1.1.1" + js-cookie "^3.0.5" + preact "^10.16.0" + +"@bufbuild/buf-darwin-arm64@1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.28.0.tgz#2a891aed84a6220628e802f9f3feb11023877e32" + integrity sha512-trbnKKCINrRUXf0Rs88QmniZeQ4ODdsA9yISOj5JdeVDr9rQf1j/P2NUM+JimQpLm78I1CRR5qQPIX3Q8xR0NA== + +"@bufbuild/buf-darwin-x64@1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.28.0.tgz#31c8037565a5ebee2cec09e9d2810bdc4e98550a" + integrity sha512-AVhGVJjaR26Qe0gNv+3AijeaQABJyFY8tlInEOOtTaQi2UGR8cgQoYBCziL3NQfH06wi7nEwTJm/ej2JUpAt2Q== + +"@bufbuild/buf-linux-aarch64@1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.28.0.tgz#6905429c44eb07bfbdd836f6c2d67648acea73ac" + integrity sha512-5qzLdO2MpXHPh2W5Rlf58oW8iH+wwOIjQs3vlgtgeI0nGu0FhRgpcrJ1E0lswTUE0NW19RMLI+q/QpjEl9W3aw== + +"@bufbuild/buf-linux-x64@1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.28.0.tgz#26e74ab66808c26f807d650e0608b29be8bfa3a1" + integrity sha512-gZm7vGjhcabb1Zqp+ARYiJYCNm2mtbXFqo9Cqy0hpaonWaKAn7lbjtT0tG7rVX++QIfOpE3CwnjUNHck5xKP6A== + +"@bufbuild/buf-win32-arm64@1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.28.0.tgz#95eac3371f322f4df5291138bf87cf8474b39e26" + integrity sha512-ptIfiTYW2cMlPOcnkz3YF/aSR9ztAzeozycv460qDR0p0c0KYHKRTTFKD8TRahoyU7znmWEluYBdmKZAbbtwKg== + +"@bufbuild/buf-win32-x64@1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.28.0.tgz#af811f625cced53dc52e0127489ae96f1a9ca56b" + integrity sha512-vwjMUfelrB8RD/xHdR6MVEl9XqIxvASvzj0szz70hvQmzmU4BEOEUTHtERMOnBJxiPE1a28YEfpUlxyvm+COCg== + +"@bufbuild/buf@^1.28.0": + version "1.28.0" + resolved "https://registry.yarnpkg.com/@bufbuild/buf/-/buf-1.28.0.tgz#306fa54101597eec92e71d892a9f0a696624c546" + integrity sha512-QizjughxiWj53BTFijxQN5YDCcIriLAsCSYgxk+l9YzEC3hHAjCBen0hGJcSezWDLKWiGxAD6AMXiRIfAHKZjQ== + optionalDependencies: + "@bufbuild/buf-darwin-arm64" "1.28.0" + "@bufbuild/buf-darwin-x64" "1.28.0" + "@bufbuild/buf-linux-aarch64" "1.28.0" + "@bufbuild/buf-linux-x64" "1.28.0" + "@bufbuild/buf-win32-arm64" "1.28.0" + "@bufbuild/buf-win32-x64" "1.28.0" + +"@bufbuild/protobuf@1.4.2", "@bufbuild/protobuf@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-1.4.2.tgz#dc4faf21264a47b71a15806616043cb006e80ac8" + integrity sha512-JyEH8Z+OD5Sc2opSg86qMHn1EM1Sa+zj/Tc0ovxdwk56ByVNONJSabuCUbLQp+eKN3rWNfrho0X+3SEqEPXIow== + +"@bufbuild/protoc-gen-es@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@bufbuild/protoc-gen-es/-/protoc-gen-es-1.4.2.tgz#00c8b09430dd1154e626da7c247fd6425a1cd41d" + integrity sha512-/It7M2s8H1zTDvUMJu6vhBmtnzeFL2VS6e78RYIY38602pNXDK/vbteKUo4KrG0O07lOPFu87hHZ0Y+w5Ib6iw== + dependencies: + "@bufbuild/protobuf" "^1.4.2" + "@bufbuild/protoplugin" "1.4.2" + +"@bufbuild/protoplugin@1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@bufbuild/protoplugin/-/protoplugin-1.4.2.tgz#abf9b0e6a3dc8b52b1d6699d7a1ce5219fa82322" + integrity sha512-5IwGC1ZRD2A+KydGXeaSOErwfILLqVtvMH/RkN+cOoHcQd4EYXFStcF7g7aR+yICRDEEjQVi5tQF/qPGBSr9vg== + dependencies: + "@bufbuild/protobuf" "1.4.2" + "@typescript/vfs" "^1.4.0" + typescript "4.5.2" + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== -"@discoveryjs/json-ext@^0.5.0", "@discoveryjs/json-ext@^0.5.5": +"@discoveryjs/json-ext@^0.5.0": version "0.5.5" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.5.tgz#9283c9ce5b289a3c4f61c12757469e59377f81f3" integrity sha512-6nFkfkmSeV/rqSaS4oWHgmpnYw194f6hmWF5is6b0J1naJZoiD0NTc9AiUwPHvWsowkjuHErCZT1wa0jg+BLIA== +"@esbuild/aix-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" + integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA== + +"@esbuild/android-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4" + integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA== + +"@esbuild/android-arm@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" + integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w== + +"@esbuild/android-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d" + integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew== + +"@esbuild/darwin-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e" + integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g== + +"@esbuild/darwin-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd" + integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A== + +"@esbuild/freebsd-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487" + integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA== + +"@esbuild/freebsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c" + integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg== + +"@esbuild/linux-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b" + integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA== + +"@esbuild/linux-arm@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef" + integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w== + +"@esbuild/linux-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601" + integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA== + +"@esbuild/linux-loong64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" + integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA== + +"@esbuild/linux-mips64el@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec" + integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w== + +"@esbuild/linux-ppc64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8" + integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg== + +"@esbuild/linux-riscv64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf" + integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg== + +"@esbuild/linux-s390x@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8" + integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg== + +"@esbuild/linux-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78" + integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg== + +"@esbuild/netbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b" + integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA== + +"@esbuild/openbsd-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0" + integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw== + +"@esbuild/sunos-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30" + integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA== + +"@esbuild/win32-arm64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae" + integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A== + +"@esbuild/win32-ia32@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67" + integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ== + +"@esbuild/win32-x64@0.19.12": + version "0.19.12" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae" + integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA== + "@eslint/eslintrc@^0.4.3": version "0.4.3" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" @@ -1046,17 +1236,37 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@fullstory/browser@^1.4.9": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@fullstory/browser/-/browser-1.7.1.tgz#eb94fcb5e21b13a1b30de58951480ac344e61cdd" - integrity sha512-IBPisG+xRyTHHX8XkZJkQRbP2hVYNMZUYW8R3YiB582dl/VZImkFN+LopIAfPqB97FAZgUTofi7flkrHT4Qmtg== +"@floating-ui/core@^1.4.2": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.1.tgz#62707d7ec585d0929f882321a1b1f4ea9c680da5" + integrity sha512-QgcKYwzcc8vvZ4n/5uklchy8KVdjJwcOeI+HnnTNclJjs2nYsy23DOCf+sSV1kBwD9yDAoVKCkv/gEPzgQU3Pw== + dependencies: + "@floating-ui/utils" "^0.1.3" + +"@floating-ui/dom@^1.4.5": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa" + integrity sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA== dependencies: - "@fullstory/snippet" "1.3.1" + "@floating-ui/core" "^1.4.2" + "@floating-ui/utils" "^0.1.3" -"@fullstory/snippet@1.3.1": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@fullstory/snippet/-/snippet-1.3.1.tgz#6817ea94601e071e630b25262e703ca356a5f537" - integrity sha512-NgrBWGHH5i8zejlRFSyJNhovkNqHAXsWKrcXIWaABrgESwbkdGETjOU7BD7d1ZeT0X+QXL/2yr/1y4xnWfVkwQ== +"@floating-ui/utils@^0.1.3": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" + integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== + +"@fullstory/browser@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@fullstory/browser/-/browser-2.0.3.tgz#09c0b590d81a8098f8fd85d160a44e4f73e15bfb" + integrity sha512-usjH8FB1O2LiSWoblsuKhFhlYDGpIPuyQVOx4JXtxm9QmQARdKZdNq1vPijxuDvOGjhwtVZa4JmhvByRRuDPnQ== + dependencies: + "@fullstory/snippet" "2.0.3" + +"@fullstory/snippet@2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@fullstory/snippet/-/snippet-2.0.3.tgz#d5410132becc3d0115bb129b57461d228c73b5f0" + integrity sha512-EaCuTQSLv5FvnjHLbTxErn3sS1+nLqf1p6sA/c4PV49stBtkUakA0eLhJJdaw0WLdXyEzZXf86lRNsjEzrgGPw== "@gar/promisify@^1.1.3": version "1.1.3" @@ -1802,6 +2012,18 @@ dependencies: nx "15.9.6" +"@oclif/command@1.8.36": + version "1.8.36" + resolved "https://registry.npmjs.org/@oclif/command/-/command-1.8.36.tgz#9739b9c268580d064a50887c4597d1b4e86ca8b5" + integrity sha512-/zACSgaYGtAQRzc7HjzrlIs14FuEYAZrMOEwicRoUnZVyRunG4+t5iSEeQu0Xy2bgbCD0U1SP/EdeNZSTXRwjQ== + dependencies: + "@oclif/config" "^1.18.2" + "@oclif/errors" "^1.3.6" + "@oclif/help" "^1.0.1" + "@oclif/parser" "^3.8.17" + debug "^4.1.1" + semver "^7.5.4" + "@oclif/command@^1.5.20", "@oclif/command@^1.6.0", "@oclif/command@^1.8.0": version "1.8.0" resolved "https://registry.yarnpkg.com/@oclif/command/-/command-1.8.0.tgz#c1a499b10d26e9d1a611190a81005589accbb339" @@ -1814,18 +2036,6 @@ debug "^4.1.1" semver "^7.3.2" -"@oclif/command@^1.8.25": - version "1.8.25" - resolved "https://registry.yarnpkg.com/@oclif/command/-/command-1.8.25.tgz#9bc7fea9d03320837ca82d851b43b1f089d7b7b9" - integrity sha512-teCfKH6GNF46fiCn/P5EMHX93RE3KJAW4i0sq3X9phrzs6807WRauhythdc8OKINxd+LpqwQ1i5bnaCKvLZRcQ== - dependencies: - "@oclif/config" "^1.18.2" - "@oclif/errors" "^1.3.6" - "@oclif/help" "^1.0.1" - "@oclif/parser" "^3.8.10" - debug "^4.1.1" - semver "^7.5.1" - "@oclif/config@1.18.6": version "1.18.6" resolved "https://registry.yarnpkg.com/@oclif/config/-/config-1.18.6.tgz#37367026b3110a2f04875509b1920a8ee4489f21" @@ -1966,6 +2176,16 @@ chalk "^4.1.0" tslib "^2.5.0" +"@oclif/parser@^3.8.17": + version "3.8.17" + resolved "https://registry.npmjs.org/@oclif/parser/-/parser-3.8.17.tgz#e1ce0f29b22762d752d9da1c7abd57ad81c56188" + integrity sha512-l04iSd0xoh/16TGVpXb81Gg3z7tlQGrEup16BrVLsZBK6SEYpYHRJZnM32BwZrHI97ZSFfuSwVlzoo6HdsaK8A== + dependencies: + "@oclif/errors" "^1.3.6" + "@oclif/linewrap" "^1.0.0" + chalk "^4.1.0" + tslib "^2.6.2" + "@oclif/plugin-help@^3", "@oclif/plugin-help@^3.2.0": version "3.2.2" resolved "https://registry.yarnpkg.com/@oclif/plugin-help/-/plugin-help-3.2.2.tgz#063ee08cee556573a5198fbdfdaa32796deba0ed" @@ -2184,15 +2404,41 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== +"@puppeteer/browsers@1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-1.9.0.tgz#dfd0aad0bdc039572f1b57648f189525d627b7ff" + integrity sha512-QwguOLy44YBGC8vuPP2nmpX4MUN2FzWbsnvZJtiCzecU3lHmVZkaC1tq6rToi9a200m8RzlVtDyxCS0UIDrxUg== + dependencies: + debug "4.3.4" + extract-zip "2.0.1" + progress "2.0.3" + proxy-agent "6.3.1" + tar-fs "3.0.4" + unbzip2-stream "1.4.3" + yargs "17.7.2" + +"@puppeteer/browsers@^1.8.0": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-1.9.1.tgz#384ee8b09786f0e8f62b1925e4c492424cb549ee" + integrity sha512-PuvK6xZzGhKPvlx3fpfdM2kYY3P/hB1URtK8wA7XUJ6prn6pp22zvJHu48th0SGcHL9SutbPHrFuQgfXTFobWA== + dependencies: + debug "4.3.4" + extract-zip "2.0.1" + progress "2.0.3" + proxy-agent "6.3.1" + tar-fs "3.0.4" + unbzip2-stream "1.4.3" + yargs "17.7.2" + "@segment/a1-notation@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@segment/a1-notation/-/a1-notation-2.1.4.tgz#35a48a0688019c3ffff23b1ba890e864c891a11f" integrity sha512-SId7GOdDFmm/B9ajIQpXELHW4OTbVvmJbOsoJkQOcUEtoZIiX2UWfk1v4BpKql8wJW9oAhzhIIru2Pv2Yxcg+w== -"@segment/action-emitters@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@segment/action-emitters/-/action-emitters-1.1.2.tgz#962527ecc014a1123ac5614ef10419b7d9371730" - integrity sha512-U6+ljitpZZHsp+BAF53pZix9ARJlHUW5NqMCuQqVHWK9w64aS7UvrfFnd5pFI/NxlmMRw068q6kIJ+fB8XFfOA== +"@segment/action-emitters@^1.3.6": + version "1.3.6" + resolved "https://registry.yarnpkg.com/@segment/action-emitters/-/action-emitters-1.3.6.tgz#0160442ae99821c43a9465c05226afddec73cbe5" + integrity sha512-866kjuuebzDdRvls0OGFw9uPRiY5xK9N2unfPdO77TJBSGak31csmhQhYk6lMcanFUlfTewRSVS8Rt0kXQk0iQ== dependencies: "@types/node" "^18.11.15" @@ -2386,6 +2632,11 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== +"@sindresorhus/merge-streams@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-1.0.0.tgz#9cd84cc15bc865a5ca35fcaae198eb899f7b5c90" + integrity sha512-rUV5WyJrJLoloD4NDN1V1+LDMDWOa4OTsT4yYJwQNpTU6FWxkxHpL7eu4w+DmiH8x/EAM1otkPE1+LaspIbplw== + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -2407,174 +2658,64 @@ dependencies: "@sinonjs/commons" "^1.7.0" -"@size-limit/file@6.0.3": - version "6.0.3" - resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-6.0.3.tgz#8cae76a17e6061416353ea75799b45e8fc565191" - integrity sha512-OfDrkJBB7OAWtnedz6jpmL1pjlha1MpgtvYwUSP2442qB96nwMN5Ig78XR3ldPj2cbxq1FVoNnc2vfWMi40vQA== - dependencies: - semver "7.3.5" - -"@size-limit/preset-small-lib@^6.0.3": - version "6.0.3" - resolved "https://registry.yarnpkg.com/@size-limit/preset-small-lib/-/preset-small-lib-6.0.3.tgz#30c37000c61ac9bbb8e848a7ff43221f19d60942" - integrity sha512-HvHgrLp5sNEWfw3js6oBWty2dQyDf2SDhehDGJuQRL+8IVRFQcx0XLkqiHAdMrCNvDmfRPfvLkcp5wcxpjgbrw== +"@sitespeed.io/tracium@^0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@sitespeed.io/tracium/-/tracium-0.3.3.tgz#b497a4a8d5837db1fd9e3053c99b78f6c0e1f53b" + integrity sha512-dNZafjM93Y+F+sfwTO5gTpsGXlnc/0Q+c2+62ViqP3gkMWvHEMSKkaEHgVJLcLg3i/g19GSIPziiKpgyne07Bw== dependencies: - "@size-limit/file" "6.0.3" - "@size-limit/webpack" "6.0.3" + debug "^4.1.1" -"@size-limit/webpack@6.0.3": - version "6.0.3" - resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-6.0.3.tgz#43002b1ac8b72ea0ea91f80968a9306ac5bb2aa4" - integrity sha512-4LI3SpkL0YbOIJsE7+t0LdPgqMeRDDwk1LELHHyk1b67tIjQ9wfnehI0BPT4LYiT23vN7Tg1TN0ofjwuRCLdbA== +"@size-limit/esbuild@11.0.2": + version "11.0.2" + resolved "https://registry.npmjs.org/@size-limit/esbuild/-/esbuild-11.0.2.tgz#8189d8f64b88a95a546ccf6393200cef57f3fe9a" + integrity sha512-67p+y+wkMBJJegLZUp1X3v1YEvgGSbbAukFbHtxJ1c/DTj/ApiHvtgMzvA5ij+A5UOay+jSU4bXetpNJlUK3Ow== dependencies: - "@statoscope/webpack-plugin" "^5.13.1" - css-loader "^6.4.0" - css-minimizer-webpack-plugin "^3.0.2" - escape-string-regexp "^4.0.0" - mkdirp "^1.0.4" - nanoid "^3.1.28" - style-loader "^3.3.0" - webpack "^5.56.0" + esbuild "^0.19.11" + nanoid "^5.0.4" -"@socket.io/component-emitter@~3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" - integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== +"@size-limit/file@11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@size-limit/file/-/file-11.0.2.tgz#1f53087e1c5043e09a37391702dc3a7ce8751935" + integrity sha512-874lrMtWYRL+xb/6xzejjwD+krfHTOo+2uFGpZfJScvuNv91Ni2O7k0o09zC70VzCYBGkXquV92ln/H+/ognGg== -"@statoscope/extensions@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/extensions/-/extensions-5.14.1.tgz#b7c32b39de447da76b9fa2daada61b2f699754e6" - integrity sha512-5O31566+bOkkdYFH81mGGBTh0YcU0zoYurTrsK5uZfpNY87ZCPpptrszX8npTRHNsxbjBBNt7vAwImJyYdhzLw== +"@size-limit/preset-big-lib@^11.0.1": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@size-limit/preset-big-lib/-/preset-big-lib-11.0.2.tgz#456f8331809a81b74b485cbbd9f82d6aee632fff" + integrity sha512-yDqI1CDHf/Mv3RXsXDyOT8eayev7YDzCyqV5X7ZOQAs3zqJVNDCb1LAoO9njhhGbDEpHa4ODfWean+s5cgy5Fg== + dependencies: + "@size-limit/file" "11.0.2" + "@size-limit/time" "11.0.2" + "@size-limit/webpack" "11.0.2" + size-limit "11.0.2" -"@statoscope/helpers@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/helpers/-/helpers-5.14.1.tgz#80e733d82585f6fc4636b26a9b4a952272ba71c8" - integrity sha512-Gl7NB06cOxxh86tFk75yQmcOCQ3b8i4euYlyxvdz4tWkzeZw8g5VLPH9g3X4uQbPM4rqkae/KmgtRo5Ey1km8w== +"@size-limit/preset-small-lib@^11.0.2": + version "11.0.2" + resolved "https://registry.npmjs.org/@size-limit/preset-small-lib/-/preset-small-lib-11.0.2.tgz#3fe304d8b4d567f58aeff40065e7c43f9de04586" + integrity sha512-Yo+RRHCLz29PMmRXzq69E3LjiAivspF2XRGdpZ+QdeFOotd3hBYVMJC9GDF3tEigPtfvEJk4L8YLlUK+SE90FA== dependencies: - "@types/archy" "^0.0.32" - "@types/semver" "^7.3.6" - archy "~1.0.0" - jora "^1.0.0-beta.5" - semver "^7.3.5" + "@size-limit/esbuild" "11.0.2" + "@size-limit/file" "11.0.2" + size-limit "11.0.2" -"@statoscope/report-writer@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/report-writer/-/report-writer-5.14.1.tgz#ae5f7151ef6348338707b28abf6d2137c368ed40" - integrity sha512-N7pjpiKspDQ6K0B9HNBs8u3bhueiYmJEqAazSw/U3v8hThcP31i/FLCH90bu/8Sj436xAE1KATtGzJnsN5mbQA== +"@size-limit/time@11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@size-limit/time/-/time-11.0.2.tgz#1b7d23122d15bc944bb7fbb3362cec94c58f1b56" + integrity sha512-5MLgwI6DHpOWTaILE/CnwXp6cHEz6leBkh6od+AyfulAnrWsDzz4XZ4JHu04RJiyAJKPxGVPtSZkTgxmpdlwSQ== dependencies: - "@discoveryjs/json-ext" "^0.5.5" + estimo "^3.0.1" -"@statoscope/stats-extension-compressed@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/stats-extension-compressed/-/stats-extension-compressed-5.14.1.tgz#456927e0e2942b99fe498466c6db32050af41005" - integrity sha512-FnfmL18OAHWg1l95tBKeMGHmeNbEOdnHlOE1c9KT3TWc5kt4q8ntpChL4AAZJSgn7uBy5WKMgjZSr5aUGebHGQ== +"@size-limit/webpack@11.0.2": + version "11.0.2" + resolved "https://registry.yarnpkg.com/@size-limit/webpack/-/webpack-11.0.2.tgz#9b1257bc086a0ea67e8e20df72d7c165a5ddd55a" + integrity sha512-MWS/KuQWez6UOUveVKhlMSgeduUAIktRFIe6z/x9wiAOEF6tCF9iLVVkzhFen2wbVR0p3sT9eW9WLiulB6yPHg== dependencies: - "@statoscope/helpers" "5.14.1" - gzip-size "^6.0.0" + nanoid "^5.0.4" + webpack "^5.89.0" -"@statoscope/stats-extension-custom-reports@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/stats-extension-custom-reports/-/stats-extension-custom-reports-5.14.1.tgz#fd11a9c20589e492f3b8804fc1cece39cf73b8a8" - integrity sha512-+t7gq2zZu8frwJF651+fq1BOZPMdngEwtNhvdRCSWyBE2uYvotVnngQE+di1/Idaosshnf4GdwX82bSMIlF1mQ== - dependencies: - "@statoscope/extensions" "5.14.1" - "@statoscope/helpers" "5.14.1" - "@statoscope/stats" "5.14.1" - "@statoscope/types" "5.14.1" - -"@statoscope/stats-extension-package-info@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/stats-extension-package-info/-/stats-extension-package-info-5.14.1.tgz#92078819a6c3c3250647d477d1f8b1d7ec153b79" - integrity sha512-TPKq6qJ7hxyQdPPpcbPsagf9MjbmPRLLo0F2ceafJ11ubP0t8LQZ+m0Orqp5ay3wuzvBy406w/njPwHQuEBHZw== - dependencies: - "@statoscope/helpers" "5.14.1" - -"@statoscope/stats-extension-stats-validation-result@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/stats-extension-stats-validation-result/-/stats-extension-stats-validation-result-5.14.1.tgz#d1dd76f0e735bb66d9f63a99b9be3a51dbb0e3fb" - integrity sha512-/TiohT+iO5Idipdd3JmzZ/Utaq3nXeKfZKnvXahkls5x6rpRQgbXs2IrDRtf7rJg4B/g2GuZUMsu4m76rHdkwA== - dependencies: - "@statoscope/extensions" "5.14.1" - "@statoscope/helpers" "5.14.1" - "@statoscope/stats" "5.14.1" - "@statoscope/types" "5.14.1" - -"@statoscope/stats@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/stats/-/stats-5.14.1.tgz#728656629bc06aa4bf5634398662ac05287793d5" - integrity sha512-Kz7kCKuT6DXaqAPfyTwp27xHMDUna9o6UlRSQXXBZ8Yyk7eYYvTNw+5ffRyqivL9IOzD7FQYDQ6VUBHh0UfyDw== - -"@statoscope/types@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/types/-/types-5.14.1.tgz#4cc3da44f6a63d4318c50a75efe89f97d512ac8b" - integrity sha512-vIo7aq2E71AC3y3mdnZqA5aupYUaEIHuPD2gUG0bAA8zTXH7YICk7nRkuxx7xnCBhTZTXAgvtF8hgQ35K4N8oQ== - dependencies: - "@statoscope/stats" "5.14.1" - -"@statoscope/webpack-model@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/webpack-model/-/webpack-model-5.14.1.tgz#999f4be029b61ced6a9a1b5b74e50136e13a2ab6" - integrity sha512-qSZmtRAk2TSCDaG+ZiMBBfe850/McoNg0Ka2JKsAeSZQ7OOSNN24U/jhmcXtHaoUzxlEGsrEyx8Xz4BMdibjgw== - dependencies: - "@statoscope/extensions" "5.14.1" - "@statoscope/helpers" "5.14.1" - "@statoscope/stats" "5.14.1" - "@statoscope/stats-extension-compressed" "5.14.1" - "@statoscope/stats-extension-custom-reports" "5.14.1" - "@statoscope/stats-extension-package-info" "5.14.1" - "@statoscope/stats-extension-stats-validation-result" "5.14.1" - "@statoscope/types" "5.14.1" - "@types/md5" "^2.3.0" - "@types/webpack" "^5.0.0" - ajv "^8.6.3" - md5 "^2.3.0" - -"@statoscope/webpack-plugin@^5.13.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/webpack-plugin/-/webpack-plugin-5.14.1.tgz#10eac3fed165794dfa094367641e3df3ede18fb0" - integrity sha512-FVbBnNaEHS10UHflXKf2ES5LRkWRlDNpECwY3Q1Ir7LyQ1okyYAb6VD4ePyclsQ1+pU64mvU3EVUbkFJWGgXMQ== - dependencies: - "@discoveryjs/json-ext" "^0.5.5" - "@statoscope/report-writer" "5.14.1" - "@statoscope/stats" "5.14.1" - "@statoscope/stats-extension-compressed" "5.14.1" - "@statoscope/stats-extension-custom-reports" "5.14.1" - "@statoscope/types" "5.14.1" - "@statoscope/webpack-model" "5.14.1" - "@statoscope/webpack-stats-extension-compressed" "5.14.1" - "@statoscope/webpack-stats-extension-package-info" "5.14.1" - "@statoscope/webpack-ui" "5.14.1" - "@types/node" "^12.20.15" - "@types/webpack" "^5.0.0" - open "^8.2.1" - -"@statoscope/webpack-stats-extension-compressed@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/webpack-stats-extension-compressed/-/webpack-stats-extension-compressed-5.14.1.tgz#bc8ea1167e61160b7fe00a4f36d395e52cd00582" - integrity sha512-fg0m30TqM1vQx0aEkyILasvmtYxTmcR0mEWsK3bR7sp+lmLP+GvbGhWoQ51GTmiVofpO83yjvAlc1YhvLAEVWA== - dependencies: - "@statoscope/stats" "5.14.1" - "@statoscope/stats-extension-compressed" "5.14.1" - "@statoscope/webpack-model" "5.14.1" - "@types/webpack" "^5.0.0" - -"@statoscope/webpack-stats-extension-package-info@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/webpack-stats-extension-package-info/-/webpack-stats-extension-package-info-5.14.1.tgz#527616db566f913a1bee46482e0636cbad389e5a" - integrity sha512-J5turi/cQSxttPgusMt1ppefBdk4a7hocpVmdO7myv+v5S996RVFrmKnyZeOavp15PivA4LytJgO8Oq6/WORMA== - dependencies: - "@statoscope/stats" "5.14.1" - "@statoscope/stats-extension-package-info" "5.14.1" - "@statoscope/webpack-model" "5.14.1" - "@types/webpack" "^5.0.0" - -"@statoscope/webpack-ui@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@statoscope/webpack-ui/-/webpack-ui-5.14.1.tgz#fbbd01bb43bd3071ed5b4a997457a8432314faca" - integrity sha512-lKiA9g7UaBZDA1Yo8eENjby2PXHB21lExNQ/nBbg4jjIqXGZ2+lU7mMYXhxw1n4PiVOdL7GrhSWlT6ByAzB3vw== - dependencies: - "@statoscope/types" "5.14.1" - highcharts "^9.2.2" +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== "@stdlib/array-float32@^0.0.x": version "0.0.6" @@ -3448,10 +3589,10 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== -"@trysound/sax@0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.1.1.tgz#3348564048e7a2d7398c935d466c0414ebb6a669" - integrity sha512-Z6DoceYb/1xSg5+e+ZlPZ9v0N16ZvZ+wYMraFue4HYrE4ttONKtsvruIRf6t9TBR0YvSOfi1hUU0fJfBLCDYow== +"@tootallnate/quickjs-emscripten@^0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" + integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== "@tufjs/canonical-json@1.0.0": version "1.0.0" @@ -3466,11 +3607,6 @@ "@tufjs/canonical-json" "1.0.0" minimatch "^9.0.0" -"@types/archy@^0.0.32": - version "0.0.32" - resolved "https://registry.yarnpkg.com/@types/archy/-/archy-0.0.32.tgz#8b572741dad9172dfbf289397af1bb41296d3e40" - integrity sha512-5ZZ5+YGmUE01yejiXsKnTcvhakMZ2UllZlMsQni53Doc1JWhe21ia8VntRoRD6fAEWw08JBh/z9qQHJ+//MrIg== - "@types/aria-query@^5.0.0": version "5.0.1" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc" @@ -3603,14 +3739,6 @@ resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.1.1.tgz#29c539826376a65e7f7d672d51301f37ed718f6d" integrity sha512-RQul5wEfY7BjWm0sYY86cmUN/pcXWGyVxWX93DFFJvcrxax5zKlieLwA3T77xJGwNcZW0YW6CYG70p1m8xPFmA== -"@types/eslint-scope@^3.7.0": - version "3.7.1" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.1.tgz#8dc390a7b4f9dd9f1284629efce982e41612116e" - integrity sha512-SCFeogqiptms4Fg29WpOTk5nHIzfpKCemSN63ksBQYKTcXoJEmJagV+DhVmbapZzY4/5YaOV1nZwrsU79fFm1g== - dependencies: - "@types/eslint" "*" - "@types/estree" "*" - "@types/eslint-scope@^3.7.3": version "3.7.4" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" @@ -3627,7 +3755,7 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^0.0.50": +"@types/estree@*": version "0.0.50" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83" integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw== @@ -3710,10 +3838,10 @@ dependencies: "@types/node" "*" -"@types/gtag.js@^0.0.13": - version "0.0.13" - resolved "https://registry.yarnpkg.com/@types/gtag.js/-/gtag.js-0.0.13.tgz#54d746635e09fa61242e05b574b1ac068e6a90dd" - integrity sha512-yOXFkfnt1DQr1v9B4ERulJOGnbdVqnPHV8NG4nkQhnu4qbrJecQ06DlaKmSjI3nzIwBj5U9/X61LY4sTc2KbaQ== +"@types/gtag.js@^0.0.19": + version "0.0.19" + resolved "https://registry.yarnpkg.com/@types/gtag.js/-/gtag.js-0.0.19.tgz#40ebbef85c8b2915df164d16304d3fbdfdaa1a41" + integrity sha512-KHoDzrf9rSd0mooKN576PjExpdk/XRrNu4RQnmigsScSTSidwyOUe9kDrHz9UPKjiBrx2QEsSkexbJSgS0j72w== "@types/http-cache-semantics@*": version "4.0.1" @@ -3817,13 +3945,6 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.175.tgz#b78dfa959192b01fae0ad90e166478769b215f45" integrity sha512-XmdEOrKQ8a1Y/yxQFOMbC47G/V2VDO1GvMRnl4O75M4GW/abC5tnfzadQYkqEveqRM1dEJGFFegfPNA2vvx2iw== -"@types/md5@^2.3.0": - version "2.3.1" - resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.3.1.tgz#010bcf3bb50a2cff3a574cb1c0b4051a9c67d6bc" - integrity sha512-OK3oe+ALIoPSo262lnhAYwpqFNXbiwH2a+0+Z5YBnkQEwWD8fk5+PIeRhYA48PzvX9I4SGNpWy+9bLj8qz92RQ== - dependencies: - "@types/node" "*" - "@types/mime@*": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" @@ -3864,11 +3985,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f" integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA== -"@types/node@^12.20.15": - version "12.20.33" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.33.tgz#24927446e8b7669d10abacedd16077359678f436" - integrity sha512-5XmYX2GECSa+CxMYaFsr2mrql71Q4EvHjKS+ox/SiwSdaASMoBIWE6UmZqFO+VX1jIcsYLStI4FFoB6V7FeIYw== - "@types/node@^18.11.15": version "18.11.15" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.15.tgz#de0e1fbd2b22b962d45971431e2ae696643d3f5d" @@ -3943,11 +4059,6 @@ "@types/glob" "*" "@types/node" "*" -"@types/semver@^7.3.6": - version "7.3.9" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc" - integrity sha512-L/TMpyURfBkf+o/526Zb6kd/tchUP3iBDEPjqjb+U2MAJhVRxxrmr2fwpe08E7QsV7YLcpq0tUaQ9O9x97ZIxQ== - "@types/send@*": version "0.17.1" resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.1.tgz#ed4932b8a2a805f1fe362a70f4e62d0ac994e301" @@ -4046,15 +4157,6 @@ resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190" integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ== -"@types/webpack@^5.0.0": - version "5.28.0" - resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-5.28.0.tgz#78dde06212f038d77e54116cfe69e88ae9ed2c03" - integrity sha512-8cP0CzcxUiFuA9xGJkfeVpqmWTk9nx6CWwamRGCj95ph1SmlRRk9KlCZ6avhCbZd4L68LvYT6l1kpdEnQXrF8w== - dependencies: - "@types/node" "*" - tapable "^2.2.0" - webpack "^5" - "@types/which@^1.3.2": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf" @@ -4208,6 +4310,13 @@ "@typescript-eslint/types" "5.1.0" eslint-visitor-keys "^3.0.0" +"@typescript/vfs@^1.4.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@typescript/vfs/-/vfs-1.5.0.tgz#ed942922724f9ace8c07c80b006c47e5e3833218" + integrity sha512-AJS307bPgbsZZ9ggCT3wwpg3VbTKMFNHfaY/uF0ahSkYYrPF2dSSKDNIDIQAHm9qJqbLvCsSJH7yN4Vs/CsMMg== + dependencies: + debug "^4.1.1" + "@wdio/cli@^7.26.0": version "7.30.0" resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.30.0.tgz#974a1d0763c077902786c71934cf72f3b0bc4804" @@ -4372,14 +4481,6 @@ "@wdio/types" "7.26.0" p-iteration "^1.1.8" -"@webassemblyjs/ast@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" - integrity sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw== - dependencies: - "@webassemblyjs/helper-numbers" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" @@ -4388,45 +4489,21 @@ "@webassemblyjs/helper-numbers" "1.11.6" "@webassemblyjs/helper-wasm-bytecode" "1.11.6" -"@webassemblyjs/floating-point-hex-parser@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz#f6c61a705f0fd7a6aecaa4e8198f23d9dc179e4f" - integrity sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ== - "@webassemblyjs/floating-point-hex-parser@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== -"@webassemblyjs/helper-api-error@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz#1a63192d8788e5c012800ba6a7a46c705288fd16" - integrity sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg== - "@webassemblyjs/helper-api-error@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== -"@webassemblyjs/helper-buffer@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz#832a900eb444884cde9a7cad467f81500f5e5ab5" - integrity sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA== - "@webassemblyjs/helper-buffer@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz#b66d73c43e296fd5e88006f18524feb0f2c7c093" integrity sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA== -"@webassemblyjs/helper-numbers@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz#64d81da219fbbba1e3bd1bfc74f6e8c4e10a62ae" - integrity sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ== - dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@xtuc/long" "4.2.2" - "@webassemblyjs/helper-numbers@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" @@ -4436,26 +4513,11 @@ "@webassemblyjs/helper-api-error" "1.11.6" "@xtuc/long" "4.2.2" -"@webassemblyjs/helper-wasm-bytecode@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz#f328241e41e7b199d0b20c18e88429c4433295e1" - integrity sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q== - "@webassemblyjs/helper-wasm-bytecode@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== -"@webassemblyjs/helper-wasm-section@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz#21ee065a7b635f319e738f0dd73bfbda281c097a" - integrity sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/helper-wasm-section@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz#ff97f3863c55ee7f580fd5c41a381e9def4aa577" @@ -4466,13 +4528,6 @@ "@webassemblyjs/helper-wasm-bytecode" "1.11.6" "@webassemblyjs/wasm-gen" "1.11.6" -"@webassemblyjs/ieee754@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz#963929e9bbd05709e7e12243a099180812992614" - integrity sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ== - dependencies: - "@xtuc/ieee754" "^1.2.0" - "@webassemblyjs/ieee754@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" @@ -4480,13 +4535,6 @@ dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.1.tgz#ce814b45574e93d76bae1fb2644ab9cdd9527aa5" - integrity sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw== - dependencies: - "@xtuc/long" "4.2.2" - "@webassemblyjs/leb128@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" @@ -4494,30 +4542,11 @@ dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.1.tgz#d1f8b764369e7c6e6bae350e854dec9a59f0a3ff" - integrity sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ== - "@webassemblyjs/utf8@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== -"@webassemblyjs/wasm-edit@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz#ad206ebf4bf95a058ce9880a8c092c5dec8193d6" - integrity sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/helper-wasm-section" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-opt" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - "@webassemblyjs/wast-printer" "1.11.1" - "@webassemblyjs/wasm-edit@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz#c72fa8220524c9b416249f3d94c2958dfe70ceab" @@ -4532,17 +4561,6 @@ "@webassemblyjs/wasm-parser" "1.11.6" "@webassemblyjs/wast-printer" "1.11.6" -"@webassemblyjs/wasm-gen@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz#86c5ea304849759b7d88c47a32f4f039ae3c8f76" - integrity sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" - "@webassemblyjs/wasm-gen@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz#fb5283e0e8b4551cc4e9c3c0d7184a65faf7c268" @@ -4554,16 +4572,6 @@ "@webassemblyjs/leb128" "1.11.6" "@webassemblyjs/utf8" "1.11.6" -"@webassemblyjs/wasm-opt@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz#657b4c2202f4cf3b345f8a4c6461c8c2418985f2" - integrity sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - "@webassemblyjs/wasm-opt@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz#d9a22d651248422ca498b09aa3232a81041487c2" @@ -4574,18 +4582,6 @@ "@webassemblyjs/wasm-gen" "1.11.6" "@webassemblyjs/wasm-parser" "1.11.6" -"@webassemblyjs/wasm-parser@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz#86ca734534f417e9bd3c67c7a1c75d8be41fb199" - integrity sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" - "@webassemblyjs/wasm-parser@1.11.6", "@webassemblyjs/wasm-parser@^1.11.5": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz#bb85378c527df824004812bbdb784eea539174a1" @@ -4598,14 +4594,6 @@ "@webassemblyjs/leb128" "1.11.6" "@webassemblyjs/utf8" "1.11.6" -"@webassemblyjs/wast-printer@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz#d0c73beda8eec5426f10ae8ef55cee5e7084c2f0" - integrity sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@xtuc/long" "4.2.2" - "@webassemblyjs/wast-printer@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz#a7bf8dd7e362aeb1668ff43f35cb849f188eff20" @@ -4723,6 +4711,11 @@ acorn-import-assertions@^1.7.6: resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== +acorn-import-assertions@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" + integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA== + acorn-jsx@^5.3.1: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -4743,7 +4736,7 @@ acorn@^7.1.1, acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.0.4, acorn@^8.2.4, acorn@^8.4.1, acorn@^8.5.0: +acorn@^8.0.4, acorn@^8.2.4, acorn@^8.5.0: version "8.5.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.5.0.tgz#4512ccb99b3698c752591e9bb4472e38ad43cee2" integrity sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q== @@ -4765,6 +4758,13 @@ agent-base@6, agent-base@^6.0.2: dependencies: debug "4" +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.0.tgz#536802b76bc0b34aa50195eb2442276d613e3434" + integrity sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg== + dependencies: + debug "^4.3.4" + agentkeepalive@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.2.1.tgz#a7975cbb9f83b367f06c90cc51ff28fe7d499717" @@ -4831,11 +4831,6 @@ ajv@^8.9.0: require-from-string "^2.0.2" uri-js "^4.2.2" -alphanum-sort@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3" - integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM= - ansi-align@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" @@ -4963,11 +4958,6 @@ archiver@^5.0.0: tar-stream "^2.2.0" zip-stream "^4.1.0" -archy@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" - integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA= - are-we-there-yet@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz#679df222b278c64f2cdba1175cdc00b0d96164bd" @@ -5099,6 +5089,13 @@ ast-types@0.14.2, ast-types@^0.14.1: dependencies: tslib "^2.0.1" +ast-types@^0.13.4: + version "0.13.4" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782" + integrity sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w== + dependencies: + tslib "^2.0.1" + astral-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" @@ -5153,6 +5150,11 @@ axios@^1.0.0: form-data "^4.0.0" proxy-from-env "^1.1.0" +b4a@^1.6.4: + version "1.6.4" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.4.tgz#ef1c1422cae5ce6535ec191baeed7567443f36c9" + integrity sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw== + babel-core@^7.0.0-bridge.0: version "7.0.0-bridge.0" resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece" @@ -5278,6 +5280,11 @@ base@^0.11.1: mixin-deep "^1.2.0" pascalcase "^0.1.1" +basic-ftp@^5.0.2: + version "5.0.4" + resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.4.tgz#28aeab7bfbbde5f5d0159cd8bb3b8e633bbb091d" + integrity sha512-8PzkB0arJFV4jJWSGOYR+OEic6aeKMu/osRhBULN6RY0ykby6LKhbmuQ5ublvaas5BOwboah5D87nrHyuh8PPA== + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -5459,7 +5466,7 @@ browser-stdout@1.3.1: resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== -browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.0, browserslist@^4.16.6, browserslist@^4.16.7: +browserslist@^4.14.5, browserslist@^4.16.6, browserslist@^4.16.7: version "4.17.4" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.17.4.tgz#72e2508af2a403aec0a49847ef31bd823c57ead4" integrity sha512-Zg7RpbZpIJRW3am9Lyckue7PLytvVxxhJj1CaJVlCWENsGEAOlnlt8X0ZxGRPp7Bt9o8tIRM5SEXy4BCPMJjLQ== @@ -5775,17 +5782,7 @@ camelcase@^6.2.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== -caniuse-api@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" - integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== - dependencies: - browserslist "^4.0.0" - caniuse-lite "^1.0.0" - lodash.memoize "^4.1.2" - lodash.uniq "^4.5.0" - -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001265: +caniuse-lite@^1.0.30001265: version "1.0.30001431" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001431.tgz" integrity sha512-zBUoFU0ZcxpvSt9IU66dXVT/3ctO1cy4y9cscs1szkPlcWb6pasYM144GqrUygUbT+k7cmUCW61cvskjcv0enQ== @@ -5994,6 +5991,14 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== +chromium-bidi@0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-0.5.1.tgz#390c1af350c4887824a33d82190de1cc5c5680fc" + integrity sha512-dcCqOgq9fHKExc2R4JZs/oKbOghWpUNFAJODS8WKRtLhp3avtIH5UDCBrutdqZdh3pARogH8y1ObXm87emwb3g== + dependencies: + mitt "3.0.1" + urlpattern-polyfill "9.0.0" + ci-info@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" @@ -6247,11 +6252,6 @@ color-support@^1.1.3: resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== -colord@^2.0.1, colord@^2.6: - version "2.9.1" - resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.1.tgz#c961ea0efeb57c9f0f4834458f26cb9cc4a3f90e" - integrity sha512-4LBMSt09vR0uLnPVkOUBnmxgoaeN4ewRbx801wY/bXcltXfpR/G46OdWn96XpYmCWuYvO46aBZP4NgX8HpNAcw== - colorette@^1.2.2: version "1.4.0" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" @@ -6287,6 +6287,11 @@ commander@^10.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== +commander@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906" + integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== + commander@^2.20.0, commander@^2.8.1: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" @@ -6297,7 +6302,7 @@ commander@^6.1.0, commander@^6.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -commander@^7.1.0, commander@^7.2.0: +commander@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== @@ -6668,6 +6673,13 @@ cross-fetch@3.1.5: dependencies: node-fetch "2.6.7" +cross-fetch@4.0.0, cross-fetch@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" + integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== + dependencies: + node-fetch "^2.6.12" + cross-fetch@^3.1.4: version "3.1.4" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" @@ -6722,45 +6734,6 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== -css-color-names@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-1.0.1.tgz#6ff7ee81a823ad46e020fa2fd6ab40a887e2ba67" - integrity sha512-/loXYOch1qU1biStIFsHH8SxTmOseh1IJqFvy8IujXOm1h+QjUdDhkzOrR5HG8K8mlxREj0yfi8ewCHx0eMxzA== - -css-declaration-sorter@^6.0.3: - version "6.1.1" - resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.1.1.tgz#77b32b644ba374bc562c0fc6f4fdaba4dfb0b749" - integrity sha512-BZ1aOuif2Sb7tQYY1GeCjG7F++8ggnwUkH5Ictw0mrdpqpEd+zWmcPdstnH2TItlb74FqR0DrVEieon221T/1Q== - dependencies: - timsort "^0.3.0" - -css-loader@^6.4.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.4.0.tgz#01c57ea776024e18ca193428dcad3ff6b42a0130" - integrity sha512-Dlt6qfsxI/w1vU0r8qDd4BtMPxWqJeY5qQU7SmmZfvbpe6Xl18McO4GhyaMLns24Y2VNPiZwJPQ8JSbg4qvQLw== - dependencies: - icss-utils "^5.1.0" - postcss "^8.2.15" - postcss-modules-extract-imports "^3.0.0" - postcss-modules-local-by-default "^4.0.0" - postcss-modules-scope "^3.0.0" - postcss-modules-values "^4.0.0" - postcss-value-parser "^4.1.0" - semver "^7.3.5" - -css-minimizer-webpack-plugin@^3.0.2: - version "3.1.1" - resolved "https://registry.yarnpkg.com/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.1.1.tgz#27bafa3b75054713565b2266c64b0228acd18634" - integrity sha512-KlB8l5uoNcf9F7i5kXnkxoqJGd2BXH4f0+Lj2vSWSmuvMLYO1kNsJ1KHSzeDW8e45/whgSOPcKVT/3JopkT8dg== - dependencies: - cssnano "^5.0.6" - jest-worker "^27.0.2" - p-limit "^3.0.2" - postcss "^8.3.5" - schema-utils "^3.1.0" - serialize-javascript "^6.0.0" - source-map "^0.6.1" - css-select@^4.1.3: version "4.1.3" resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.3.tgz#a70440f70317f2669118ad74ff105e65849c7067" @@ -6777,14 +6750,6 @@ css-shorthand-properties@^1.1.1: resolved "https://registry.yarnpkg.com/css-shorthand-properties/-/css-shorthand-properties-1.1.1.tgz#1c808e63553c283f289f2dd56fcee8f3337bd935" integrity sha512-Md+Juc7M3uOdbAFwOYlTrccIZ7oCFuzrhKYQjdeUEW/sE1hv17Jp/Bws+ReOPpGVBTYCBoYo+G17V5Qo8QQ75A== -css-tree@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" - integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== - dependencies: - mdn-data "2.0.14" - source-map "^0.6.1" - css-value@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/css-value/-/css-value-0.0.1.tgz#5efd6c2eea5ea1fd6b6ac57ec0427b18452424ea" @@ -6805,63 +6770,6 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== -cssnano-preset-default@^5.1.4: - version "5.1.4" - resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.1.4.tgz#359943bf00c5c8e05489f12dd25f3006f2c1cbd2" - integrity sha512-sPpQNDQBI3R/QsYxQvfB4mXeEcWuw0wGtKtmS5eg8wudyStYMgKOQT39G07EbW1LB56AOYrinRS9f0ig4Y3MhQ== - dependencies: - css-declaration-sorter "^6.0.3" - cssnano-utils "^2.0.1" - postcss-calc "^8.0.0" - postcss-colormin "^5.2.0" - postcss-convert-values "^5.0.1" - postcss-discard-comments "^5.0.1" - postcss-discard-duplicates "^5.0.1" - postcss-discard-empty "^5.0.1" - postcss-discard-overridden "^5.0.1" - postcss-merge-longhand "^5.0.2" - postcss-merge-rules "^5.0.2" - postcss-minify-font-values "^5.0.1" - postcss-minify-gradients "^5.0.2" - postcss-minify-params "^5.0.1" - postcss-minify-selectors "^5.1.0" - postcss-normalize-charset "^5.0.1" - postcss-normalize-display-values "^5.0.1" - postcss-normalize-positions "^5.0.1" - postcss-normalize-repeat-style "^5.0.1" - postcss-normalize-string "^5.0.1" - postcss-normalize-timing-functions "^5.0.1" - postcss-normalize-unicode "^5.0.1" - postcss-normalize-url "^5.0.2" - postcss-normalize-whitespace "^5.0.1" - postcss-ordered-values "^5.0.2" - postcss-reduce-initial "^5.0.1" - postcss-reduce-transforms "^5.0.1" - postcss-svgo "^5.0.2" - postcss-unique-selectors "^5.0.1" - -cssnano-utils@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-2.0.1.tgz#8660aa2b37ed869d2e2f22918196a9a8b6498ce2" - integrity sha512-i8vLRZTnEH9ubIyfdZCAdIdgnHAUeQeByEeQ2I7oTilvP9oHO6RScpeq3GsFUVqeB8uZgOQ9pw8utofNn32hhQ== - -cssnano@^5.0.6: - version "5.0.8" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.0.8.tgz#39ad166256980fcc64faa08c9bb18bb5789ecfa9" - integrity sha512-Lda7geZU0Yu+RZi2SGpjYuQz4HI4/1Y+BhdD0jL7NXAQ5larCzVn+PUGuZbDMYz904AXXCOgO5L1teSvgu7aFg== - dependencies: - cssnano-preset-default "^5.1.4" - is-resolvable "^1.1.0" - lilconfig "^2.0.3" - yaml "^1.10.2" - -csso@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" - integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== - dependencies: - css-tree "^1.1.2" - cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -6902,6 +6810,11 @@ dargs@^7.0.0: resolved "https://registry.yarnpkg.com/dargs/-/dargs-7.0.0.tgz#04015c41de0bcb69ec84050f3d9be0caf8d6d5cc" integrity sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg== +data-uri-to-buffer@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.1.tgz#540bd4c8753a25ee129035aebdedf63b078703c7" + integrity sha512-MZd3VlchQkp8rdend6vrx7MmVDJzSNTBvghvKjirLkD+WTChA3KUf0jkE68Q4UyctNqI11zZO9/x2Yx+ub5Cvg== + data-urls@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" @@ -7184,6 +7097,15 @@ define-property@^2.0.2: is-descriptor "^1.0.2" isobject "^3.0.1" +degenerator@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-5.0.1.tgz#9403bf297c6dad9a1ece409b37db27954f91f2f5" + integrity sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ== + dependencies: + ast-types "^0.13.4" + escodegen "^2.1.0" + esprima "^4.0.1" + del@^6.0.0: version "6.1.1" resolved "https://registry.yarnpkg.com/del/-/del-6.1.1.tgz#3b70314f1ec0aa325c6b14eb36b95786671edb7a" @@ -7253,6 +7175,11 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== +devtools-protocol@0.0.1203626: + version "0.0.1203626" + resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1203626.tgz#4366a4c81a7e0d4fd6924e9182c67f1e5941e820" + integrity sha512-nEzHZteIUZfGCZtTiS1fRpC8UZmsfD1SiyPvaUNvS13dvKf666OAm8YTi0+Ca3n1nLEyu49Cy4+dPWpaHFJk9g== + devtools-protocol@0.0.981744: version "0.0.981744" resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf" @@ -7609,7 +7536,7 @@ engine.io@~6.2.1: engine.io-parser "~5.0.3" ws "~8.2.3" -enhanced-resolve@^5.0.0, enhanced-resolve@^5.8.3: +enhanced-resolve@^5.0.0: version "5.8.3" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz#6d552d465cce0423f5b3d718511ea53826a7b2f0" integrity sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA== @@ -7625,6 +7552,14 @@ enhanced-resolve@^5.13.0: graceful-fs "^4.2.4" tapable "^2.2.0" +enhanced-resolve@^5.15.0: + version "5.15.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz#1af946c7d93603eb88e9896cee4904dc012e9c35" + integrity sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + enquirer@^2.3.5, enquirer@^2.3.6, enquirer@~2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" @@ -7684,11 +7619,6 @@ es-get-iterator@^1.1.2: isarray "^2.0.5" stop-iteration-iterator "^1.0.0" -es-module-lexer@^0.9.0: - version "0.9.3" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" - integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== - es-module-lexer@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.2.1.tgz#ba303831f63e6a394983fde2f97ad77b22324527" @@ -7740,6 +7670,35 @@ es6-weak-map@^2.0.3: es6-iterator "^2.0.3" es6-symbol "^3.1.1" +esbuild@^0.19.11: + version "0.19.12" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" + integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.19.12" + "@esbuild/android-arm" "0.19.12" + "@esbuild/android-arm64" "0.19.12" + "@esbuild/android-x64" "0.19.12" + "@esbuild/darwin-arm64" "0.19.12" + "@esbuild/darwin-x64" "0.19.12" + "@esbuild/freebsd-arm64" "0.19.12" + "@esbuild/freebsd-x64" "0.19.12" + "@esbuild/linux-arm" "0.19.12" + "@esbuild/linux-arm64" "0.19.12" + "@esbuild/linux-ia32" "0.19.12" + "@esbuild/linux-loong64" "0.19.12" + "@esbuild/linux-mips64el" "0.19.12" + "@esbuild/linux-ppc64" "0.19.12" + "@esbuild/linux-riscv64" "0.19.12" + "@esbuild/linux-s390x" "0.19.12" + "@esbuild/linux-x64" "0.19.12" + "@esbuild/netbsd-x64" "0.19.12" + "@esbuild/openbsd-x64" "0.19.12" + "@esbuild/sunos-x64" "0.19.12" + "@esbuild/win32-arm64" "0.19.12" + "@esbuild/win32-ia32" "0.19.12" + "@esbuild/win32-x64" "0.19.12" + escalade@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" @@ -7787,6 +7746,17 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" +escodegen@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" + integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionalDependencies: + source-map "~0.6.1" + eslint-config-prettier@^6.15.0: version "6.15.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz#7f93f6cb7d45a92f1537a70ecc06366e1ac6fed9" @@ -7919,6 +7889,17 @@ esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" +estimo@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/estimo/-/estimo-3.0.1.tgz#b0d80ebeab940d0e69f634af3c3e91d9607fb049" + integrity sha512-xk0Gln+Ie+rfF3EDfa07wcq1n8u3tT6Hbt9UVAYBb3CMvYVfeljqlX9eJBSklbMhgV2BV3Hpcd22Q4T+jiC0fw== + dependencies: + "@sitespeed.io/tracium" "^0.3.3" + commander "^11.1.0" + find-chrome-bin "2.0.1" + nanoid "5.0.4" + puppeteer-core "21.6.0" + estraverse@^4.1.1: version "4.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" @@ -8284,6 +8265,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-fifo@^1.1.0, fast-fifo@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@3.2.7, fast-glob@^3.0.3, fast-glob@^3.1.1: version "3.2.7" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1" @@ -8306,6 +8292,17 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" +fast-glob@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" + integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== + dependencies: + "@nodelib/fs.stat" "^2.0.2" + "@nodelib/fs.walk" "^1.2.3" + glob-parent "^5.1.2" + merge2 "^1.3.0" + micromatch "^4.0.4" + fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" @@ -8483,6 +8480,13 @@ find-cache-dir@^2.0.0: make-dir "^2.0.0" pkg-dir "^3.0.0" +find-chrome-bin@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/find-chrome-bin/-/find-chrome-bin-2.0.1.tgz#e91a5496b7118bb9e3b4c306b25bda9616b572cb" + integrity sha512-aDwC2y0dLxt0GFmQ+q8bqBCZ10VW9zYT/lNV806tRDqDAh5XpkTWulB96RKDHDuKu36m/dEvhmhD5IU237oOTg== + dependencies: + "@puppeteer/browsers" "^1.8.0" + find-up@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -8864,6 +8868,16 @@ get-stream@^6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +get-uri@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-6.0.2.tgz#e019521646f4a8ff6d291fbaea2c46da204bb75b" + integrity sha512-5KLucCJobh8vBY1K07EFV4+cPZH3mrV9YeAruUseCQKHB58SGjjT2l9/eA9LD082IiuMjSlFJEcdJ27TXvbZNw== + dependencies: + basic-ftp "^5.0.2" + data-uri-to-buffer "^6.0.0" + debug "^4.3.4" + fs-extra "^8.1.0" + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -9092,6 +9106,18 @@ globby@^11.0.1, globby@^11.0.2, globby@^11.0.3, globby@^11.0.4: merge2 "^1.3.0" slash "^3.0.0" +globby@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globby/-/globby-14.0.0.tgz#ea9c062a3614e33f516804e778590fcf055256b9" + integrity sha512-/1WM/LNHRAOH9lZta77uGbq0dAEQM+XjNesWwhlERDVenqothRbnzTrL3/LrIoEPPjeUHC3vrS6TwoyxeHs7MQ== + dependencies: + "@sindresorhus/merge-streams" "^1.0.0" + fast-glob "^3.3.2" + ignore "^5.2.4" + path-type "^5.0.0" + slash "^5.1.0" + unicorn-magic "^0.1.0" + globule@^1.0.0: version "1.3.4" resolved "https://registry.yarnpkg.com/globule/-/globule-1.3.4.tgz#7c11c43056055a75a6e68294453c17f2796170fb" @@ -9341,11 +9367,6 @@ header-case@^2.0.4: resolved "https://registry.yarnpkg.com/heap/-/heap-0.2.6.tgz#087e1f10b046932fc8594dd9e6d378afc9d1e5ac" integrity sha1-CH4fELBGky/IWU3Z5tN4r8nR5aw= -highcharts@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/highcharts/-/highcharts-9.2.2.tgz#4ace4aa7c4d2b4051115d9be70bfd2038559d656" - integrity sha512-OMEdFCaG626ES1JEcKAvJTpxAOMuchy0XuAplmnOs0Yu7NMd2RMfTLFQ2fCJOxo3ubSdm/RVQwKAWC+5HYThnw== - hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -9521,6 +9542,14 @@ http-proxy-agent@^5.0.0: agent-base "6" debug "4" +http-proxy-agent@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.0.tgz#e9096c5afd071a3fce56e6252bb321583c124673" + integrity sha512-+ZT+iBxVUQ1asugqnD6oWoRiS25AkjNfG085dKJGtGxkdwLQrMKU5wJr2bOOFAXzKcTuqq+7fZlTMgG3SRfIYQ== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + http-proxy-middleware@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" @@ -9565,6 +9594,14 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" +https-proxy-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz#e2645b846b90e96c6e6f347fb5b2e41f1590b09b" + integrity sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA== + dependencies: + agent-base "^7.0.2" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -9606,11 +9643,6 @@ iconv-lite@0.6.3, iconv-lite@^0.6.2: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -icss-utils@^5.0.0, icss-utils@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" - integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== - ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -9650,6 +9682,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== +ignore@^5.2.4: + version "5.3.0" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" + integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== + import-fresh@^3.0.0, import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -9786,6 +9823,11 @@ into-stream@^3.1.0: from2 "^2.1.1" p-is-promise "^1.1.0" +ip@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" + integrity sha512-PuExPYUiu6qMBQb4l06ecm6T6ujzhmh+MeJcW9wa89PoAz5pvd4zPgN5WJV104mb6S2T1AwNIAaB70JNrLQWhg== + ip@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da" @@ -9801,11 +9843,6 @@ ipaddr.js@^2.0.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== -is-absolute-url@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" - integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== - is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -9869,6 +9906,11 @@ is-buffer@^1.1.5, is-buffer@~1.1.6: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== +is-bundling-for-browser-or-node@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-bundling-for-browser-or-node/-/is-bundling-for-browser-or-node-1.1.1.tgz#dbbff6fc4ca4d0e8fbae26a404e135c80462fe7e" + integrity sha512-QjaU/+InR3DN5qVlaWgRJEuvz6CdP5jtGp37dxuvlY693AEuNNgmPGfDXXzkdRmJD+GqWSAhIQW1GTQXN60LPA== + is-callable@^1.1.3: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" @@ -10106,11 +10148,6 @@ is-regexp@^1.0.0: resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= -is-resolvable@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" - integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== - is-retry-allowed@^1.1.0, is-retry-allowed@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" @@ -10849,7 +10886,7 @@ jest-watcher@^27.3.1: jest-util "^27.3.1" string-length "^4.0.1" -jest-worker@^27.0.2, jest-worker@^27.0.6, jest-worker@^27.3.1: +jest-worker@^27.0.6, jest-worker@^27.3.1: version "27.3.1" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.3.1.tgz#0def7feae5b8042be38479799aeb7b5facac24b2" integrity sha512-ks3WCzsiZaOPJl/oMsDjaf0TRiSv7ctNgs0FqRr2nARsovz6AWWy4oLElwcquGSz692DzgZQrCLScPNs5YlC4g== @@ -10887,16 +10924,16 @@ joi@^17.4.0: "@sideway/formula" "^3.0.0" "@sideway/pinpoint" "^2.0.0" -jora@^1.0.0-beta.5: - version "1.0.0-beta.5" - resolved "https://registry.yarnpkg.com/jora/-/jora-1.0.0-beta.5.tgz#55b2c4d86078af1bc74da401e88b67be42b0bddd" - integrity sha512-hPJKQyF0eiCqQOwfgIuQa+8wIn+WcEcjjyeOchuiXEUnt6zbV0tHKsUqRRwJY47ZtBiGcJQNr/BGuYW1Sfwbvg== - js-cookie@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.1.tgz#9e39b4c6c2f56563708d7d31f6f5f21873a92414" integrity sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw== +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -11050,7 +11087,7 @@ json-diff@^0.5.4: difflib "~0.2.1" dreamopt "~0.6.0" -json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: +json-parse-better-errors@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== @@ -11182,6 +11219,11 @@ just-diff@^6.0.0: resolved "https://registry.yarnpkg.com/just-diff/-/just-diff-6.0.2.tgz#03b65908543ac0521caf6d8eb85035f7d27ea285" integrity sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA== +kafkajs@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/kafkajs/-/kafkajs-2.2.4.tgz#59e6e16459d87fdf8b64be73970ed5aa42370a5b" + integrity sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA== + karma-chrome-launcher@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.1.1.tgz#baca9cc071b1562a1db241827257bfe5cab597ea" @@ -11458,6 +11500,11 @@ lilconfig@^2.0.3: resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd" integrity sha512-EHKqr/+ZvdKCifpNrJCKxBTgk5XupZA3y/aCPY9mxfgBzmgh93Mt/WqjjQ38oMxXuvDokaKiM3lAgvSH2sjtHg== +lilconfig@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.0.0.tgz#f8067feb033b5b74dab4602a5f5029420be749bc" + integrity sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g== + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -11628,7 +11675,7 @@ lodash.isplainobject@^4.0.6: resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== -lodash.memoize@4.x, lodash.memoize@^4.1.2: +lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= @@ -11673,11 +11720,6 @@ lodash.union@^4.6.0: resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88" integrity sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw== -lodash.uniq@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= - lodash.zip@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.zip/-/lodash.zip-4.2.0.tgz#ec6662e4896408ed4ab6c542a3990b72cc080020" @@ -11769,6 +11811,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.14.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1: version "7.14.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea" @@ -11890,7 +11937,7 @@ marky@^1.2.2: resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0" integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q== -md5@^2.2.1, md5@^2.3.0: +md5@^2.2.1: version "2.3.0" resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f" integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g== @@ -11899,11 +11946,6 @@ md5@^2.2.1, md5@^2.3.0: crypt "0.0.2" is-buffer "~1.1.6" -mdn-data@2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" - integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== - media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -12266,6 +12308,11 @@ minizlib@^2.1.1, minizlib@^2.1.2: minipass "^3.0.0" yallist "^4.0.0" +mitt@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -12405,16 +12452,16 @@ nanoid@3.3.3: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== +nanoid@5.0.4, nanoid@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.4.tgz#d2b608d8169d7da669279127615535705aa52edf" + integrity sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig== + nanoid@^2.1.7: version "2.1.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== -nanoid@^3.1.28: - version "3.1.30" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.30.tgz#63f93cc548d2a113dc5dfbc63bfa09e2b9b64362" - integrity sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ== - nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -12439,6 +12486,13 @@ nanospinner@^0.3.0: dependencies: picocolors "^1.0.0" +nanospinner@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/nanospinner/-/nanospinner-1.1.0.tgz#d17ff621cb1784b0a206b400da88a0ef6db39b97" + integrity sha512-yFvNYMig4AthKYfHFl1sLj7B2nkHL4lzdig4osvl9/LdGbXwrdFRoqBS98gsEsOakr0yH+r5NZ/1Y9gdVB8trA== + dependencies: + picocolors "^1.0.0" + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -12464,6 +12518,11 @@ neo-async@^2.5.0, neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +netmask@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" + integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== + new-date@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/new-date/-/new-date-1.0.3.tgz#a5956086d3f5ed43d0b210d87a10219ccb7a2326" @@ -12528,6 +12587,13 @@ node-fetch@2.6.7, node-fetch@^2.6.7: dependencies: whatwg-url "^5.0.0" +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-forge@^1: version "1.3.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" @@ -12999,7 +13065,7 @@ onetime@^5.1.0, onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" -open@^8.0.9, open@^8.2.1: +open@^8.0.9: version "8.3.0" resolved "https://registry.yarnpkg.com/open/-/open-8.3.0.tgz#fdef1cdfe405e60dec8ebd18889e7e812f39c59f" integrity sha512-7INcPWb1UcOwSQxAXTnBJ+FxVV4MPs/X++FWWBtgY69/J5lc+tCteMt/oFK1MnkyHC4VILLa9ntmwKTwDR4Q9w== @@ -13226,6 +13292,29 @@ p-waterfall@2.1.1: dependencies: p-reduce "^2.0.0" +pac-proxy-agent@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.0.1.tgz#6b9ddc002ec3ff0ba5fdf4a8a21d363bcc612d75" + integrity sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A== + dependencies: + "@tootallnate/quickjs-emscripten" "^0.23.0" + agent-base "^7.0.2" + debug "^4.3.4" + get-uri "^6.0.1" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.2" + pac-resolver "^7.0.0" + socks-proxy-agent "^8.0.2" + +pac-resolver@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-7.0.0.tgz#79376f1ca26baf245b96b34c339d79bff25e900c" + integrity sha512-Fd9lT9vJbHYRACT8OhCbZBbxr6KRSawSovFpy8nDGshaK99S/EBhVIHp9+crhxrsZOuvLpgL1n23iyPg6Rl2hg== + dependencies: + degenerator "^5.0.0" + ip "^1.1.8" + netmask "^2.0.2" + pacote@15.1.1: version "15.1.1" resolved "https://registry.yarnpkg.com/pacote/-/pacote-15.1.1.tgz#94d8c6e0605e04d427610b3aacb0357073978348" @@ -13479,6 +13568,11 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +path-type@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-5.0.0.tgz#14b01ed7aea7ddf9c7c3f46181d4d04f9c785bb8" + integrity sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg== + pathval@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" @@ -13603,225 +13697,6 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= -postcss-calc@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.0.0.tgz#a05b87aacd132740a5db09462a3612453e5df90a" - integrity sha512-5NglwDrcbiy8XXfPM11F3HeC6hoT9W7GUH/Zi5U/p7u3Irv4rHhdDcIZwG0llHXV4ftsBjpfWMXAnXNl4lnt8g== - dependencies: - postcss-selector-parser "^6.0.2" - postcss-value-parser "^4.0.2" - -postcss-colormin@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-5.2.0.tgz#2b620b88c0ff19683f3349f4cf9e24ebdafb2c88" - integrity sha512-+HC6GfWU3upe5/mqmxuqYZ9B2Wl4lcoUUNkoaX59nEWV4EtADCMiBqui111Bu8R8IvaZTmqmxrqOAqjbHIwXPw== - dependencies: - browserslist "^4.16.6" - caniuse-api "^3.0.0" - colord "^2.0.1" - postcss-value-parser "^4.1.0" - -postcss-convert-values@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.0.1.tgz#4ec19d6016534e30e3102fdf414e753398645232" - integrity sha512-C3zR1Do2BkKkCgC0g3sF8TS0koF2G+mN8xxayZx3f10cIRmTaAnpgpRQZjNekTZxM2ciSPoh2IWJm0VZx8NoQg== - dependencies: - postcss-value-parser "^4.1.0" - -postcss-discard-comments@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.0.1.tgz#9eae4b747cf760d31f2447c27f0619d5718901fe" - integrity sha512-lgZBPTDvWrbAYY1v5GYEv8fEO/WhKOu/hmZqmCYfrpD6eyDWWzAOsl2rF29lpvziKO02Gc5GJQtlpkTmakwOWg== - -postcss-discard-duplicates@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-5.0.1.tgz#68f7cc6458fe6bab2e46c9f55ae52869f680e66d" - integrity sha512-svx747PWHKOGpAXXQkCc4k/DsWo+6bc5LsVrAsw+OU+Ibi7klFZCyX54gjYzX4TH+f2uzXjRviLARxkMurA2bA== - -postcss-discard-empty@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.0.1.tgz#ee136c39e27d5d2ed4da0ee5ed02bc8a9f8bf6d8" - integrity sha512-vfU8CxAQ6YpMxV2SvMcMIyF2LX1ZzWpy0lqHDsOdaKKLQVQGVP1pzhrI9JlsO65s66uQTfkQBKBD/A5gp9STFw== - -postcss-discard-overridden@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-5.0.1.tgz#454b41f707300b98109a75005ca4ab0ff2743ac6" - integrity sha512-Y28H7y93L2BpJhrdUR2SR2fnSsT+3TVx1NmVQLbcnZWwIUpJ7mfcTC6Za9M2PG6w8j7UQRfzxqn8jU2VwFxo3Q== - -postcss-merge-longhand@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.0.2.tgz#277ada51d9a7958e8ef8cf263103c9384b322a41" - integrity sha512-BMlg9AXSI5G9TBT0Lo/H3PfUy63P84rVz3BjCFE9e9Y9RXQZD3+h3YO1kgTNsNJy7bBc1YQp8DmSnwLIW5VPcw== - dependencies: - css-color-names "^1.0.1" - postcss-value-parser "^4.1.0" - stylehacks "^5.0.1" - -postcss-merge-rules@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.0.2.tgz#d6e4d65018badbdb7dcc789c4f39b941305d410a" - integrity sha512-5K+Md7S3GwBewfB4rjDeol6V/RZ8S+v4B66Zk2gChRqLTCC8yjnHQ601omj9TKftS19OPGqZ/XzoqpzNQQLwbg== - dependencies: - browserslist "^4.16.6" - caniuse-api "^3.0.0" - cssnano-utils "^2.0.1" - postcss-selector-parser "^6.0.5" - vendors "^1.0.3" - -postcss-minify-font-values@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-5.0.1.tgz#a90cefbfdaa075bd3dbaa1b33588bb4dc268addf" - integrity sha512-7JS4qIsnqaxk+FXY1E8dHBDmraYFWmuL6cgt0T1SWGRO5bzJf8sUoelwa4P88LEWJZweHevAiDKxHlofuvtIoA== - dependencies: - postcss-value-parser "^4.1.0" - -postcss-minify-gradients@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.0.2.tgz#7c175c108f06a5629925d698b3c4cf7bd3864ee5" - integrity sha512-7Do9JP+wqSD6Prittitt2zDLrfzP9pqKs2EcLX7HJYxsxCOwrrcLt4x/ctQTsiOw+/8HYotAoqNkrzItL19SdQ== - dependencies: - colord "^2.6" - cssnano-utils "^2.0.1" - postcss-value-parser "^4.1.0" - -postcss-minify-params@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.0.1.tgz#371153ba164b9d8562842fdcd929c98abd9e5b6c" - integrity sha512-4RUC4k2A/Q9mGco1Z8ODc7h+A0z7L7X2ypO1B6V8057eVK6mZ6xwz6QN64nHuHLbqbclkX1wyzRnIrdZehTEHw== - dependencies: - alphanum-sort "^1.0.2" - browserslist "^4.16.0" - cssnano-utils "^2.0.1" - postcss-value-parser "^4.1.0" - uniqs "^2.0.0" - -postcss-minify-selectors@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.1.0.tgz#4385c845d3979ff160291774523ffa54eafd5a54" - integrity sha512-NzGBXDa7aPsAcijXZeagnJBKBPMYLaJJzB8CQh6ncvyl2sIndLVWfbcDi0SBjRWk5VqEjXvf8tYwzoKf4Z07og== - dependencies: - alphanum-sort "^1.0.2" - postcss-selector-parser "^6.0.5" - -postcss-modules-extract-imports@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" - integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== - -postcss-modules-local-by-default@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" - integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== - dependencies: - icss-utils "^5.0.0" - postcss-selector-parser "^6.0.2" - postcss-value-parser "^4.1.0" - -postcss-modules-scope@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" - integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== - dependencies: - postcss-selector-parser "^6.0.4" - -postcss-modules-values@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" - integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== - dependencies: - icss-utils "^5.0.0" - -postcss-normalize-charset@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-5.0.1.tgz#121559d1bebc55ac8d24af37f67bd4da9efd91d0" - integrity sha512-6J40l6LNYnBdPSk+BHZ8SF+HAkS4q2twe5jnocgd+xWpz/mx/5Sa32m3W1AA8uE8XaXN+eg8trIlfu8V9x61eg== - -postcss-normalize-display-values@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-5.0.1.tgz#62650b965981a955dffee83363453db82f6ad1fd" - integrity sha512-uupdvWk88kLDXi5HEyI9IaAJTE3/Djbcrqq8YgjvAVuzgVuqIk3SuJWUisT2gaJbZm1H9g5k2w1xXilM3x8DjQ== - dependencies: - cssnano-utils "^2.0.1" - postcss-value-parser "^4.1.0" - -postcss-normalize-positions@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.0.1.tgz#868f6af1795fdfa86fbbe960dceb47e5f9492fe5" - integrity sha512-rvzWAJai5xej9yWqlCb1OWLd9JjW2Ex2BCPzUJrbaXmtKtgfL8dBMOOMTX6TnvQMtjk3ei1Lswcs78qKO1Skrg== - dependencies: - postcss-value-parser "^4.1.0" - -postcss-normalize-repeat-style@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.0.1.tgz#cbc0de1383b57f5bb61ddd6a84653b5e8665b2b5" - integrity sha512-syZ2itq0HTQjj4QtXZOeefomckiV5TaUO6ReIEabCh3wgDs4Mr01pkif0MeVwKyU/LHEkPJnpwFKRxqWA/7O3w== - dependencies: - cssnano-utils "^2.0.1" - postcss-value-parser "^4.1.0" - -postcss-normalize-string@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-5.0.1.tgz#d9eafaa4df78c7a3b973ae346ef0e47c554985b0" - integrity sha512-Ic8GaQ3jPMVl1OEn2U//2pm93AXUcF3wz+OriskdZ1AOuYV25OdgS7w9Xu2LO5cGyhHCgn8dMXh9bO7vi3i9pA== - dependencies: - postcss-value-parser "^4.1.0" - -postcss-normalize-timing-functions@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.0.1.tgz#8ee41103b9130429c6cbba736932b75c5e2cb08c" - integrity sha512-cPcBdVN5OsWCNEo5hiXfLUnXfTGtSFiBU9SK8k7ii8UD7OLuznzgNRYkLZow11BkQiiqMcgPyh4ZqXEEUrtQ1Q== - dependencies: - cssnano-utils "^2.0.1" - postcss-value-parser "^4.1.0" - -postcss-normalize-unicode@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-5.0.1.tgz#82d672d648a411814aa5bf3ae565379ccd9f5e37" - integrity sha512-kAtYD6V3pK0beqrU90gpCQB7g6AOfP/2KIPCVBKJM2EheVsBQmx/Iof+9zR9NFKLAx4Pr9mDhogB27pmn354nA== - dependencies: - browserslist "^4.16.0" - postcss-value-parser "^4.1.0" - -postcss-normalize-url@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-5.0.2.tgz#ddcdfb7cede1270740cf3e4dfc6008bd96abc763" - integrity sha512-k4jLTPUxREQ5bpajFQZpx8bCF2UrlqOTzP9kEqcEnOfwsRshWs2+oAFIHfDQB8GO2PaUaSE0NlTAYtbluZTlHQ== - dependencies: - is-absolute-url "^3.0.3" - normalize-url "^6.0.1" - postcss-value-parser "^4.1.0" - -postcss-normalize-whitespace@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.0.1.tgz#b0b40b5bcac83585ff07ead2daf2dcfbeeef8e9a" - integrity sha512-iPklmI5SBnRvwceb/XH568yyzK0qRVuAG+a1HFUsFRf11lEJTiQQa03a4RSCQvLKdcpX7XsI1Gen9LuLoqwiqA== - dependencies: - postcss-value-parser "^4.1.0" - -postcss-ordered-values@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.0.2.tgz#1f351426977be00e0f765b3164ad753dac8ed044" - integrity sha512-8AFYDSOYWebJYLyJi3fyjl6CqMEG/UVworjiyK1r573I56kb3e879sCJLGvR3merj+fAdPpVplXKQZv+ey6CgQ== - dependencies: - cssnano-utils "^2.0.1" - postcss-value-parser "^4.1.0" - -postcss-reduce-initial@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-5.0.1.tgz#9d6369865b0f6f6f6b165a0ef5dc1a4856c7e946" - integrity sha512-zlCZPKLLTMAqA3ZWH57HlbCjkD55LX9dsRyxlls+wfuRfqCi5mSlZVan0heX5cHr154Dq9AfbH70LyhrSAezJw== - dependencies: - browserslist "^4.16.0" - caniuse-api "^3.0.0" - -postcss-reduce-transforms@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-5.0.1.tgz#93c12f6a159474aa711d5269923e2383cedcf640" - integrity sha512-a//FjoPeFkRuAguPscTVmRQUODP+f3ke2HqFNgGPwdYnpeC29RZdCBvGRGTsKpMURb/I3p6jdKoBQ2zI+9Q7kA== - dependencies: - cssnano-utils "^2.0.1" - postcss-value-parser "^4.1.0" - postcss-selector-parser@^6.0.10: version "6.0.13" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz#d05d8d76b1e8e173257ef9d60b706a8e5e99bf1b" @@ -13830,44 +13705,10 @@ postcss-selector-parser@^6.0.10: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5: - version "6.0.6" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.6.tgz#2c5bba8174ac2f6981ab631a42ab0ee54af332ea" - integrity sha512-9LXrvaaX3+mcv5xkg5kFwqSzSH1JIObIx51PrndZwlmznwXRfxMddDvo9gve3gVR8ZTKgoFDdWkbRFmEhT4PMg== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - -postcss-svgo@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-5.0.2.tgz#bc73c4ea4c5a80fbd4b45e29042c34ceffb9257f" - integrity sha512-YzQuFLZu3U3aheizD+B1joQ94vzPfE6BNUcSYuceNxlVnKKsOtdo6hL9/zyC168Q8EwfLSgaDSalsUGa9f2C0A== - dependencies: - postcss-value-parser "^4.1.0" - svgo "^2.3.0" - -postcss-unique-selectors@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.0.1.tgz#3be5c1d7363352eff838bd62b0b07a0abad43bfc" - integrity sha512-gwi1NhHV4FMmPn+qwBNuot1sG1t2OmacLQ/AX29lzyggnjd+MnVD5uqQmpXO3J17KGL2WAxQruj1qTd3H0gG/w== - dependencies: - alphanum-sort "^1.0.2" - postcss-selector-parser "^6.0.5" - uniqs "^2.0.0" - -postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" - integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== - -postcss@^8.2.15, postcss@^8.3.5: - version "8.3.9" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.9.tgz#98754caa06c4ee9eb59cc48bd073bb6bd3437c31" - integrity sha512-f/ZFyAKh9Dnqytx5X62jgjhhzttjZS7hMsohcI7HEI5tjELX/HxCy3EFhsRxyzGvrzFF+82XPvCS8T9TFleVJw== - dependencies: - nanoid "^3.1.28" - picocolors "^0.2.1" - source-map-js "^0.6.2" +preact@^10.16.0: + version "10.19.2" + resolved "https://registry.yarnpkg.com/preact/-/preact-10.19.2.tgz#841797620dba649aaac1f8be42d37c3202dcea8b" + integrity sha512-UA9DX/OJwv6YwP9Vn7Ti/vF80XL+YA5H2l7BpCtUr3ya8LWHFzpiO5R+N7dN16ujpIxhekRFuOOF82bXX7K/lg== prelude-ls@^1.2.1: version "1.2.1" @@ -14020,6 +13861,20 @@ proxy-addr@~2.0.5, proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-agent@6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.3.1.tgz#40e7b230552cf44fd23ffaf7c59024b692612687" + integrity sha512-Rb5RVBy1iyqOtNl15Cw/llpeLH8bsb37gM1FUfKQ+Wck6xHlbAhWGUFiTRHtkjqGTA5pSHz6+0hrPW/oECihPQ== + dependencies: + agent-base "^7.0.2" + debug "^4.3.4" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.2" + lru-cache "^7.14.1" + pac-proxy-agent "^7.0.1" + proxy-from-env "^1.1.0" + socks-proxy-agent "^8.0.2" + proxy-from-env@1.1.0, proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -14053,6 +13908,18 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +puppeteer-core@21.6.0: + version "21.6.0" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-21.6.0.tgz#506d1b7f982a1adca6f98ba88eb6a8be26869978" + integrity sha512-1vrzbp2E1JpBwtIIrriWkN+A0afUxkqRuFTC3uASc5ql6iuK9ppOdIU/CPGKwOyB4YFIQ16mRbK0PK19mbXnaQ== + dependencies: + "@puppeteer/browsers" "1.9.0" + chromium-bidi "0.5.1" + cross-fetch "4.0.0" + debug "4.3.4" + devtools-protocol "0.0.1203626" + ws "8.14.2" + puppeteer-core@^13.1.3: version "13.7.0" resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b" @@ -14158,6 +14025,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue-tick@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142" + integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== + quick-lru@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" @@ -14755,7 +14627,7 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" -schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1: +schema-utils@^3.0.0, schema-utils@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281" integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw== @@ -14773,6 +14645,15 @@ schema-utils@^3.1.2: ajv "^6.12.5" ajv-keywords "^3.5.2" +schema-utils@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" + integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== + dependencies: + "@types/json-schema" "^7.0.8" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + schema-utils@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.0.1.tgz#eb2d042df8b01f4b5c276a2dfd41ba0faab72e8d" @@ -14824,13 +14705,6 @@ semver@7.3.4: dependencies: lru-cache "^6.0.0" -semver@7.3.5, semver@7.x, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: - version "7.3.5" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== - dependencies: - lru-cache "^6.0.0" - semver@7.3.8, semver@^7.0.0, semver@^7.3.7: version "7.3.8" resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798" @@ -14838,6 +14712,13 @@ semver@7.3.8, semver@^7.0.0, semver@^7.3.7: dependencies: lru-cache "^6.0.0" +semver@7.x, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: + version "7.3.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" + integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== + dependencies: + lru-cache "^6.0.0" + semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" @@ -14850,10 +14731,10 @@ semver@^7.3.8: dependencies: lru-cache "^6.0.0" -semver@^7.5.1: - version "7.5.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.1.tgz#c90c4d631cf74720e46b21c1d37ea07edfab91ec" - integrity sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw== +semver@^7.5.4: + version "7.6.0" + resolved "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" + integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== dependencies: lru-cache "^6.0.0" @@ -15130,6 +15011,18 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== +size-limit@11.0.2, size-limit@^11.0.1: + version "11.0.2" + resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-11.0.2.tgz#a9b133d420270abe44489b075ca1b7960db3216b" + integrity sha512-iFZ8iTR/3zPqxSwEIdGnTVYVU0F2nhodLQG/G6zpi/NxECYAK9ntq2lNr+prXH7h3gyBjx2Umt2D/oS2Qzz+eg== + dependencies: + bytes-iec "^3.1.1" + chokidar "^3.5.3" + globby "^14.0.0" + lilconfig "^3.0.0" + nanospinner "^1.1.0" + picocolors "^1.0.0" + size-limit@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/size-limit/-/size-limit-6.0.3.tgz#980e91993a409cb80dd4776fe3e2867afa4d55d0" @@ -15148,6 +15041,11 @@ slash@3.0.0, slash@^3.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +slash@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" + integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== + slice-ansi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" @@ -15257,7 +15155,16 @@ socks-proxy-agent@^7.0.0: debug "^4.3.3" socks "^2.6.2" -socks@^2.6.2: +socks-proxy-agent@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz#5acbd7be7baf18c46a3f293a840109a430a640ad" + integrity sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g== + dependencies: + agent-base "^7.0.2" + debug "^4.3.4" + socks "^2.7.1" + +socks@^2.6.2, socks@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/socks/-/socks-2.7.1.tgz#d8e651247178fde79c0663043e07240196857d55" integrity sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ== @@ -15298,11 +15205,6 @@ source-list-map@^2.0.1: resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== -source-map-js@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" - integrity sha512-/3GptzWzu0+0MBQFrDKzw/DvvMTUORvgY6k6jd/VS6iCR4RDTKWH6v6WPwQoUO8667uQEf9Oe38DxAYWY5F/Ug== - source-map-resolve@^0.5.0: version "0.5.3" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" @@ -15471,11 +15373,6 @@ ssri@^10.0.0, ssri@^10.0.1: dependencies: minipass "^5.0.0" -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - stack-utils@^2.0.3: version "2.0.5" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.5.tgz#d25265fca995154659dbbfba3b49254778d2fdd5" @@ -15530,6 +15427,14 @@ streamroller@^3.1.3: debug "^4.3.4" fs-extra "^8.1.0" +streamx@^2.15.0: + version "2.15.6" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.15.6.tgz#28bf36997ebc7bf6c08f9eba958735231b833887" + integrity sha512-q+vQL4AAz+FdfT137VF69Cc/APqUbxy+MDOImRrMvchJpigHj9GksgDU2LYbO9rx7RX6osWgxJB2WxhYv4SZAw== + dependencies: + fast-fifo "^1.1.0" + queue-tick "^1.0.1" + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" @@ -15713,19 +15618,6 @@ strong-log-transformer@2.1.0, strong-log-transformer@^2.1.0: minimist "^1.2.0" through "^2.3.4" -style-loader@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.0.tgz#d66ea95fc50b22f8b79b69a9e414760fcf58d8d8" - integrity sha512-szANub7ksJtQioJYtpbWwh1hUl99uK15n5HDlikeCRil/zYMZgSxucHddyF/4A3qJMUiAjPhFowrrQuNMA7jwQ== - -stylehacks@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.0.1.tgz#323ec554198520986806388c7fdaebc38d2c06fb" - integrity sha512-Es0rVnHIqbWzveU1b24kbw92HsebBepxfcqe5iix7t9j0PQqhs0IxXVXv0pY2Bxa08CgMkzD6OWql7kbGOuEdA== - dependencies: - browserslist "^4.16.0" - postcss-selector-parser "^6.0.4" - suffix@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/suffix/-/suffix-0.1.1.tgz#cc58231646a0ef1102f79478ef3a9248fd9c842f" @@ -15770,19 +15662,6 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -svgo@^2.3.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.4.0.tgz#0c42653101fd668692c0f69b55b8d7b182ef422b" - integrity sha512-W25S1UUm9Lm9VnE0TvCzL7aso/NCzDEaXLaElCUO/KaVitw0+IBicSVfM1L1c0YHK5TOFh73yQ2naCpVHEQ/OQ== - dependencies: - "@trysound/sax" "0.1.1" - colorette "^1.2.2" - commander "^7.1.0" - css-select "^4.1.3" - css-tree "^1.1.2" - csso "^4.2.0" - stable "^0.1.8" - symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -15815,6 +15694,15 @@ tar-fs@2.1.1, tar-fs@^2.0.0: pump "^3.0.0" tar-stream "^2.1.4" +tar-fs@3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.0.4.tgz#a21dc60a2d5d9f55e0089ccd78124f1d3771dbbf" + integrity sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w== + dependencies: + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^3.1.5" + tar-stream@^1.5.2: version "1.6.2" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" @@ -15839,6 +15727,15 @@ tar-stream@^2.1.4, tar-stream@^2.2.0, tar-stream@~2.2.0: inherits "^2.0.3" readable-stream "^3.1.1" +tar-stream@^3.1.5: + version "3.1.6" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.6.tgz#6520607b55a06f4a2e2e04db360ba7d338cc5bab" + integrity sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + tar@6.1.11: version "6.1.11" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" @@ -15906,7 +15803,7 @@ terminal-link@^2.0.0: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" -terser-webpack-plugin@^5.1.1, terser-webpack-plugin@^5.1.3: +terser-webpack-plugin@^5.1.1: version "5.2.4" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.2.4.tgz#ad1be7639b1cbe3ea49fab995cbe7224b31747a1" integrity sha512-E2CkNMN+1cho04YpdANyRrn8CyN4yMy+WdFKZIySFZrGXZxJwJP6PMNGGc/Mcr6qygQHUUqRxnAPmi0M9f00XA== @@ -16031,11 +15928,6 @@ timers-ext@^0.1.7: es5-ext "~0.10.46" next-tick "1" -timsort@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" - integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= - tiny-hashes@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tiny-hashes/-/tiny-hashes-1.0.1.tgz#ddbe9060312ddb4efe0a174bb3a27e1331c425a1" @@ -16225,10 +16117,10 @@ ts-custom-error@^3.2.0: resolved "https://registry.yarnpkg.com/ts-custom-error/-/ts-custom-error-3.2.0.tgz#ff8f80a3812bab9dc448536312da52dce1b720fb" integrity sha512-cBvC2QjtvJ9JfWLvstVnI45Y46Y5dMxIaG1TDMGAD/R87hpvqFL+7LhvUDhnRCfOnx/xitollFWWvUKKKhbN0A== -ts-jest@^27.0.0: - version "27.0.7" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-27.0.7.tgz#fb7c8c8cb5526ab371bc1b23d06e745652cca2d0" - integrity sha512-O41shibMqzdafpuP+CkrOL7ykbmLh+FqQrXEmV9CydQ5JBk0Sj0uAEF5TNNe94fZWKm3yYvWa/IbyV4Yg1zK2Q== +ts-jest@^27.0.7: + version "27.1.5" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-27.1.5.tgz#0ddf1b163fbaae3d5b7504a1e65c914a95cff297" + integrity sha512-Xv6jBQPoBEvBq/5i2TeSG9tt/nqkbpcurrEG1b+2yfBrcJelOZF9Ml6dmyMh7bcW9JyFbRYpR5rxROSlBLTZHA== dependencies: bs-logger "0.x" fast-json-stable-stringify "2.x" @@ -16305,6 +16197,11 @@ tslib@^2.5.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.1.tgz#f2ad78c367857d54e49a0ef9def68737e1a67b21" integrity sha512-KaI6gPil5m9vF7DKaoXxx1ia9fxS4qG5YveErRRVknPDXXriu5M8h48YRjB6h5ZUOKuAKlSJYb0GaDe8I39fRw== +tslib@^2.6.2: + version "2.6.2" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -16432,6 +16329,11 @@ typescript@4.3.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== +typescript@4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.2.tgz#8ac1fba9f52256fdb06fb89e4122fa6a346c2998" + integrity sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw== + "typescript@^3 || ^4": version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" @@ -16493,6 +16395,11 @@ unicode-property-aliases-ecmascript@^1.0.4: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4" integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg== +unicorn-magic@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" + integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== + union-value@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" @@ -16503,11 +16410,6 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^2.0.1" -uniqs@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" - integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= - unique-filename@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-2.0.1.tgz#e785f8675a9a7589e0ac77e0b5c34d2eaeac6da2" @@ -16622,6 +16524,11 @@ url-to-options@^1.0.1: resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9" integrity sha512-0kQLIzG4fdk/G5NONku64rSH/x32NOA39LVQqlK8Le6lvTF6GGRJpqaQFGgU+CLwySIqBSMdwYM0sYcW9f6P4A== +urlpattern-polyfill@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz#bc7e386bb12fd7898b58d1509df21d3c29ab3460" + integrity sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" @@ -16695,11 +16602,6 @@ vary@^1, vary@~1.1.2: resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= -vendors@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.4.tgz#e2b800a53e7a29b93506c3cf41100d16c4c4ad8e" - integrity sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w== - vm-browserify@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" @@ -16743,14 +16645,6 @@ walker@^1.0.7: dependencies: makeerror "1.0.x" -watchpack@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.2.0.tgz#47d78f5415fe550ecd740f99fe2882323a58b1ce" - integrity sha512-up4YAn/XHgZHIxFBVCdlMiWDj6WaLKpwVeGQk2I5thdYxF/KmF0aaz6TfJZ/hfl1h/XlcDr7k1KH7ThDagpFaA== - dependencies: - glob-to-regexp "^0.4.1" - graceful-fs "^4.1.2" - watchpack@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" @@ -16964,50 +16858,45 @@ webpack-sources@^2.2.0: source-list-map "^2.0.1" source-map "^0.6.1" -webpack-sources@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.1.tgz#251a7d9720d75ada1469ca07dbb62f3641a05b6d" - integrity sha512-t6BMVLQ0AkjBOoRTZgqrWm7xbXMBzD+XDq2EZ96+vMfn3qKgsvdXZhbPZ4ElUOpdv4u+iiGe+w3+J75iy/bYGA== - webpack-sources@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@^5, webpack@^5.56.0: - version "5.59.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.59.0.tgz#a5038fc0d4d9350ee528e7e1e0282080c63efcf5" - integrity sha512-2HiFHKnWIb/cBfOfgssQn8XIRvntISXiz//F1q1+hKMs+uzC1zlVCJZEP7XqI1wzrDyc/ZdB4G+MYtz5biJxCA== - dependencies: - "@types/eslint-scope" "^3.7.0" - "@types/estree" "^0.0.50" - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/wasm-edit" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.4.1" +webpack@^5.82.0: + version "5.82.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.82.0.tgz#3c0d074dec79401db026b4ba0fb23d6333f88e7d" + integrity sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg== + dependencies: + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^1.0.0" + "@webassemblyjs/ast" "^1.11.5" + "@webassemblyjs/wasm-edit" "^1.11.5" + "@webassemblyjs/wasm-parser" "^1.11.5" + acorn "^8.7.1" acorn-import-assertions "^1.7.6" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.8.3" - es-module-lexer "^0.9.0" + enhanced-resolve "^5.13.0" + es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" - graceful-fs "^4.2.4" - json-parse-better-errors "^1.0.2" + graceful-fs "^4.2.9" + json-parse-even-better-errors "^2.3.1" loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.1.0" + schema-utils "^3.1.2" tapable "^2.1.1" - terser-webpack-plugin "^5.1.3" - watchpack "^2.2.0" - webpack-sources "^3.2.0" + terser-webpack-plugin "^5.3.7" + watchpack "^2.4.0" + webpack-sources "^3.2.3" -webpack@^5.82.0: - version "5.82.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.82.0.tgz#3c0d074dec79401db026b4ba0fb23d6333f88e7d" - integrity sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg== +webpack@^5.89.0: + version "5.89.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.89.0.tgz#56b8bf9a34356e93a6625770006490bf3a7f32dc" + integrity sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^1.0.0" @@ -17015,10 +16904,10 @@ webpack@^5.82.0: "@webassemblyjs/wasm-edit" "^1.11.5" "@webassemblyjs/wasm-parser" "^1.11.5" acorn "^8.7.1" - acorn-import-assertions "^1.7.6" + acorn-import-assertions "^1.9.0" browserslist "^4.14.5" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.13.0" + enhanced-resolve "^5.15.0" es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" @@ -17028,7 +16917,7 @@ webpack@^5.82.0: loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.1.2" + schema-utils "^3.2.0" tapable "^2.1.1" terser-webpack-plugin "^5.3.7" watchpack "^2.4.0" @@ -17301,6 +17190,11 @@ write-pkg@4.0.0: type-fest "^0.4.1" write-json-file "^3.2.0" +ws@8.14.2: + version "8.14.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.14.2.tgz#6c249a806eb2db7a20d26d51e7709eab7b2e6c7f" + integrity sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g== + ws@8.5.0, ws@^8.5.0: version "8.5.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" @@ -17363,7 +17257,7 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@^1.10.0, yaml@^1.10.2: +yaml@^1.10.0: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== @@ -17406,6 +17300,19 @@ yargs@16.2.0, yargs@^16.1.1, yargs@^16.2.0: y18n "^5.0.5" yargs-parser "^20.2.2" +yargs@17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yargs@^17.0.0, yargs@^17.2.1, yargs@^17.6.2: version "17.6.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.6.2.tgz#2e23f2944e976339a1ee00f18c77fedee8332541"