Skip to content

Commit f742616

Browse files
authored
feat: Add Workspace IDSync search on user identification (#92)
1 parent c7b34b3 commit f742616

2 files changed

Lines changed: 517 additions & 1 deletion

File tree

src/Rokt-Kit.ts

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ interface RoktKitSettings {
3030
loggingUrl?: string;
3131
errorUrl?: string;
3232
isLoggingEnabled?: string | boolean;
33+
workspaceIdSyncApiKey?: string;
3334
}
3435

3536
interface EventAttributeCondition {
@@ -86,6 +87,29 @@ interface FilteredUser extends IMParticleUser {
8687
getUserIdentities?: () => { userIdentities: Record<string, string> };
8788
}
8889

90+
// TODO: Replace with `IIdentitySearchResult` from `@mparticle/web-sdk` once
91+
// a version that exports it is published (currently on a feature branch in
92+
// mParticle/mparticle-web-sdk PR #1255). The shape below is intentionally
93+
// structurally identical so the swap is a one-line import change.
94+
interface WorkspaceIdSyncResult {
95+
httpCode: number;
96+
body?: {
97+
context?: string | null;
98+
mpid?: string;
99+
matched_identities?: Record<string, string>;
100+
is_ephemeral?: boolean;
101+
is_logged_in?: boolean;
102+
};
103+
}
104+
105+
// TODO: Replace with `IdentitySearchCallback`-compatible reference from
106+
// `@mparticle/web-sdk` once published (mirrors `SDKIdentityApi.search`).
107+
type WorkspaceIdSyncSearcher = (
108+
apiKey: string,
109+
knownIdentities: { email: string },
110+
callback: (result: WorkspaceIdSyncResult) => void,
111+
) => void;
112+
89113
interface KitFilters {
90114
userAttributeFilters?: string[];
91115
filterUserAttributes?: (attributes: Record<string, unknown>, filters?: string[]) => Record<string, unknown>;
@@ -134,6 +158,7 @@ interface MParticleExtended {
134158
loggedEvents?: Array<Record<string, unknown>>;
135159
_registerErrorReportingService?(service: ErrorReportingService): void;
136160
_registerLoggingService?(service: LoggingService): void;
161+
Identity?: { search?: WorkspaceIdSyncSearcher };
137162
}
138163

139164
interface TestHelpers {
@@ -217,6 +242,13 @@ const ROKT_IDENTITY_EVENT_TYPE = {
217242
const ROKT_THANK_YOU_JOURNEY_EXTENSION = 'ThankYouPageJourney';
218243
const ROKT_INTEGRATION_SCRIPT_ID = 'rokt-launcher';
219244
const ROKT_THANK_YOU_ELEMENT_SCRIPT_ID = 'rokt-thank-you-element';
245+
const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace';
246+
247+
// Bound on how long selectPlacements will wait for an in-flight Workspace
248+
// IDSync search before proceeding without the userIdentifiedInWorkspace flag.
249+
// Long enough to cover the typical /v1/search round-trip (~50ms); short enough that a
250+
// stalled search never blocks placement rendering on a thank-you page.
251+
const WORKSPACE_SEARCH_SELECT_TIMEOUT_MS = 500;
220252

221253
type RoktIdentityEventType = (typeof ROKT_IDENTITY_EVENT_TYPE)[keyof typeof ROKT_IDENTITY_EVENT_TYPE];
222254

@@ -670,6 +702,9 @@ class RoktKit implements KitInterface {
670702
public launcher: RoktLauncher | null = null;
671703
public filters: KitFilters = {};
672704
public userAttributes: Record<string, unknown> = {};
705+
// Flag set by the Workspace IDSync flow on a 200 response. Stored on the
706+
// kit instance and merged into placement attributes inside selectPlacements.
707+
public userIdentifiedInWorkspace = false;
673708
public testHelpers: TestHelpers | null = null;
674709
public placementEventMappingLookup: Record<string, string> = {};
675710
public placementEventAttributeMappingLookup: Record<string, PlacementEventRule[]> = {};
@@ -686,6 +721,17 @@ class RoktKit implements KitInterface {
686721
private _onboardingExpProvider?: string;
687722
private _thankYouElementOnLoadCallback: (() => void) | null = null;
688723
private _isThankYouElementLoaded = false;
724+
private _workspaceIdSyncApiKey?: string;
725+
726+
// Held during a search dispatch so the next selectPlacements call;
727+
// can wait for the HTTP response before reading userIdentifiedInWorkspace;
728+
// — otherwise the first placement call ships without the flag.
729+
private _workspaceSearchInFlightPromise: Promise<void> | null = null;
730+
// The email value sent in the most recent successful search
731+
// dispatch. If a subsequent identification arrives with the same email,
732+
// we skip the network call (the flag is still correct from the prior
733+
// search). Cleared on logout so a re-login re-evaluates fresh.
734+
private _workspaceLastSearchedEmail?: string;
689735

690736
// ---- Private helpers ----
691737

@@ -1044,6 +1090,10 @@ class RoktKit implements KitInterface {
10441090
this._mappedEmailSha256Key = kitSettings.hashedEmailUserIdentityType.toLowerCase();
10451091
}
10461092

1093+
this._workspaceIdSyncApiKey = isString(kitSettings.workspaceIdSyncApiKey)
1094+
? kitSettings.workspaceIdSyncApiKey
1095+
: undefined;
1096+
10471097
const domain = mp().Rokt?.domain;
10481098
const { roktExtensionsQueryParams, legacyRoktExtensions, loadThankYouElement } = extractRoktExtensionConfig(
10491099
kitSettings.roktExtensions,
@@ -1195,15 +1245,75 @@ class RoktKit implements KitInterface {
11951245
}
11961246

11971247
public onUserIdentified(user: IMParticleUser): string {
1198-
this.filters.filteredUser = user as FilteredUser;
1248+
const filteredUser = user as FilteredUser;
1249+
this.filters.filteredUser = filteredUser;
1250+
this._workspaceSearchInFlightPromise = this.search(filteredUser);
11991251
return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.IDENTIFY, 'onUserIdentified');
12001252
}
12011253

1254+
private search(filteredUser: FilteredUser): Promise<void> {
1255+
const apiKey = this._workspaceIdSyncApiKey;
1256+
if (!apiKey) {
1257+
this.userIdentifiedInWorkspace = false;
1258+
this._workspaceLastSearchedEmail = undefined;
1259+
return Promise.resolve();
1260+
}
1261+
const search = mp().Identity?.search;
1262+
if (typeof search !== 'function') {
1263+
this.userIdentifiedInWorkspace = false;
1264+
this._workspaceLastSearchedEmail = undefined;
1265+
return Promise.resolve();
1266+
}
1267+
const userIdentities = filteredUser.getUserIdentities ? filteredUser.getUserIdentities().userIdentities : null;
1268+
const email = userIdentities?.email;
1269+
if (!email || !isString(email)) {
1270+
this.userIdentifiedInWorkspace = false;
1271+
this._workspaceLastSearchedEmail = undefined;
1272+
return Promise.resolve();
1273+
}
1274+
1275+
// Same email as the last successful dispatch → skip the network call.
1276+
// The current flag value still reflects the correct match status.
1277+
if (email === this._workspaceLastSearchedEmail) {
1278+
return Promise.resolve();
1279+
}
1280+
1281+
// New / different email → reset and re-search. Cache the email up front
1282+
// so a second concurrent invocation with the same email also dedupes.
1283+
this.userIdentifiedInWorkspace = false;
1284+
this._workspaceLastSearchedEmail = email;
1285+
1286+
return new Promise<void>((resolve) => {
1287+
try {
1288+
search(apiKey, { email }, (result: WorkspaceIdSyncResult) => {
1289+
if (result?.httpCode === 200) {
1290+
this.userIdentifiedInWorkspace = true;
1291+
}
1292+
resolve();
1293+
});
1294+
} catch (err) {
1295+
console.error('Rokt Kit: Workspace IDSync search failed', err);
1296+
// Dispatch failed — clear the cache so the same email can retry on
1297+
// the next identification rather than being stuck behind a poisoned
1298+
// entry that short-circuits future searches.
1299+
this._workspaceLastSearchedEmail = undefined;
1300+
resolve();
1301+
}
1302+
});
1303+
}
1304+
12021305
public onLoginComplete(user: IMParticleUser, _filteredIdentityRequest: unknown): string {
12031306
return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGIN, 'onLoginComplete');
12041307
}
12051308

12061309
public onLogoutComplete(user: IMParticleUser, _filteredIdentityRequest: unknown): string {
1310+
// Anonymous sessions must not carry the previous user's match forward.
1311+
// Clear the flag explicitly here. Also clear the email cache so a
1312+
// re-login (possibly the same email) dispatches a fresh search rather
1313+
// than reusing a stale answer.
1314+
this.userIdentifiedInWorkspace = false;
1315+
this._workspaceSearchInFlightPromise = null;
1316+
this._workspaceLastSearchedEmail = undefined;
12071317
return this.handleIdentityComplete(user, ROKT_IDENTITY_EVENT_TYPE.LOGOUT, 'onLogoutComplete');
12081318
}
12091319

@@ -1213,8 +1323,39 @@ class RoktKit implements KitInterface {
12131323

12141324
/**
12151325
* Selects placements for Rokt Web SDK with merged attributes, filters, and experimentation options.
1326+
*
1327+
* If a Workspace IDSync search is in flight from a recent onUserIdentified
1328+
* call, this method waits up to `WORKSPACE_SEARCH_SELECT_TIMEOUT_MS` for it
1329+
* to settle so the first placement call can include the
1330+
* `userIdentifiedInWorkspace` flag without racing the network response.
1331+
* The timeout protects against a stalled or slow search blocking placement
1332+
* rendering — if it fires, selectPlacements proceeds without the flag.
1333+
*
1334+
* Implementation note: this method stays non-async deliberately. First,
1335+
* the public return type is `RoktSelection | Promise<RoktSelection> |
1336+
* undefined` — a superset of the `RoktSelection | Promise<RoktSelection>`
1337+
* shape declared for `RoktLauncher.selectPlacements` above (line ~70).
1338+
* Marking this `async` would narrow it to `Promise<RoktSelection |
1339+
* undefined>` and silently change the contract for callers that read
1340+
* the result synchronously. Second, `RoktSelection` has an optional
1341+
* `then?` member, so TS treats it as ambiguously promise-like and
1342+
* rejects it as the awaited return of an async function (TS1058) —
1343+
* working around that would require a cast or wrapping every return in
1344+
* `Promise.resolve(...)`. The inner work runs in `_dispatchPlacements`;
1345+
* this wrapper just gates it on the in-flight search via `Promise.race`.
12161346
*/
12171347
public selectPlacements(options: Record<string, unknown>): RoktSelection | Promise<RoktSelection> | undefined {
1348+
if (this._workspaceSearchInFlightPromise) {
1349+
const inFlight = this._workspaceSearchInFlightPromise;
1350+
return Promise.race([
1351+
inFlight,
1352+
new Promise<void>((resolve) => setTimeout(resolve, WORKSPACE_SEARCH_SELECT_TIMEOUT_MS)),
1353+
]).then(() => this._dispatchPlacements(options)) as Promise<RoktSelection>;
1354+
}
1355+
return this._dispatchPlacements(options);
1356+
}
1357+
1358+
private _dispatchPlacements(options: Record<string, unknown>): RoktSelection | Promise<RoktSelection> | undefined {
12181359
const attributes = ((options && (options.attributes as Record<string, unknown>)) || {}) as Record<string, unknown>;
12191360
const placementAttributes: Record<string, unknown> = { ...this.userAttributes, ...attributes };
12201361

@@ -1247,6 +1388,7 @@ class RoktKit implements KitInterface {
12471388
...filteredAttributes,
12481389
...optimizelyAttributes,
12491390
...localSessionAttributes,
1391+
...(this.userIdentifiedInWorkspace ? { [USER_IDENTIFIED_IN_WORKSPACE_KEY]: true } : {}),
12501392
mpid,
12511393
};
12521394

0 commit comments

Comments
 (0)