@joryio/web-sdk 0.0.0-stage → 1.3.0-beta.1

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +54 -0
  3. package/README.md +497 -2
  4. package/dist/core/config.d.ts +101 -0
  5. package/dist/core/config.d.ts.map +1 -0
  6. package/dist/core/device.d.ts +14 -0
  7. package/dist/core/device.d.ts.map +1 -0
  8. package/dist/core/ecommerce.d.ts +67 -0
  9. package/dist/core/ecommerce.d.ts.map +1 -0
  10. package/dist/core/identity.d.ts +36 -0
  11. package/dist/core/identity.d.ts.map +1 -0
  12. package/dist/core/inapp.d.ts +169 -0
  13. package/dist/core/inapp.d.ts.map +1 -0
  14. package/dist/core/network.d.ts +158 -0
  15. package/dist/core/network.d.ts.map +1 -0
  16. package/dist/core/outbox.d.ts +79 -0
  17. package/dist/core/outbox.d.ts.map +1 -0
  18. package/dist/core/pending-attributes.d.ts +43 -0
  19. package/dist/core/pending-attributes.d.ts.map +1 -0
  20. package/dist/core/push.d.ts +59 -0
  21. package/dist/core/push.d.ts.map +1 -0
  22. package/dist/core/queue.d.ts +107 -0
  23. package/dist/core/queue.d.ts.map +1 -0
  24. package/dist/core/sdk.d.ts +169 -0
  25. package/dist/core/sdk.d.ts.map +1 -0
  26. package/dist/core/session.d.ts +36 -0
  27. package/dist/core/session.d.ts.map +1 -0
  28. package/dist/core/storage.d.ts +17 -0
  29. package/dist/core/storage.d.ts.map +1 -0
  30. package/dist/core/user.d.ts +28 -0
  31. package/dist/core/user.d.ts.map +1 -0
  32. package/dist/core/utm.d.ts +39 -0
  33. package/dist/core/utm.d.ts.map +1 -0
  34. package/dist/core/values.d.ts +3 -0
  35. package/dist/core/values.d.ts.map +1 -0
  36. package/dist/core/write-retry.d.ts +66 -0
  37. package/dist/core/write-retry.d.ts.map +1 -0
  38. package/dist/index.d.mts +711 -0
  39. package/dist/index.d.ts +21 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/joryio-sw.js +6 -0
  42. package/dist/joryio.cjs.js +7265 -0
  43. package/dist/joryio.min.js +7 -0
  44. package/dist/joryio.min.js.map +1 -0
  45. package/dist/joryio.mjs +7254 -0
  46. package/dist/joryio.umd.js +7271 -0
  47. package/dist/utils/bucket-hash.d.ts +2 -0
  48. package/dist/utils/bucket-hash.d.ts.map +1 -0
  49. package/dist/utils/logger.d.ts +14 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/uuid.d.ts +3 -0
  52. package/dist/utils/uuid.d.ts.map +1 -0
  53. package/package.json +90 -4
  54. package/src/core/config.ts +362 -0
  55. package/src/core/device.ts +50 -0
  56. package/src/core/ecommerce.ts +482 -0
  57. package/src/core/identity.ts +251 -0
  58. package/src/core/inapp.ts +2419 -0
  59. package/src/core/network.ts +1261 -0
  60. package/src/core/outbox.ts +707 -0
  61. package/src/core/pending-attributes.ts +350 -0
  62. package/src/core/push.ts +667 -0
  63. package/src/core/queue.ts +894 -0
  64. package/src/core/sdk.ts +3108 -0
  65. package/src/core/session.ts +311 -0
  66. package/src/core/storage.ts +157 -0
  67. package/src/core/user.ts +203 -0
  68. package/src/core/utm.ts +304 -0
  69. package/src/core/values.ts +85 -0
  70. package/src/core/write-retry.ts +136 -0
  71. package/src/index.ts +305 -0
  72. package/src/snippet/loader.js +134 -0
  73. package/src/utils/bucket-hash.ts +44 -0
  74. package/src/utils/logger.ts +58 -0
  75. package/src/utils/uuid.ts +29 -0
  76. package/src/worker/joryio-sw.js +218 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Session Manager for tracking user sessions
