Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 46 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<IterableResponse>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2101,8 +2124,16 @@ Return value from [`initialize`](#initialize) and [`initializeWithConfig`](#init
```ts
interface WithJWT {
clearRefresh: () => void;
setEmail: (email: string) => Promise<string>;
setUserID: (userId: string) => Promise<string>;
setEmail: (
email: string,
identityResolution?: IdentityResolution,
preferUserId?: boolean
) => Promise<string>;
setUserID: (
userId: string,
identityResolution?: IdentityResolution,
preferUserId?: boolean
) => Promise<string>;
logout: () => void;
refreshJwtToken: (authTypes: string) => Promise<string>;
setVisitorUsageTracked: (consent: boolean) => void;
Expand All @@ -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).
Expand Down
138 changes: 138 additions & 0 deletions src/authorization/authorization.autoUserUpdate.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
24 changes: 21 additions & 3 deletions src/authorization/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();

Expand Down
34 changes: 34 additions & 0 deletions src/authorization/autoUserUpdate.ts
Original file line number Diff line number Diff line change
@@ -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);
};
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/utils/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Options = {
eventThresholdLimit?: number;
onUnknownUserCreated?: (userId: string) => void;
identityResolution?: IdentityResolution;
autoUserUpdate: boolean | 'oncePerUser';
};

const _config = () => {
Expand All @@ -27,7 +28,8 @@ const _config = () => {
identityResolution: {
replayOnVisitorToKnown: true,
mergeOnUnknownToKnown: true
}
},
autoUserUpdate: true
};

const getConfig = <K extends keyof Options>(option: K) => options[option];
Expand Down
Loading