@teardown/react-native 2.0.29 → 2.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -19
- package/docs/adapters/device/basic.mdx +7 -7
- package/docs/adapters/device/device-info.mdx +8 -8
- package/docs/adapters/device/expo.mdx +10 -10
- package/docs/adapters/device/index.mdx +51 -20
- package/docs/adapters/notifications/expo.mdx +64 -30
- package/docs/adapters/notifications/firebase.mdx +61 -27
- package/docs/adapters/notifications/index.mdx +120 -39
- package/docs/adapters/notifications/wix.mdx +61 -27
- package/docs/advanced.mdx +99 -3
- package/docs/api-reference.mdx +177 -12
- package/docs/core-concepts.mdx +31 -13
- package/docs/force-updates.mdx +18 -8
- package/docs/hooks-reference.mdx +9 -6
- package/docs/identity.mdx +46 -6
- package/docs/logging.mdx +5 -15
- package/package.json +5 -5
- package/src/clients/api/api.client.ts +4 -0
- package/src/clients/device/device.client.ts +9 -0
- package/src/clients/events/events.client.ts +102 -0
- package/src/clients/events/index.ts +1 -0
- package/src/clients/force-update/force-update.client.ts +27 -10
- package/src/clients/identity/identity.client.test.ts +2 -1
- package/src/clients/identity/identity.client.ts +274 -50
- package/src/clients/logging/logging.client.ts +0 -24
- package/src/exports/index.ts +1 -0
- package/src/hooks/use-force-update.ts +14 -0
- package/src/teardown.core.ts +42 -3
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { AsyncResult } from "@teardown/types";
|
|
2
|
+
import type { ApiClient } from "../api";
|
|
3
|
+
import type { DeviceClient } from "../device/device.client";
|
|
4
|
+
import type { Logger, LoggingClient } from "../logging";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Event payload for tracking events
|
|
8
|
+
*/
|
|
9
|
+
export interface EventPayload {
|
|
10
|
+
/** Name of the event (e.g., "user_signed_out", "button_clicked") */
|
|
11
|
+
event_name: string;
|
|
12
|
+
/** Type of event */
|
|
13
|
+
event_type?: "action" | "screen_view" | "custom";
|
|
14
|
+
/** Additional properties to attach to the event */
|
|
15
|
+
properties?: Record<string, unknown>;
|
|
16
|
+
/** ISO timestamp. Defaults to current time if not provided */
|
|
17
|
+
timestamp?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* EventsClient - Universal client for sending events to the backend
|
|
22
|
+
*
|
|
23
|
+
* This client provides a centralized way to track events from anywhere in the SDK.
|
|
24
|
+
* It automatically handles device ID retrieval and API communication.
|
|
25
|
+
*/
|
|
26
|
+
export class EventsClient {
|
|
27
|
+
private readonly logger: Logger;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
logging: LoggingClient,
|
|
31
|
+
private readonly api: ApiClient,
|
|
32
|
+
private readonly device: DeviceClient
|
|
33
|
+
) {
|
|
34
|
+
this.logger = logging.createLogger({
|
|
35
|
+
name: "EventsClient",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Track a single event
|
|
41
|
+
* @param event - The event payload to track
|
|
42
|
+
* @param sessionId - Optional session ID to associate with the event
|
|
43
|
+
* @returns AsyncResult indicating success/failure
|
|
44
|
+
*/
|
|
45
|
+
async track(event: EventPayload, sessionId?: string): AsyncResult<void> {
|
|
46
|
+
return this.trackBatch([event], sessionId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Track multiple events in a single batch
|
|
51
|
+
* @param events - Array of event payloads to track
|
|
52
|
+
* @param sessionId - Optional session ID to associate with all events
|
|
53
|
+
* @returns AsyncResult indicating success/failure
|
|
54
|
+
*/
|
|
55
|
+
async trackBatch(events: EventPayload[], sessionId?: string): AsyncResult<void> {
|
|
56
|
+
if (events.length === 0) {
|
|
57
|
+
return { success: true, data: undefined };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
this.logger.debug(`Tracking ${events.length} event(s)`, {
|
|
61
|
+
eventNames: events.map((e) => e.event_name),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const deviceId = await this.device.getDeviceId();
|
|
66
|
+
|
|
67
|
+
const response = await this.api.client("/v1/events", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"td-api-key": this.api.apiKey,
|
|
71
|
+
"td-org-id": this.api.orgId,
|
|
72
|
+
"td-project-id": this.api.projectId,
|
|
73
|
+
"td-environment-slug": this.api.environmentSlug,
|
|
74
|
+
"td-device-id": deviceId,
|
|
75
|
+
...(sessionId ? { "td-session-id": sessionId } : {}),
|
|
76
|
+
},
|
|
77
|
+
body: {
|
|
78
|
+
events: events.map((event) => ({
|
|
79
|
+
event_name: event.event_name,
|
|
80
|
+
event_type: event.event_type ?? "custom",
|
|
81
|
+
properties: event.properties,
|
|
82
|
+
timestamp: event.timestamp ?? new Date().toISOString(),
|
|
83
|
+
})),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (response.error != null) {
|
|
88
|
+
this.logger.warn("Failed to track events", { error: response.error });
|
|
89
|
+
return { success: false, error: "Failed to track events" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.logger.debug(`Successfully tracked ${events.length} event(s)`);
|
|
93
|
+
return { success: true, data: undefined };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
this.logger.error("Error tracking events", { error });
|
|
96
|
+
return {
|
|
97
|
+
success: false,
|
|
98
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { type EventPayload, EventsClient } from "./events.client";
|
|
@@ -32,9 +32,18 @@ export enum IdentifyVersionStatusEnum {
|
|
|
32
32
|
export const InitializingVersionStatusSchema = z.object({ type: z.literal("initializing") });
|
|
33
33
|
export const CheckingVersionStatusSchema = z.object({ type: z.literal("checking") });
|
|
34
34
|
export const UpToDateVersionStatusSchema = z.object({ type: z.literal("up_to_date") });
|
|
35
|
-
export const UpdateAvailableVersionStatusSchema = z.object({
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
export const UpdateAvailableVersionStatusSchema = z.object({
|
|
36
|
+
type: z.literal("update_available"),
|
|
37
|
+
releaseNotes: z.string().nullable().optional(),
|
|
38
|
+
});
|
|
39
|
+
export const UpdateRecommendedVersionStatusSchema = z.object({
|
|
40
|
+
type: z.literal("update_recommended"),
|
|
41
|
+
releaseNotes: z.string().nullable().optional(),
|
|
42
|
+
});
|
|
43
|
+
export const UpdateRequiredVersionStatusSchema = z.object({
|
|
44
|
+
type: z.literal("update_required"),
|
|
45
|
+
releaseNotes: z.string().nullable().optional(),
|
|
46
|
+
});
|
|
38
47
|
export const DisabledVersionStatusSchema = z.object({ type: z.literal("disabled") });
|
|
39
48
|
/**
|
|
40
49
|
* The version status schema.
|
|
@@ -143,7 +152,7 @@ export class ForceUpdateClient {
|
|
|
143
152
|
this.logger.debug(
|
|
144
153
|
`Identity already identified, syncing version status from: ${currentState.version_info.status}`
|
|
145
154
|
);
|
|
146
|
-
this.
|
|
155
|
+
this.updateFromVersionInfo(currentState.version_info);
|
|
147
156
|
} else {
|
|
148
157
|
this.logger.debug(`Identity not yet identified (${currentState.type}), waiting for identify event`);
|
|
149
158
|
}
|
|
@@ -171,7 +180,7 @@ export class ForceUpdateClient {
|
|
|
171
180
|
|
|
172
181
|
return parsed;
|
|
173
182
|
} catch (error) {
|
|
174
|
-
this.logger.
|
|
183
|
+
this.logger.error("Error getting version status from storage", { error });
|
|
175
184
|
return InitializingVersionStatusSchema.parse({ type: "initializing" });
|
|
176
185
|
}
|
|
177
186
|
}
|
|
@@ -189,13 +198,19 @@ export class ForceUpdateClient {
|
|
|
189
198
|
break;
|
|
190
199
|
case "identified":
|
|
191
200
|
this.logger.debug(`Identified with version_info.status: ${state.version_info.status}`);
|
|
192
|
-
this.
|
|
201
|
+
this.updateFromVersionInfo(state.version_info);
|
|
193
202
|
break;
|
|
194
203
|
}
|
|
195
204
|
});
|
|
196
205
|
}
|
|
197
206
|
|
|
198
|
-
private
|
|
207
|
+
private updateFromVersionInfo(versionInfo: {
|
|
208
|
+
status: IdentifyVersionStatusEnum;
|
|
209
|
+
update: { release_notes: string | null } | null;
|
|
210
|
+
}) {
|
|
211
|
+
const status = versionInfo.status;
|
|
212
|
+
const releaseNotes = versionInfo.update?.release_notes ?? null;
|
|
213
|
+
|
|
199
214
|
if (!status) {
|
|
200
215
|
this.setVersionStatus({ type: "up_to_date" });
|
|
201
216
|
return;
|
|
@@ -203,13 +218,15 @@ export class ForceUpdateClient {
|
|
|
203
218
|
|
|
204
219
|
switch (status) {
|
|
205
220
|
case "UPDATE_AVAILABLE":
|
|
206
|
-
this.setVersionStatus({ type: "update_available" });
|
|
221
|
+
this.setVersionStatus(releaseNotes ? { type: "update_available", releaseNotes } : { type: "update_available" });
|
|
207
222
|
break;
|
|
208
223
|
case "UPDATE_RECOMMENDED":
|
|
209
|
-
this.setVersionStatus(
|
|
224
|
+
this.setVersionStatus(
|
|
225
|
+
releaseNotes ? { type: "update_recommended", releaseNotes } : { type: "update_recommended" }
|
|
226
|
+
);
|
|
210
227
|
break;
|
|
211
228
|
case "UPDATE_REQUIRED":
|
|
212
|
-
this.setVersionStatus({ type: "update_required" });
|
|
229
|
+
this.setVersionStatus(releaseNotes ? { type: "update_required", releaseNotes } : { type: "update_required" });
|
|
213
230
|
break;
|
|
214
231
|
case "UP_TO_DATE":
|
|
215
232
|
this.setVersionStatus({ type: "up_to_date" });
|
|
@@ -127,6 +127,7 @@ function createMockApiClient(
|
|
|
127
127
|
apiKey: "test-api-key",
|
|
128
128
|
orgId: "test-org-id",
|
|
129
129
|
projectId: "test-project-id",
|
|
130
|
+
environmentSlug: "production",
|
|
130
131
|
client: async (endpoint: string, config: ApiCallRecord["config"]) => {
|
|
131
132
|
calls.push({ endpoint, config });
|
|
132
133
|
|
|
@@ -635,7 +636,7 @@ describe("IdentityClient", () => {
|
|
|
635
636
|
|
|
636
637
|
// Should have debug logs about state transitions
|
|
637
638
|
// When already identified, identify() will transition: identified -> identifying -> identified
|
|
638
|
-
const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug" || l.level === "
|
|
639
|
+
const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug" || l.level === "info");
|
|
639
640
|
expect(debugLogs.length).toBeGreaterThan(0);
|
|
640
641
|
// Check that state transitions are logged
|
|
641
642
|
expect(debugLogs.some((l) => l.message.includes("Identify state"))).toBe(true);
|
|
@@ -2,9 +2,11 @@ import type { AsyncResult } from "@teardown/types";
|
|
|
2
2
|
import { EventEmitter } from "eventemitter3";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import type { ApiClient } from "../api";
|
|
5
|
-
import type { DeviceClient } from "../device/device.client";
|
|
5
|
+
import type { DeviceClient, NotificationPlatformEnum } from "../device/device.client";
|
|
6
|
+
import type { EventsClient } from "../events";
|
|
6
7
|
import { IdentifyVersionStatusEnum } from "../force-update";
|
|
7
8
|
import type { Logger, LoggingClient } from "../logging";
|
|
9
|
+
import type { NotificationsClient } from "../notifications/notifications.client";
|
|
8
10
|
import type { StorageClient, SupportedStorage } from "../storage";
|
|
9
11
|
import type { UtilsClient } from "../utils";
|
|
10
12
|
|
|
@@ -14,6 +16,26 @@ export interface Persona {
|
|
|
14
16
|
email?: string | undefined;
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
export interface SignOutOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Additional properties to include in the sign out event
|
|
22
|
+
*/
|
|
23
|
+
properties?: Record<string, unknown>;
|
|
24
|
+
/**
|
|
25
|
+
* Whether to wait for the event to be sent before clearing state.
|
|
26
|
+
* Default: true (fire-and-forget if false)
|
|
27
|
+
*/
|
|
28
|
+
waitForEvent?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UpdateInfo {
|
|
32
|
+
version: string;
|
|
33
|
+
build: string;
|
|
34
|
+
update_id: string;
|
|
35
|
+
effective_date: Date;
|
|
36
|
+
release_notes: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
export interface IdentityUser {
|
|
18
40
|
session_id: string;
|
|
19
41
|
device_id: string;
|
|
@@ -21,7 +43,7 @@ export interface IdentityUser {
|
|
|
21
43
|
token: string;
|
|
22
44
|
version_info: {
|
|
23
45
|
status: IdentifyVersionStatusEnum;
|
|
24
|
-
update: null;
|
|
46
|
+
update: UpdateInfo | null;
|
|
25
47
|
};
|
|
26
48
|
}
|
|
27
49
|
|
|
@@ -49,13 +71,50 @@ export const VersionStatusResponseSchema = z.object({
|
|
|
49
71
|
latest_version: z.string().optional(),
|
|
50
72
|
});
|
|
51
73
|
|
|
74
|
+
export const UpdateInfoSchema = z.object({
|
|
75
|
+
version: z.string(),
|
|
76
|
+
build: z.string(),
|
|
77
|
+
update_id: z.string(),
|
|
78
|
+
effective_date: z.coerce.date(),
|
|
79
|
+
release_notes: z.string().nullable(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/** Type guard to check if update has nested structure */
|
|
83
|
+
function isNestedUpdate(update: unknown): update is { status: string; update: UpdateInfo } {
|
|
84
|
+
return typeof update === "object" && update !== null && "status" in update && "update" in update;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Accept either the nested schema from API or the flat UpdateInfo schema
|
|
88
|
+
export const VersionInfoResponseSchema = z.object({
|
|
89
|
+
status: z.enum(IdentifyVersionStatusEnum),
|
|
90
|
+
update: z.union([
|
|
91
|
+
z.object({
|
|
92
|
+
status: z.enum(IdentifyVersionStatusEnum),
|
|
93
|
+
update: UpdateInfoSchema,
|
|
94
|
+
}),
|
|
95
|
+
UpdateInfoSchema,
|
|
96
|
+
z.null(),
|
|
97
|
+
]),
|
|
98
|
+
});
|
|
99
|
+
|
|
52
100
|
export const IdentifiedSessionStateSchema = z.object({
|
|
53
101
|
type: z.literal("identified"),
|
|
54
102
|
session: SessionSchema,
|
|
55
|
-
version_info: z
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
103
|
+
version_info: z
|
|
104
|
+
.object({
|
|
105
|
+
status: z.enum(IdentifyVersionStatusEnum),
|
|
106
|
+
update: UpdateInfoSchema.nullable(),
|
|
107
|
+
})
|
|
108
|
+
.transform((data) => {
|
|
109
|
+
// Flatten nested structure if present
|
|
110
|
+
if (isNestedUpdate(data.update)) {
|
|
111
|
+
return {
|
|
112
|
+
status: data.status,
|
|
113
|
+
update: data.update.update,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return data;
|
|
117
|
+
}),
|
|
59
118
|
});
|
|
60
119
|
|
|
61
120
|
export const IdentifyStateSchema = z.discriminatedUnion("type", [
|
|
@@ -89,7 +148,9 @@ export class IdentityClient {
|
|
|
89
148
|
utils: UtilsClient,
|
|
90
149
|
storage: StorageClient,
|
|
91
150
|
private readonly api: ApiClient,
|
|
92
|
-
private readonly device: DeviceClient
|
|
151
|
+
private readonly device: DeviceClient,
|
|
152
|
+
private readonly events: EventsClient,
|
|
153
|
+
private readonly notificationsClient?: NotificationsClient
|
|
93
154
|
) {
|
|
94
155
|
this.logger = logging.createLogger({
|
|
95
156
|
name: "IdentityClient",
|
|
@@ -113,7 +174,7 @@ export class IdentityClient {
|
|
|
113
174
|
this.logger.debug(`Initialized with state: ${this.identifyState.type}`);
|
|
114
175
|
} catch (error) {
|
|
115
176
|
// Silently fail on errors - we'll re-identify on app boot if needed
|
|
116
|
-
this.logger.
|
|
177
|
+
this.logger.error("Error initializing IdentityClient", { error });
|
|
117
178
|
this.identifyState = { type: "unidentified" };
|
|
118
179
|
}
|
|
119
180
|
|
|
@@ -126,17 +187,17 @@ export class IdentityClient {
|
|
|
126
187
|
const stored = this.storage.getItem(IDENTIFY_STORAGE_KEY);
|
|
127
188
|
|
|
128
189
|
if (stored == null) {
|
|
129
|
-
this.logger.
|
|
190
|
+
this.logger.info("No stored identity state, returning unidentified");
|
|
130
191
|
return UnidentifiedSessionStateSchema.parse({ type: "unidentified" });
|
|
131
192
|
}
|
|
132
193
|
|
|
133
194
|
const parsed = IdentifyStateSchema.parse(JSON.parse(stored));
|
|
134
|
-
this.logger.
|
|
195
|
+
this.logger.info(`Parsed identity state from storage: ${parsed.type}`);
|
|
135
196
|
|
|
136
197
|
// "identifying" is a transient state - if we restore it, treat as unidentified
|
|
137
198
|
// This can happen if the app was killed during an identify call
|
|
138
199
|
if (parsed.type === "identifying") {
|
|
139
|
-
this.logger.
|
|
200
|
+
this.logger.info("Found stale 'identifying' state in storage, resetting to unidentified");
|
|
140
201
|
// Clear the stale state from storage immediately
|
|
141
202
|
this.storage.removeItem(IDENTIFY_STORAGE_KEY);
|
|
142
203
|
return UnidentifiedSessionStateSchema.parse({ type: "unidentified" });
|
|
@@ -144,7 +205,7 @@ export class IdentityClient {
|
|
|
144
205
|
|
|
145
206
|
return parsed;
|
|
146
207
|
} catch (error) {
|
|
147
|
-
this.logger.
|
|
208
|
+
this.logger.error("Error getting identify state from storage", { error });
|
|
148
209
|
return { type: "unidentified" };
|
|
149
210
|
}
|
|
150
211
|
}
|
|
@@ -155,11 +216,11 @@ export class IdentityClient {
|
|
|
155
216
|
|
|
156
217
|
private setIdentifyState(newState: IdentifyState): void {
|
|
157
218
|
if (this.identifyState.type === newState.type) {
|
|
158
|
-
this.logger.
|
|
219
|
+
this.logger.info(`Identify state already set: ${this.identifyState.type}`);
|
|
159
220
|
return;
|
|
160
221
|
}
|
|
161
222
|
|
|
162
|
-
this.logger.
|
|
223
|
+
this.logger.info(`Identify state: ${this.identifyState.type} -> ${newState.type}`);
|
|
163
224
|
this.identifyState = newState;
|
|
164
225
|
this.saveIdentifyStateToStorage(newState);
|
|
165
226
|
this.emitter.emit("IDENTIFY_STATE_CHANGED", newState);
|
|
@@ -187,11 +248,109 @@ export class IdentityClient {
|
|
|
187
248
|
this.emitter.removeAllListeners("IDENTIFY_STATE_CHANGED");
|
|
188
249
|
}
|
|
189
250
|
|
|
190
|
-
|
|
251
|
+
/**
|
|
252
|
+
* Reset identity state to unidentified.
|
|
253
|
+
* Clears stored state and emits state change.
|
|
254
|
+
*/
|
|
255
|
+
public reset(): void {
|
|
256
|
+
this.setIdentifyState({ type: "unidentified" });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Internal method to clear identity state.
|
|
261
|
+
* Used by signOut() and signOutAll().
|
|
262
|
+
*/
|
|
263
|
+
private clearIdentityState(): void {
|
|
191
264
|
this.storage.removeItem(IDENTIFY_STORAGE_KEY);
|
|
192
265
|
this.setIdentifyState({ type: "unidentified" });
|
|
193
266
|
}
|
|
194
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Sign out the current user.
|
|
270
|
+
* Sends a sign_out event to the backend and clears session/user identity.
|
|
271
|
+
* The deviceId is preserved - the same device will be recognized on next identify.
|
|
272
|
+
*
|
|
273
|
+
* @param options - Optional configuration for the sign out
|
|
274
|
+
* @returns AsyncResult indicating success/failure of the event send
|
|
275
|
+
*/
|
|
276
|
+
async signOut(options?: SignOutOptions): AsyncResult<void> {
|
|
277
|
+
this.logger.info("Signing out user");
|
|
278
|
+
|
|
279
|
+
const session = this.getSessionState();
|
|
280
|
+
const waitForEvent = options?.waitForEvent ?? true;
|
|
281
|
+
|
|
282
|
+
// Send event FIRST while session/device data still exists
|
|
283
|
+
const eventPromise = this.events.track(
|
|
284
|
+
{
|
|
285
|
+
event_name: "user_signed_out",
|
|
286
|
+
event_type: "action",
|
|
287
|
+
properties: {
|
|
288
|
+
sign_out_type: "sign_out",
|
|
289
|
+
session_id: session?.session_id,
|
|
290
|
+
user_id: session?.user_id,
|
|
291
|
+
...options?.properties,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
session?.session_id
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
if (waitForEvent) {
|
|
298
|
+
const result = await eventPromise;
|
|
299
|
+
// Clear state regardless of event send result
|
|
300
|
+
this.clearIdentityState();
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Fire-and-forget mode - don't await but still clear state
|
|
305
|
+
void eventPromise;
|
|
306
|
+
this.clearIdentityState();
|
|
307
|
+
return { success: true, data: undefined };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Sign out and reset all state including deviceId.
|
|
312
|
+
* Sends a sign_out_all event to the backend and clears all SDK state.
|
|
313
|
+
* The device will appear as a fresh install on next identify.
|
|
314
|
+
*
|
|
315
|
+
* @param options - Optional configuration for the sign out
|
|
316
|
+
* @returns AsyncResult indicating success/failure of the event send
|
|
317
|
+
*/
|
|
318
|
+
async signOutAll(options?: SignOutOptions): AsyncResult<void> {
|
|
319
|
+
this.logger.info("Signing out all - full reset");
|
|
320
|
+
|
|
321
|
+
const session = this.getSessionState();
|
|
322
|
+
const waitForEvent = options?.waitForEvent ?? true;
|
|
323
|
+
|
|
324
|
+
// Send event FIRST while session/device data still exists
|
|
325
|
+
const eventPromise = this.events.track(
|
|
326
|
+
{
|
|
327
|
+
event_name: "user_signed_out",
|
|
328
|
+
event_type: "action",
|
|
329
|
+
properties: {
|
|
330
|
+
sign_out_type: "sign_out_all",
|
|
331
|
+
session_id: session?.session_id,
|
|
332
|
+
user_id: session?.user_id,
|
|
333
|
+
...options?.properties,
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
session?.session_id
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
if (waitForEvent) {
|
|
340
|
+
const result = await eventPromise;
|
|
341
|
+
// Clear all state regardless of event send result
|
|
342
|
+
this.clearIdentityState();
|
|
343
|
+
this.device.reset();
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Fire-and-forget mode - don't await but still clear state
|
|
348
|
+
void eventPromise;
|
|
349
|
+
this.clearIdentityState();
|
|
350
|
+
this.device.reset();
|
|
351
|
+
return { success: true, data: undefined };
|
|
352
|
+
}
|
|
353
|
+
|
|
195
354
|
/**
|
|
196
355
|
* Re-identify the current persona to refresh session data.
|
|
197
356
|
* Only works if already identified.
|
|
@@ -223,8 +382,84 @@ export class IdentityClient {
|
|
|
223
382
|
}
|
|
224
383
|
}
|
|
225
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Extract error message from API validation error response (422)
|
|
387
|
+
*/
|
|
388
|
+
private extractValidationErrorMessage(errorValue: unknown): string {
|
|
389
|
+
if (typeof errorValue === "string") return errorValue;
|
|
390
|
+
if (typeof errorValue === "object" && errorValue !== null) {
|
|
391
|
+
const obj = errorValue as { summary?: string; message?: string };
|
|
392
|
+
if (obj.summary) return obj.summary;
|
|
393
|
+
if (obj.message) return obj.message;
|
|
394
|
+
}
|
|
395
|
+
return "Unknown error";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Extract error message from API error response (non-422)
|
|
400
|
+
*/
|
|
401
|
+
private extractErrorMessage(errorValue: unknown): string {
|
|
402
|
+
if (typeof errorValue === "string") return errorValue;
|
|
403
|
+
if (typeof errorValue === "object" && errorValue !== null) {
|
|
404
|
+
const obj = errorValue as { code?: string; message?: string };
|
|
405
|
+
if (obj.message) return obj.message;
|
|
406
|
+
}
|
|
407
|
+
return "Unknown error";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Get push notification info if notifications client is configured
|
|
412
|
+
*/
|
|
413
|
+
private async getNotificationsInfo(): Promise<
|
|
414
|
+
| {
|
|
415
|
+
push: {
|
|
416
|
+
enabled: boolean;
|
|
417
|
+
granted: boolean;
|
|
418
|
+
token: string | null;
|
|
419
|
+
platform: NotificationPlatformEnum;
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
| undefined
|
|
423
|
+
> {
|
|
424
|
+
if (!this.notificationsClient) return undefined;
|
|
425
|
+
|
|
426
|
+
this.logger.debug("Getting push notification token...");
|
|
427
|
+
const token = await this.notificationsClient.getToken();
|
|
428
|
+
this.logger.debug(`Push token retrieved: ${token ? "yes" : "no"}, platform: ${this.notificationsClient.platform}`);
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
push: {
|
|
432
|
+
enabled: true,
|
|
433
|
+
granted: token !== null,
|
|
434
|
+
token,
|
|
435
|
+
platform: this.notificationsClient.platform,
|
|
436
|
+
},
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Flatten nested version_info structure from API response
|
|
442
|
+
*/
|
|
443
|
+
private flattenVersionInfo(rawVersionInfo: { status: string; update: unknown }): {
|
|
444
|
+
status: IdentifyVersionStatusEnum;
|
|
445
|
+
update: UpdateInfo | null;
|
|
446
|
+
} {
|
|
447
|
+
let flattenedUpdate: UpdateInfo | null = null;
|
|
448
|
+
|
|
449
|
+
if (rawVersionInfo.update) {
|
|
450
|
+
flattenedUpdate = isNestedUpdate(rawVersionInfo.update)
|
|
451
|
+
? rawVersionInfo.update.update
|
|
452
|
+
: (rawVersionInfo.update as UpdateInfo);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
status: rawVersionInfo.status as IdentifyVersionStatusEnum,
|
|
457
|
+
update: flattenedUpdate,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
226
461
|
async identify(user?: Persona): AsyncResult<IdentityUser> {
|
|
227
|
-
this.logger.
|
|
462
|
+
this.logger.info(`Identifying user with persona: ${user?.name ?? "none"}`);
|
|
228
463
|
const previousState = this.identifyState;
|
|
229
464
|
this.setIdentifyState({ type: "identifying" });
|
|
230
465
|
|
|
@@ -233,6 +468,8 @@ export class IdentityClient {
|
|
|
233
468
|
this.logger.debug("Getting device ID...");
|
|
234
469
|
const deviceId = await this.device.getDeviceId();
|
|
235
470
|
const deviceInfo = await this.device.getDeviceInfo();
|
|
471
|
+
const notificationsInfo = await this.getNotificationsInfo();
|
|
472
|
+
|
|
236
473
|
this.logger.debug("Calling identify API...");
|
|
237
474
|
const response = await this.api.client("/v1/identify", {
|
|
238
475
|
method: "POST",
|
|
@@ -240,7 +477,7 @@ export class IdentityClient {
|
|
|
240
477
|
"td-api-key": this.api.apiKey,
|
|
241
478
|
"td-org-id": this.api.orgId,
|
|
242
479
|
"td-project-id": this.api.projectId,
|
|
243
|
-
"td-environment-slug":
|
|
480
|
+
"td-environment-slug": this.api.environmentSlug,
|
|
244
481
|
"td-device-id": deviceId,
|
|
245
482
|
},
|
|
246
483
|
body: {
|
|
@@ -251,57 +488,44 @@ export class IdentityClient {
|
|
|
251
488
|
application: deviceInfo.application,
|
|
252
489
|
hardware: deviceInfo.hardware,
|
|
253
490
|
update: null,
|
|
491
|
+
notifications: notificationsInfo,
|
|
254
492
|
},
|
|
255
493
|
},
|
|
256
494
|
});
|
|
257
495
|
|
|
258
|
-
this.logger.
|
|
496
|
+
this.logger.info(`Identify API response received`);
|
|
259
497
|
if (response.error != null) {
|
|
260
498
|
this.logger.warn("Identify API error", response.error.status, response.error.value);
|
|
261
499
|
this.setIdentifyState(previousState);
|
|
262
500
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const value = response.error.value;
|
|
272
|
-
return {
|
|
273
|
-
success: false,
|
|
274
|
-
error: value?.error?.message ?? "Unknown error",
|
|
275
|
-
};
|
|
501
|
+
const errorMessage =
|
|
502
|
+
response.error.status === 422
|
|
503
|
+
? this.extractValidationErrorMessage(response.error.value)
|
|
504
|
+
: this.extractErrorMessage(response.error.value);
|
|
505
|
+
|
|
506
|
+
return { success: false, error: errorMessage };
|
|
276
507
|
}
|
|
277
508
|
|
|
509
|
+
const parsedSession = SessionSchema.parse(response.data.data);
|
|
510
|
+
const flattenedVersionInfo = this.flattenVersionInfo(response.data.data.version_info);
|
|
511
|
+
|
|
512
|
+
const identityUser: IdentityUser = {
|
|
513
|
+
...parsedSession,
|
|
514
|
+
version_info: flattenedVersionInfo,
|
|
515
|
+
};
|
|
516
|
+
|
|
278
517
|
this.setIdentifyState({
|
|
279
518
|
type: "identified",
|
|
280
|
-
session:
|
|
281
|
-
version_info:
|
|
282
|
-
status: response.data.data.version_info.status,
|
|
283
|
-
update: null,
|
|
284
|
-
},
|
|
519
|
+
session: parsedSession,
|
|
520
|
+
version_info: flattenedVersionInfo,
|
|
285
521
|
});
|
|
286
522
|
|
|
287
|
-
return {
|
|
288
|
-
success: true,
|
|
289
|
-
data: {
|
|
290
|
-
...response.data.data,
|
|
291
|
-
version_info: {
|
|
292
|
-
status: response.data.data.version_info.status,
|
|
293
|
-
update: null,
|
|
294
|
-
},
|
|
295
|
-
},
|
|
296
|
-
};
|
|
523
|
+
return { success: true, data: identityUser };
|
|
297
524
|
},
|
|
298
525
|
(error) => {
|
|
299
526
|
this.logger.error("Error identifying user", error);
|
|
300
527
|
this.setIdentifyState(previousState);
|
|
301
|
-
return {
|
|
302
|
-
success: false,
|
|
303
|
-
error: error.message,
|
|
304
|
-
};
|
|
528
|
+
return { success: false, error: error.message };
|
|
305
529
|
}
|
|
306
530
|
);
|
|
307
531
|
}
|
|
@@ -57,10 +57,6 @@ export class Logger {
|
|
|
57
57
|
warn: console.warn.bind(console),
|
|
58
58
|
trace: console.trace.bind(console),
|
|
59
59
|
debug: console.debug.bind(console),
|
|
60
|
-
debugError: console.debug.bind(console, "Error: "),
|
|
61
|
-
debugWarn: console.debug.bind(console, "Warning: "),
|
|
62
|
-
debugInfo: console.debug.bind(console, "Info: "),
|
|
63
|
-
debugVerbose: console.debug.bind(console, "Verbose: "),
|
|
64
60
|
};
|
|
65
61
|
|
|
66
62
|
constructor(private readonly options: LoggerOptions) {}
|
|
@@ -93,24 +89,4 @@ export class Logger {
|
|
|
93
89
|
if (!this.options.loggingClient.shouldLog("verbose")) return;
|
|
94
90
|
this.boundConsole.debug(`${this.prefix} ${message}`, ...args);
|
|
95
91
|
}
|
|
96
|
-
|
|
97
|
-
debugError(message: string, ...args: unknown[]) {
|
|
98
|
-
if (!this.options.loggingClient.shouldLog("verbose")) return;
|
|
99
|
-
this.boundConsole.debugError(`${this.prefix} ${message}`, ...args);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
debugWarn(message: string, ...args: unknown[]) {
|
|
103
|
-
if (!this.options.loggingClient.shouldLog("verbose")) return;
|
|
104
|
-
this.boundConsole.debugWarn(`${this.prefix} ${message}`, ...args);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
debugInfo(message: string, ...args: unknown[]) {
|
|
108
|
-
if (!this.options.loggingClient.shouldLog("verbose")) return;
|
|
109
|
-
this.boundConsole.debugInfo(`${this.prefix} ${message}`, ...args);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
debugVerbose(message: string, ...args: unknown[]) {
|
|
113
|
-
if (!this.options.loggingClient.shouldLog("verbose")) return;
|
|
114
|
-
this.boundConsole.debugVerbose(`${this.prefix} ${message}`, ...args);
|
|
115
|
-
}
|
|
116
92
|
}
|