3
+ */
4
+
5
+ import { StorageManager } from './storage';
6
+ import { Logger } from '../utils/logger';
7
+ import { generateShortId } from '../utils/uuid';
8
+
9
+ export const SESSION_ID_KEY = 'session_id';
10
+ const SESSION_START_KEY = 'session_start';
11
+ const LAST_ACTIVITY_KEY = 'last_activity';
12
+ /**
13
+ * How often the last-activity stamp is actually written to storage. It only
14
+ * feeds a 30-minute timeout decision, so a 30s resolution is far more precision
15
+ * than the decision needs — see updateActivity().
16
+ */
17
+ const ACTIVITY_PERSIST_INTERVAL_MS = 30_000;
18
+ const DEFAULT_SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
19
+ const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours max session age
20
+
21
+ export interface SessionInfo {
22
+ sessionId: string;
23
+ sessionStart: number;
24
+ lastActivity: number;
25
+ isNewSession: boolean;
26
+ }
27
+
28
+ export class SessionManager {
29
+ private storage: StorageManager;
30
+ private logger: Logger;
31
+ private sessionTimeoutMs: number;
32
+ private currentSessionId: string | null = null;
33
+ /** Exact in-memory activity time; the persisted copy is throttled. */
34
+ private lastActivityAt = 0;
35
+ private lastActivityPersistedAt = 0;
36
+ private isNewSession: boolean = false;
37
+ private onNewSessionCallback: (() => void) | null = null;
38
+ private boundVisibilityHandler: (() => void) | null = null;
39
+
40
+ constructor(storage: StorageManager, logger: Logger, sessionTimeoutMs?: number) {
41
+ this.storage = storage;
42
+ this.logger = logger;
43
+ this.sessionTimeoutMs = sessionTimeoutMs || DEFAULT_SESSION_TIMEOUT_MS;
44
+
45
+ // Initialize session on construction
46
+ this.initializeSession();
47
+
48
+ // Listen for visibility changes to check session when tab becomes visible
49
+ if (typeof document !== 'undefined') {
50
+ this.boundVisibilityHandler = () => {
51
+ if (document.visibilityState === 'visible') {
52
+ this.checkAndRefreshSession();
53
+ }
54
+ };
55
+ document.addEventListener('visibilitychange', this.boundVisibilityHandler);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Set callback to be called when a new session is created (e.g., to track session start)
61
+ */
62
+ onNewSession(callback: () => void): void {
63
+ this.onNewSessionCallback = callback;
64
+ }
65
+
66
+ /**
67
+ * Initialize or resume session
68
+ */
69
+ private initializeSession(): void {
70
+ const existingSessionId = this.storage.get<string>(SESSION_ID_KEY);
71
+ const lastActivity = this.storage.get<number>(LAST_ACTIVITY_KEY);
72
+ const sessionStart = this.storage.get<number>(SESSION_START_KEY);
73
+
74
+ if (existingSessionId && lastActivity) {
75
+ const now = Date.now();
76
+ const timeSinceLastActivity = now - lastActivity;
77
+ const sessionAge = sessionStart ? now - sessionStart : Infinity;
78
+
79
+ // Check both inactivity timeout AND max session age
80
+ // This prevents sessions from being kept alive indefinitely by background tabs
81
+ if (timeSinceLastActivity < this.sessionTimeoutMs && sessionAge < MAX_SESSION_AGE_MS) {
82
+ // Resume existing session
83
+ this.currentSessionId = existingSessionId;
84
+ this.isNewSession = false;
85
+ this.updateActivity();
86
+ this.logger.debug(`Resumed session: ${this.currentSessionId}`);
87
+ return;
88
+ } else if (sessionAge >= MAX_SESSION_AGE_MS) {
89
+ this.logger.debug(`Session too old (${Math.round(sessionAge / 1000 / 60 / 60)}h), creating new session`);
90
+ } else {
91
+ this.logger.debug(`Session expired (${timeSinceLastActivity}ms since last activity)`);
92
+ }
93
+ }
94
+
95
+ // Create new session
96
+ this.startNewSession();
97
+ }
98
+
99
+ /**
100
+ * Start a new session
101
+ */
102
+ private startNewSession(): void {
103
+ const previousSessionId = this.currentSessionId;
104
+ this.currentSessionId = generateShortId();
105
+ this.isNewSession = true;
106
+ const now = Date.now();
107
+
108
+ this.storage.set(SESSION_ID_KEY, this.currentSessionId);
109
+ this.storage.set(SESSION_START_KEY, now);
110
+ this.storage.set(LAST_ACTIVITY_KEY, now);
111
+ this.lastActivityAt = now;
112
+ this.lastActivityPersistedAt = now;
113
+
114
+ this.logger.debug(`Started new session: ${this.currentSessionId}`);
115
+
116
+ // Call the callback if this is a session refresh (not initial creation)
117
+ if (previousSessionId && this.onNewSessionCallback) {
118
+ this.onNewSessionCallback();
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check session validity and refresh if needed (called when tab becomes visible)
124
+ */
125
+ private checkAndRefreshSession(): void {
126
+ const lastActivity = this.storage.get<number>(LAST_ACTIVITY_KEY);
127
+ const sessionStart = this.storage.get<number>(SESSION_START_KEY);
128
+
129
+ if (lastActivity && sessionStart) {
130
+ const now = Date.now();
131
+ const timeSinceLastActivity = now - lastActivity;
132
+ const sessionAge = now - sessionStart;
133
+
134
+ // Check if session should expire
135
+ if (timeSinceLastActivity >= this.sessionTimeoutMs) {
136
+ this.logger.debug(`Session expired on tab focus (${Math.round(timeSinceLastActivity / 1000 / 60)} min inactive)`);
137
+ this.startNewSession();
138
+ } else if (sessionAge >= MAX_SESSION_AGE_MS) {
139
+ this.logger.debug(`Session too old on tab focus (${Math.round(sessionAge / 1000 / 60 / 60)}h old)`);
140
+ this.startNewSession();
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * End the session if it timed out or outlived its maximum age, and start a
147
+ * new one (its Session Start comes from the onNewSession hook). Called on
148
+ * every track(): the visibility check alone never ended a session in a tab
149
+ * that stayed visible (contract item 32). Returns true when a new session
150
+ * began.
151
+ */
152
+ refreshIfExpired(): boolean {
153
+ if (!this.currentSessionId) return false;
154
+ const now = Date.now();
155
+ // The later of this tab's exact stamp and the stored one (another tab
156
+ // of the same site keeps the session alive too).
157
+ const lastActivity = Math.max(this.lastActivityAt, this.storage.get<number>(LAST_ACTIVITY_KEY) || 0) || now;
158
+ const sessionStart = this.storage.get<number>(SESSION_START_KEY) || now;
159
+ if (now - lastActivity >= this.sessionTimeoutMs) {
160
+ this.logger.debug(`Session expired (${Math.round((now - lastActivity) / 1000 / 60)} min inactive)`);
161
+ this.startNewSession();
162
+ return true;
163
+ }
164
+ if (now - sessionStart >= MAX_SESSION_AGE_MS) {
165
+ this.logger.debug('Session reached its maximum age');
166
+ this.startNewSession();
167
+ return true;
168
+ }
169
+ return false;
170
+ }
171
+
172
+ /**
173
+ * Update last activity timestamp (call this when tracking events)
174
+ */
175
+ updateActivity(): void {
176
+ if (!this.currentSessionId) return;
177
+
178
+ // Written on EVERY tracked event, and localStorage.setItem is synchronous
179
+ // main-thread I/O. A page that tracks a burst (scroll, product impressions)
180
+ // paid one write per event: measured in headless Chrome as 40 writes for a
181
+ // 40-event burst.
182
+ //
183
+ // The stamp only has to be accurate enough to judge a 30-MINUTE session
184
+ // timeout, so persisting at most once every 30 seconds is ~60x fewer writes
185
+ // and cannot change a timeout decision. The in-memory value stays exact; only
186
+ // the persisted copy is throttled, so the worst case after an abrupt tab
187
+ // close is a stamp up to 30s stale against a 30min threshold.
188
+ const now = Date.now();
189
+ this.lastActivityAt = now;
190
+ if (now - this.lastActivityPersistedAt < ACTIVITY_PERSIST_INTERVAL_MS) return;
191
+ this.lastActivityPersistedAt = now;
192
+ this.storage.set(LAST_ACTIVITY_KEY, now);
193
+ }
194
+
195
+ /**
196
+ * Get current session information
197
+ */
198
+ getSessionInfo(): SessionInfo {
199
+ // A READ (rule 54): never starts or extends a session. With none (after
200
+ // wipeData(), before the next tracked event) the id is ''.
201
+ const sessionId = this.currentSessionId || this.storage.get<string>(SESSION_ID_KEY) || '';
202
+
203
+ const sessionStart = this.storage.get<number>(SESSION_START_KEY) || Date.now();
204
+ // In-memory value first: it is exact, whereas the persisted copy is
205
+ // throttled to one write per ACTIVITY_PERSIST_INTERVAL_MS.
206
+ const lastActivity =
207
+ this.lastActivityAt || this.storage.get<number>(LAST_ACTIVITY_KEY) || Date.now();
208
+
209
+ return {
210
+ sessionId,
211
+ sessionStart,
212
+ lastActivity,
213
+ isNewSession: this.isNewSession,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Get current session ID
219
+ */
220
+ getSessionId(): string {
221
+ // A READ (rule 54): in-app sync, resolve and impressions read the id, and
222
+ // a read used to start a session silently - one with no Session Start.
223
+ return this.currentSessionId || this.storage.get<string>(SESSION_ID_KEY) || '';
224
+ }
225
+
226
+ /**
227
+ * The lifecycle path: the current session, or a new one when there is none
228
+ * (after wipeData()). A new one is announced through onNewSession - which
229
+ * tracks its Session Start - so no session ever starts without one.
230
+ * Returns the session id.
231
+ */
232
+ ensureSession(): string {
233
+ if (this.currentSessionId) return this.currentSessionId;
234
+ const stored = this.storage.get<string>(SESSION_ID_KEY);
235
+ if (stored) {
236
+ this.currentSessionId = stored;
237
+ return stored;
238
+ }
239
+ this.currentSessionId = generateShortId();
240
+ this.isNewSession = true;
241
+ const now = Date.now();
242
+ this.storage.set(SESSION_ID_KEY, this.currentSessionId);
243
+ this.storage.set(SESSION_START_KEY, now);
244
+ this.storage.set(LAST_ACTIVITY_KEY, now);
245
+ this.lastActivityAt = now;
246
+ this.lastActivityPersistedAt = now;
247
+ this.logger.debug(`Started new session: ${this.currentSessionId}`);
248
+ if (this.onNewSessionCallback) this.onNewSessionCallback();
249
+ return this.currentSessionId;
250
+ }
251
+
252
+ /**
253
+ * Another tab changed the visitor (its reset()): take over the session it
254
+ * stored - it started that session and sent its Session Start - instead of
255
+ * carrying the outgoing visitor's session id in memory here. No stored
256
+ * session (a wipe there): this one is forgotten too, and the next call that
257
+ * needs one starts it (with its Session Start). Returns true when the
258
+ * session id here changed.
259
+ */
260
+ followStoredSession(): boolean {
261
+ const stored = this.storage.get<string>(SESSION_ID_KEY);
262
+ if (stored === this.currentSessionId) return false;
263
+ this.currentSessionId = stored || null;
264
+ this.isNewSession = false;
265
+ this.lastActivityAt = stored ? this.storage.get<number>(LAST_ACTIVITY_KEY) || Date.now() : 0;
266
+ this.lastActivityPersistedAt = this.lastActivityAt;
267
+ this.logger.debug(`Following the session another tab started: ${stored || '(none)'}`);
268
+ return true;
269
+ }
270
+
271
+ /**
272
+ * Manually renew the session (reset timeout)
273
+ */
274
+ renewSession(): void {
275
+ this.updateActivity();
276
+ this.logger.debug(`Session renewed: ${this.currentSessionId}`);
277
+ }
278
+
279
+ /**
280
+ * Force start a new session
281
+ */
282
+ resetSession(): void {
283
+ this.logger.debug(`Resetting session (was: ${this.currentSessionId})`);
284
+ this.startNewSession();
285
+ }
286
+
287
+ /**
288
+ * Forget the session entirely (wipeData). The next call that needs a session
289
+ * starts a fresh one; no Session Start is tracked for it, because nothing
290
+ * about the wiped visitor may carry over.
291
+ */
292
+ clear(): void {
293
+ this.currentSessionId = null;
294
+ this.isNewSession = false;
295
+ this.lastActivityAt = 0;
296
+ this.lastActivityPersistedAt = 0;
297
+ this.storage.remove(SESSION_ID_KEY);
298
+ this.storage.remove(SESSION_START_KEY);
299
+ this.storage.remove(LAST_ACTIVITY_KEY);
300
+ }
301
+
302
+ /**
303
+ * Cleanup session manager
304
+ */
305
+ destroy(): void {
306
+ if (this.boundVisibilityHandler && typeof document !== 'undefined') {
307
+ document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
308
+ this.boundVisibilityHandler = null;
309
+ }
310
+ }
311
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Storage Manager for localStorage with fallback to in-memory storage
3
+ */
4
+
5
+ import { Logger } from '../utils/logger';
6
+ import { bucketHash } from '../utils/bucket-hash';
7
+
8
+ interface StorageItem<T = any> {
9
+ value: T;
10
+ expiresAt: number | null;
11
+ }
12
+
13
+ export class StorageManager {
14
+ private prefix: string;
15
+ private memoryStore: Map<string, StorageItem> = new Map();
16
+ private isLocalStorageAvailable: boolean;
17
+ private logger: Logger;
18
+
19
+ /**
20
+ * Namespaces this workspace's localStorage so two SDK keys on one origin do
21
+ * not share a deviceId, session or consent state.
22
+ *
23
+ * Was the Java String.hashCode recurrence (`hash * 31 + char`), the last
24
+ * inline copy of it in the repo after every bucketing site moved to the
25
+ * shared util. Measured on real-format keys (`jry_live_<32 random>`) that
26
+ * recurrence was only ~2x worse than ideal here rather than dangerous - the
27
+ * shared `jry_live_` prefix is the part it weights LEAST, and the random tail
28
+ * dominates - and a single origin carries one or two SDK keys, so collisions
29
+ * were never a practical risk.
30
+ *
31
+ * It was replaced anyway, and the reason is timing rather than risk: the
32
+ * prefix is baked into every stored key, so changing it orphans every
33
+ * browser's deviceId, session and consent. That costs nothing today and can
34
+ * never be done again once real visitors hold state. Taking the free move now
35
+ * leaves ONE hash in the codebase with no exception to remember.
36
+ */
37
+ private static hashKey(key: string): string {
38
+ return bucketHash(key).toString(36);
39
+ }
40
+
41
+ constructor(sdkKey: string, logger: Logger) {
42
+ this.prefix = `jry_${StorageManager.hashKey(sdkKey)}_`;
43
+ this.logger = logger;
44
+ this.isLocalStorageAvailable = this.checkLocalStorageAvailable();
45
+
46
+ if (!this.isLocalStorageAvailable) {
47
+ this.logger.warn('LocalStorage not available, using in-memory storage');
48
+ }
49
+ }
50
+
51
+ private checkLocalStorageAvailable(): boolean {
52
+ try {
53
+ const testKey = '__jry_test__';
54
+ localStorage.setItem(testKey, 'test');
55
+ localStorage.removeItem(testKey);
56
+ return true;
57
+ } catch (e) {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ private getKey(key: string): string {
63
+ return `${this.prefix}${key}`;
64
+ }
65
+
66
+ /** The localStorage key one SDK key is stored under (to read `storage` events of other tabs). */
67
+ storageKeyFor(key: string): string {
68
+ return this.getKey(key);
69
+ }
70
+
71
+ set<T>(key: string, value: T, expiresInDays?: number): void {
72
+ const item: StorageItem<T> = {
73
+ value,
74
+ expiresAt: expiresInDays
75
+ ? Date.now() + expiresInDays * 24 * 60 * 60 * 1000
76
+ : null,
77
+ };
78
+
79
+ if (this.isLocalStorageAvailable) {
80
+ try {
81
+ localStorage.setItem(this.getKey(key), JSON.stringify(item));
82
+ this.memoryStore.delete(key);
83
+ } catch (e) {
84
+ this.logger.error('Failed to write to localStorage:', e);
85
+ // Fallback to memory - and the OLD stored value goes, or get() would
86
+ // keep preferring it (a stale event queue re-sent on the next page).
87
+ try {
88
+ localStorage.removeItem(this.getKey(key));
89
+ } catch {
90
+ // nothing more to do
91
+ }
92
+ this.memoryStore.set(key, item);
93
+ }
94
+ } else {
95
+ this.memoryStore.set(key, item);
96
+ }
97
+ }
98
+
99
+ get<T>(key: string): T | null {
100
+ let item: StorageItem<T> | null = null;
101
+
102
+ if (this.isLocalStorageAvailable) {
103
+ try {
104
+ const stored = localStorage.getItem(this.getKey(key));
105
+ if (stored) {
106
+ item = JSON.parse(stored);
107
+ }
108
+ } catch (e) {
109
+ this.logger.error('Failed to read from localStorage:', e);
110
+ try { localStorage.removeItem(this.getKey(key)); } catch {}
111
+ }
112
+ }
113
+
114
+ // Fallback to memory store
115
+ if (!item) {
116
+ item = this.memoryStore.get(key) as StorageItem<T> || null;
117
+ }
118
+
119
+ // Check expiration
120
+ if (item && item.expiresAt && item.expiresAt < Date.now()) {
121
+ this.remove(key);
122
+ return null;
123
+ }
124
+
125
+ return item?.value ?? null;
126
+ }
127
+
128
+ remove(key: string): void {
129
+ if (this.isLocalStorageAvailable) {
130
+ try {
131
+ localStorage.removeItem(this.getKey(key));
132
+ } catch (e) {
133
+ this.logger.error('Failed to remove from localStorage:', e);
134
+ }
135
+ }
136
+ this.memoryStore.delete(key);
137
+ }
138
+
139
+ clear(): void {
140
+ if (this.isLocalStorageAvailable) {
141
+ try {
142
+ // Remove only keys with our prefix
143
+ const keysToRemove: string[] = [];
144
+ for (let i = 0; i < localStorage.length; i++) {
145
+ const key = localStorage.key(i);
146
+ if (key && key.startsWith(this.prefix)) {
147
+ keysToRemove.push(key);
148
+ }
149
+ }
150
+ keysToRemove.forEach((key) => localStorage.removeItem(key));
151
+ } catch (e) {
152
+ this.logger.error('Failed to clear localStorage:', e);
153
+ }
154
+ }
155
+ this.memoryStore.clear();
156
+ }
157
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Joryio SDK User
3
+ * Provides methods to update user subscription status
4
+ * Write-only for security - no getter methods
5
+ */
6
+
7
+ import { Logger } from '../utils/logger';
8
+ import { NetworkClient } from './network';
9
+ import { IdentityManager } from './identity';
10
+ import type { WriteRunner, WriteRecord } from './write-retry';
11
+
12
+ /**
13
+ * Subscription status types
14
+ * - OPTED_IN: User has explicitly opted in (e.g., confirmed double opt-in)
15
+ * - SUBSCRIBED: User is subscribed but hasn't explicitly opted in
16
+ * - UNSUBSCRIBED: User has opted out
17
+ */
18
+ export enum SubscriptionStatus {
19
+ // Wire value MUST be camelCase `optedIn` — the backend `@IsIn`/`@IsEnum`
20
+ // (sdk-subscriptions.controller.ts, update-channel-subscription.dto.ts) 400s
21
+ // on snake_case `opted_in` (a NormalizeEnumCasing migration renamed the stored
22
+ // value). See docs/SSOT_DRIFT_AUDIT.md B7 — the enum NAME stays OPTED_IN.
23
+ OPTED_IN = 'optedIn',
24
+ SUBSCRIBED = 'subscribed',
25
+ UNSUBSCRIBED = 'unsubscribed',
26
+ }
27
+
28
+ /**
29
+ * Channel types for subscriptions
30
+ */
31
+ /*
32
+ * MIRRORS the backend's SUBSCRIPTION_CHANNELS, which is five, not four.
33
+ *
34
+ * `viber` was added there and never here, so this SDK refused locally - with a
35
+ * logged error and no request - a channel the backend accepts. Viber is a
36
+ * first-class campaign channel, so a web preference centre simply could not
37
+ * offer it, and the failure was client-side and silent to the server.
38
+ */
39
+ export type SubscriptionChannel = 'email' | 'sms' | 'whatsapp' | 'push' | 'viber';
40
+
41
+ /** The runtime list, so validation and the type cannot drift apart. */
42
+ export const SUBSCRIPTION_CHANNELS: readonly SubscriptionChannel[] = [
43
+ 'email',
44
+ 'sms',
45
+ 'whatsapp',
46
+ 'push',
47
+ 'viber',
48
+ ];
49
+
50
+ /**
51
+ * Subscription preferences object for bulk updates
52
+ */
53
+ export type SubscriptionPreferences = Partial<Record<SubscriptionChannel, SubscriptionStatus>>;
54
+
55
+ export class User {
56
+ private logger: Logger;
57
+ private network: NetworkClient;
58
+ private identity: IdentityManager;
59
+ private apiEndpoint: string;
60
+ private runWrite: WriteRunner;
61
+
62
+ constructor(
63
+ logger: Logger,
64
+ network: NetworkClient,
65
+ identity: IdentityManager,
66
+ apiEndpoint: string,
67
+ runWrite?: WriteRunner,
68
+ ) {
69
+ this.logger = logger;
70
+ this.network = network;
71
+ this.identity = identity;
72
+ this.apiEndpoint = apiEndpoint;
73
+ // Standalone use (tests): send once, immediately. The SDK passes its
74
+ // persisted, ordered outbox (outbox.ts) instead.
75
+ this.runWrite =
76
+ runWrite ||
77
+ ((label, record) => {
78
+ this.sendOnce(record).then(
79
+ (res) => {
80
+ if (!res.ok) this.logger.error(`${label} failed (HTTP ${res.status || 'no response'})`);
81
+ },
82
+ (error) => this.logger.error(`${label} failed:`, error),
83
+ );
84
+ });
85
+ }
86
+
87
+ private sendOnce(record: WriteRecord) {
88
+ if (record.kind === 'subscription') return this.network.sendSubscriptionUpdate(this.apiEndpoint, record.payload);
89
+ if (record.kind === 'group') return this.network.sendSubscriptionGroupUpdate(this.apiEndpoint, record.payload);
90
+ return Promise.resolve({ ok: false, status: 0 });
91
+ }
92
+
93
+ /**
94
+ * Both ids, captured NOW. The server resolves `userId` first and falls back
95
+ * to `anonymousId`, so a visitor whose identify has not been merged yet is
96
+ * still found. A write made while anonymous is sent with the user id once
97
+ * that anonymous id has been identified (resolved at send time by the
98
+ * outbox, contract item 38); a write of an earlier identity keeps its own.
99
+ */
100
+ private ids(): { userId?: string; anonymousId: string } {
101
+ const userId = this.identity.getUserId();
102
+ return { ...(userId ? { userId } : {}), anonymousId: this.identity.getAnonymousId() };
103
+ }
104
+
105
+ /**
106
+ * Set subscription status for a channel
107
+ * @param channel - The channel (email, sms, whatsapp, push, viber)
108
+ * @param status - The subscription status (OPTED_IN, SUBSCRIBED, UNSUBSCRIBED)
109
+ *
110
+ * @example
111
+ * user.setSubscription('email', SubscriptionStatus.OPTED_IN);
112
+ * user.setSubscription('sms', SubscriptionStatus.UNSUBSCRIBED);
113
+ */
114
+ setSubscription(channel: SubscriptionChannel, status: SubscriptionStatus): void {
115
+ // An unsubscribe is a WITHDRAWAL: it is delivered even after optOut(), a
116
+ // denied consent or wipeData() (contract item 36). Any other status stops
117
+ // with them.
118
+ // Validate channel
119
+ if (!SUBSCRIPTION_CHANNELS.includes(channel)) {
120
+ this.logger.error(
121
+ `Invalid channel: ${channel}. Must be one of: ${SUBSCRIPTION_CHANNELS.join(', ')}`,
122
+ );
123
+ return;
124
+ }
125
+
126
+ // Validate status
127
+ const validStatuses = Object.values(SubscriptionStatus);
128
+ if (!validStatuses.includes(status)) {
129
+ this.logger.error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
130
+ return;
131
+ }
132
+
133
+ this.logger.debug(`Setting ${channel} subscription status to ${status}`);
134
+ const payload = { channel, status, ...this.ids() };
135
+ this.runWrite(`Update ${channel} subscription`, { kind: 'subscription', payload });
136
+ }
137
+
138
+ /**
139
+ * Set multiple channel subscriptions at once
140
+ * @param preferences - Object mapping channels to their subscription status
141
+ *
142
+ * @example
143
+ * user.setSubscriptions({
144
+ * email: SubscriptionStatus.OPTED_IN,
145
+ * sms: SubscriptionStatus.SUBSCRIBED,
146
+ * push: SubscriptionStatus.UNSUBSCRIBED
147
+ * });
148
+ */
149
+ setSubscriptions(preferences: SubscriptionPreferences): void {
150
+ if (!preferences || typeof preferences !== 'object') {
151
+ this.logger.error('setSubscriptions requires an object, e.g. { email: SubscriptionStatus.OPTED_IN }');
152
+ return;
153
+ }
154
+ for (const [channel, status] of Object.entries(preferences)) {
155
+ if (status !== undefined) {
156
+ this.setSubscription(channel as SubscriptionChannel, status);
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Add user to a subscription group (list)
163
+ * @param groupId - The subscription group/list ID
164
+ * @param channel - Optional channel for the subscription (defaults to email)
165
+ *
166
+ * @example
167
+ * user.addToSubscriptionGroup('list_abc123');
168
+ * user.addToSubscriptionGroup('list_abc123', 'sms');
169
+ */
170
+ addToSubscriptionGroup(groupId: string, channel: SubscriptionChannel = 'email'): void {
171
+ this.updateGroup(groupId, channel, 'subscribe');
172
+ }
173
+
174
+ /**
175
+ * Remove user from a subscription group (list)
176
+ * @param groupId - The subscription group/list ID
177
+ * @param channel - Optional channel for the subscription (defaults to email)
178
+ *
179
+ * @example
180
+ * user.removeFromSubscriptionGroup('list_abc123');
181
+ * user.removeFromSubscriptionGroup('list_abc123', 'sms');
182
+ */
183
+ removeFromSubscriptionGroup(groupId: string, channel: SubscriptionChannel = 'email'): void {
184
+ this.updateGroup(groupId, channel, 'unsubscribe');
185
+ }
186
+
187
+ private updateGroup(groupId: string, channel: SubscriptionChannel, action: 'subscribe' | 'unsubscribe'): void {
188
+ if (typeof groupId !== 'string' || !groupId) {
189
+ this.logger.error(`Cannot ${action} subscription group: groupId is required`);
190
+ return;
191
+ }
192
+ if (!SUBSCRIPTION_CHANNELS.includes(channel)) {
193
+ this.logger.error(
194
+ `Invalid channel: ${channel}. Must be one of: ${SUBSCRIPTION_CHANNELS.join(', ')}`,
195
+ );
196
+ return;
197
+ }
198
+
199
+ this.logger.debug(`${action} subscription group ${groupId} for channel ${channel}`);
200
+ const payload = { groupId, channel, action, ...this.ids() };
201
+ this.runWrite(`Subscription group ${groupId} ${action}`, { kind: 'group', payload });
202
+ }
203
+ }