From 857c2eda4fc90f0b7190643b5789ddf4e475accf Mon Sep 17 00:00:00 2001 From: Joao Dordio Date: Fri, 23 Oct 2026 12:10:55 +0100 Subject: [PATCH] SDK-128 add autoUserUpdate config to control identify-time users/update setUserID and JWT setEmail call users/update on every identify. Sites that reload on every navigation send one per page view. autoUserUpdate keeps that as the default (true), adds 'oncePerUser' to send it once per user per browser until logout, and false to skip it. --- CHANGELOG.md | 5 + README.md | 54 ++++++- .../authorization.autoUserUpdate.test.ts | 138 ++++++++++++++++++ src/authorization/authorization.ts | 24 ++- src/authorization/autoUserUpdate.ts | 34 +++++ src/constants.ts | 1 + src/utils/config.test.ts | 1 + src/utils/config.ts | 4 +- 8 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 src/authorization/authorization.autoUserUpdate.test.ts create mode 100644 src/authorization/autoUserUpdate.ts 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];