diff --git a/CHANGELOG.md b/CHANGELOG.md index f0195c2d..e20c2b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Updates +- Added the `autoUserUpdate` config option to control the `users/update` call made by `setUserID` and JWT `setEmail`. `'oncePerUser'` sends it once per user per browser until `logout`, and `false` turns it off. The default, `true`, keeps the current behavior (SDK-128). + +### Fixes +- Fixed the `updateUser` endpoint name and the `setEmail`/`setUserID` signatures in the README. ## [2.3.3] ### Fixes diff --git a/README.md b/README.md index d6adb437..12e94052 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Iterable's API, see the [API Overview](https://support.iterable.com/hc/articles/ | [`trackPurchase`](#trackPurchase) | Tracks a `purchase` event by calling [`POST /api/commerce/trackPurchase`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-trackpurchase). | | [`updateCart`](#updateCart) | Updates the shopping cart items on the user's Iterable profile by calling [`POST /api/commerce/updateCart`](https://support.iterable.com/hc/articles/204780579#post-api-commerce-updatecart). | | [`updateSubscriptions`](#updateSubscriptions) | Updates the user's subscriptions by calling [`POST /api/users/updateSubscriptions`](https://support.iterable.com/hc/articles/204780579#post-api-users-updatesubscriptions). | -| [`updateUser`](#updateUser) | Updates the data on a user's Iterable profile by calling [`POST /api/users/updateUser`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). | +| [`updateUser`](#updateUser) | Updates the data on a user's Iterable profile by calling [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). | | [`updateUserEmail`](#updateUserEmail) | Updates the current user's `email` by calling [`POST /api/users/updateEmail`](https://support.iterable.com/hc/articles/204780579#post-api-users-updateemail). Causes the SDK to fetch a JWT for the new email address. | | [`setVisitorUsageTracked`](#setVisitorUsageTracked) | Enables or disables unknown visitor tracking based on user consent (UUA). Returned by `initialize`/`initializeWithConfig`. | | [`clearVisitorEventsAndUserData`](#clearVisitorEventsAndUserData) | Clears unknown visitor session data and queued events (UUA). Returned by `initialize`/`initializeWithConfig`. | @@ -420,8 +420,9 @@ parameter. Returns methods you can use to identify the current user, work with JWT tokens, and log the user out (see [`WithJWT`](#withjwt)). The configuration options you can pass to this function are useful if you -need to [point the SDK to Iterable's EU API endpoints](#iterables-european-data-center-eudc) -or [allow JavaScript execution in Safari tabs](#safari-allowing-javascript-execution-in-tabs-opened-by-in-app-message-link-clicks). +need to [point the SDK to Iterable's EU API endpoints](#iterables-european-data-center-eudc), +[allow JavaScript execution in Safari tabs](#safari-allowing-javascript-execution-in-tabs-opened-by-in-app-message-link-clicks), +or [reduce the `users/update` calls the SDK makes when you identify a user](#options). ```ts function initializeWithConfig(initializeParams: WithJWTParams): WithJWT; @@ -1034,7 +1035,11 @@ See also: ## `updateUser` -Updates the data on a user's Iterable profile by calling [`POST /api/users/updateUser`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). +Updates the data on a user's Iterable profile by calling [`POST /api/users/update`](https://support.iterable.com/hc/articles/204780579#post-api-users-update). + +If you set [`autoUserUpdate`](#options) to `false`, create the user's profile +yourself: call `updateUser` after `setUserID` or `setEmail` resolves, or create +the profile from your server. ```ts const updateUser = (payload: UpdateUserParams = {}): IterablePromise @@ -1953,9 +1958,27 @@ type Options = { mergeOnUnknownToKnown?: boolean; replayOnVisitorToKnown?: boolean; }; + autoUserUpdate: boolean | 'oncePerUser'; }; ``` +`autoUserUpdate` controls the `POST /api/users/update` call the SDK makes to +create the user's profile when you call `setUserID`, or `setEmail` with a JWT +enabled API key: + +- `true` (default) – Calls `users/update` every time you identify the user. +- `'oncePerUser'` – Calls `users/update` the first time you identify a user in + this browser, then skips it for the same user until `logout`. Use this if + your site reloads the page on navigation and identifies the user on every + page load. If the call fails, the SDK tries again the next time you identify + the user. +- `false` – Never calls `users/update` when you identify the user. You are + responsible for creating the user's profile, by calling + [`updateUser`](#updateuser) after `setUserID` or `setEmail` resolves, or from + your server. Until the profile exists, in-app and embedded message requests + can fail, for example in userID-based and hybrid projects, or when you + identify users with `setUserID`. + ## `OutOfTheBoxButton` Custom styles to apply to buttons in an embedded message. The same as @@ -2101,8 +2124,16 @@ Return value from [`initialize`](#initialize) and [`initializeWithConfig`](#init ```ts interface WithJWT { clearRefresh: () => void; - setEmail: (email: string) => Promise; - setUserID: (userId: string) => Promise; + setEmail: ( + email: string, + identityResolution?: IdentityResolution, + preferUserId?: boolean + ) => Promise; + setUserID: ( + userId: string, + identityResolution?: IdentityResolution, + preferUserId?: boolean + ) => Promise; logout: () => void; refreshJwtToken: (authTypes: string) => Promise; setVisitorUsageTracked: (consent: boolean) => void; @@ -2115,15 +2146,22 @@ Definitions: - `clearRefresh` – Clears the JWT refresh timer. - `setEmail` – Identifies the current user by `email`, and fetches a valid JWT token by calling the `generateJWT` function passed to [`initialize`](#initialize) - or [`initializeWithConfig`](#initializeWithConfig). + or [`initializeWithConfig`](#initializeWithConfig). Then calls + `POST /api/users/update` to create the user's profile, as controlled by + [`autoUserUpdate`](#options). - `setUserID` - Identifies the current user by `userId`, and fetches a valid JWT token by calling the `generateJWT` function passed to [`initialize`](#initialize) - or [`initializeWithConfig`](#initializeWithConfig). + or [`initializeWithConfig`](#initializeWithConfig). Then calls + `POST /api/users/update` to create the user's profile, as controlled by + [`autoUserUpdate`](#options). - `refreshJwtToken` – Manually refreshes the JWT token for the signed-in user. - `logout` – Signs the current user out of the SDK. - `setVisitorUsageTracked` – Enables/disables unknown visitor tracking based on user consent. - `clearVisitorEventsAndUserData` – Clears unknown visitor session data and queued events. +`setEmail` and `setUserID` pass `preferUserId` (default `true`) to their +`users/update` call. + ## `WithJWTParams` Parameters to pass to [`initializeWithConfig`](#initializewithconfig). diff --git a/src/authorization/authorization.autoUserUpdate.test.ts b/src/authorization/authorization.autoUserUpdate.test.ts new file mode 100644 index 00000000..0b190be1 --- /dev/null +++ b/src/authorization/authorization.autoUserUpdate.test.ts @@ -0,0 +1,138 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import MockAdapter from 'axios-mock-adapter'; +import { initialize } from './authorization'; +import { baseAxiosRequest } from '../request'; +import { config } from '../utils/config'; +import { SHARED_PREF_USER_UPDATED } from '../constants'; + +const MOCK_JWT_KEY = + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MzA2MTc3MzQsImlhdCI6MTYzMDYxNzQzNCwiZW1haWwiOiJ3aWR0aC50ZXN0ZXJAZ21haWwuY29tIn0.knLmbgO8kKM9CHP2TH2v85OSC2Jorh2JjRm76FFsPQc'; + +const generateJWT = () => Promise.resolve(MOCK_JWT_KEY); + +/* each call re-runs initialize, the same way a full page reload does */ +const identifyPaths = [ + { + name: 'non-JWT setUserID', + identify: (id: string) => initialize('123').setUserID(id), + logout: () => initialize('123').logout() + }, + { + name: 'JWT setUserID', + identify: (id: string) => initialize('123', generateJWT).setUserID(id), + logout: () => initialize('123', generateJWT).logout() + }, + { + name: 'JWT setEmail', + identify: (id: string) => + initialize('123', generateJWT).setEmail(`${id}@example.com`), + logout: () => initialize('123', generateJWT).logout() + } +]; + +let mockRequest: MockAdapter; + +const usersUpdateCalls = () => + mockRequest.history.post.filter((e: any) => /users\/update$/.test(e.url)) + .length; + +describe('autoUserUpdate', () => { + beforeAll(() => { + mockRequest = new MockAdapter(baseAxiosRequest); + jest.spyOn(console, 'warn').mockImplementation(jest.fn()); + }); + + beforeEach(() => { + mockRequest.reset(); + mockRequest.onPost('/users/update').reply(200, {}); + localStorage.clear(); + config.setConfig({ autoUserUpdate: true }); + }); + + describe.each(identifyPaths)('$name', ({ identify, logout }) => { + it('calls users/update on every identify when true', async () => { + await identify('user-1'); + await identify('user-1'); + + expect(usersUpdateCalls()).toBe(2); + expect(localStorage.getItem(SHARED_PREF_USER_UPDATED)).toBeNull(); + }); + + it('never calls users/update when false', async () => { + config.setConfig({ autoUserUpdate: false }); + + await identify('user-1'); + await identify('user-2'); + + expect(usersUpdateCalls()).toBe(0); + }); + + it('treats unrecognized values as true', async () => { + config.setConfig({ autoUserUpdate: 'sometimes' as any }); + + await identify('user-1'); + await identify('user-1'); + + expect(usersUpdateCalls()).toBe(2); + }); + + describe('oncePerUser', () => { + beforeEach(() => { + config.setConfig({ autoUserUpdate: 'oncePerUser' }); + }); + + it('calls users/update only on the first identify for the same user', async () => { + await identify('user-1'); + await identify('user-1'); + await identify('user-1'); + + expect(usersUpdateCalls()).toBe(1); + }); + + it('calls users/update again when the identity changes', async () => { + await identify('user-1'); + await identify('user-2'); + await identify('user-2'); + + expect(usersUpdateCalls()).toBe(2); + }); + + it('calls users/update again after logout', async () => { + await identify('user-1'); + logout(); + expect(localStorage.getItem(SHARED_PREF_USER_UPDATED)).toBeNull(); + await identify('user-1'); + + expect(usersUpdateCalls()).toBe(2); + }); + + it('retries on the next identify when users/update failed', async () => { + mockRequest.onPost('/users/update').reply(500, {}); + await expect(identify('user-1')).rejects.toBeDefined(); + expect(localStorage.getItem(SHARED_PREF_USER_UPDATED)).toBeNull(); + + mockRequest.resetHistory(); + mockRequest.onPost('/users/update').reply(200, {}); + await identify('user-1'); + await identify('user-1'); + + expect(usersUpdateCalls()).toBe(1); + }); + }); + }); + + it('does not treat a userId and an email with the same value as one user', async () => { + config.setConfig({ autoUserUpdate: 'oncePerUser' }); + + await initialize('123', generateJWT).setUserID('same@example.com'); + await initialize('123', generateJWT).setEmail('same@example.com'); + + expect(usersUpdateCalls()).toBe(2); + }); + + it('keeps non-JWT setEmail free of users/update calls', async () => { + await initialize('123').setEmail('user-1@example.com'); + + expect(usersUpdateCalls()).toBe(0); + }); +}); diff --git a/src/authorization/authorization.ts b/src/authorization/authorization.ts index d46782ea..b9e18bcd 100644 --- a/src/authorization/authorization.ts +++ b/src/authorization/authorization.ts @@ -33,6 +33,12 @@ import { validateTokenTime } from './utils'; import { updateUser } from '../users'; +import { + UserIdentityType, + clearUserUpdated, + markUserUpdated, + shouldAutoUpdateUser +} from './autoUserUpdate'; import { baseAxiosRequest } from '../request'; import { clearMessages } from '../inapp'; @@ -486,6 +492,16 @@ export function initialize( }; }; + const autoUpdateUser = async ( + type: UserIdentityType, + identity: string, + preferUserId?: boolean + ) => { + if (!shouldAutoUpdateUser(type, identity)) return; + await tryUser(preferUserId)(); + markUserUpdated(type, identity); + }; + const enableUnknownTracking = () => { try { if (config.getConfig('enableUnknownActivation')) { @@ -600,7 +616,7 @@ export function initialize( // Initialize user authentication first, then create user profile initializeUserId(userId); - await tryUser(preferUserId)(); + await autoUpdateUser('userID', userId, preferUserId); const result = await tryMergeUser(userId, false, merge); if (result.success) { @@ -628,6 +644,7 @@ export function initialize( localStorage.removeItem(SHARED_PREF_EMAIL); localStorage.removeItem(SHARED_PREF_USER_ID); localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + clearUserUpdated(); /* clear fetched in-app messages */ clearMessages(); @@ -983,7 +1000,7 @@ export function initialize( initializeEmailUser(email); // Create user profile first before attempting merge - await tryUser(preferUserId)(); + await autoUpdateUser('email', email, preferUserId); const result = await tryMergeUser(email, true, merge); if (result.success) { @@ -1034,7 +1051,7 @@ export function initialize( initializeUserId(userId); // Create user profile after authentication is set up - await tryUser(preferUserId)(); + await autoUpdateUser('userID', userId, preferUserId); const result = await tryMergeUser(userId, false, merge); if (result.success) { @@ -1075,6 +1092,7 @@ export function initialize( localStorage.removeItem(SHARED_PREF_EMAIL); localStorage.removeItem(SHARED_PREF_USER_ID); localStorage.removeItem(SHARED_PREF_CONSENT_TIMESTAMP); + clearUserUpdated(); /* clear fetched in-app messages */ clearMessages(); diff --git a/src/authorization/autoUserUpdate.ts b/src/authorization/autoUserUpdate.ts new file mode 100644 index 00000000..980be473 --- /dev/null +++ b/src/authorization/autoUserUpdate.ts @@ -0,0 +1,34 @@ +import { SHARED_PREF_USER_UPDATED } from '../constants'; +import { config } from '../utils/config'; + +export type UserIdentityType = 'email' | 'userID'; + +const toStoredIdentity = (type: UserIdentityType, identity: string) => + `${type}:${identity}`; + +/* any value other than false or 'oncePerUser' keeps the default behavior */ +export const shouldAutoUpdateUser = ( + type: UserIdentityType, + identity: string +) => { + const autoUserUpdate = config.getConfig('autoUserUpdate'); + if (autoUserUpdate === false) return false; + if (autoUserUpdate !== 'oncePerUser') return true; + return ( + localStorage.getItem(SHARED_PREF_USER_UPDATED) !== + toStoredIdentity(type, identity) + ); +}; + +export const markUserUpdated = (type: UserIdentityType, identity: string) => { + if (config.getConfig('autoUserUpdate') === 'oncePerUser') { + localStorage.setItem( + SHARED_PREF_USER_UPDATED, + toStoredIdentity(type, identity) + ); + } +}; + +export const clearUserUpdated = () => { + localStorage.removeItem(SHARED_PREF_USER_UPDATED); +}; diff --git a/src/constants.ts b/src/constants.ts index e86bb7f5..ea79b85d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -290,6 +290,7 @@ export const SHARED_PREF_UNKNOWN_USER_ID = 'unknown_userId'; export const SHARED_PREF_UNKNOWN_USAGE_TRACKED = 'itbl_unknown_usage_tracked'; export const SHARED_PREF_CONSENT_TIMESTAMP = 'itbl_consent_timestamp'; export const SHARED_PREF_USER_TOKEN = 'itbl_auth_token'; +export const SHARED_PREF_USER_UPDATED = 'itbl_user_updated'; export const KEY_EVENT_NAME = 'eventName'; export const KEY_CREATED_AT = 'createdAt'; diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts index 624d15ca..d993039c 100644 --- a/src/utils/config.test.ts +++ b/src/utils/config.test.ts @@ -4,6 +4,7 @@ describe('Config', () => { it('should get config', () => { expect(config.getConfig('logLevel')).toBe('none'); expect(config.getConfig('baseURL')).toBe('https://api.iterable.com/api'); + expect(config.getConfig('autoUserUpdate')).toBe(true); }); it('should set config', () => { diff --git a/src/utils/config.ts b/src/utils/config.ts index 28285ebc..d9791d25 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -14,6 +14,7 @@ export type Options = { eventThresholdLimit?: number; onUnknownUserCreated?: (userId: string) => void; identityResolution?: IdentityResolution; + autoUserUpdate: boolean | 'oncePerUser'; }; const _config = () => { @@ -27,7 +28,8 @@ const _config = () => { identityResolution: { replayOnVisitorToKnown: true, mergeOnUnknownToKnown: true - } + }, + autoUserUpdate: true }; const getConfig = (option: K) => options[option];