@knocklabs/client 0.13.1 → 0.14.0

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.
@@ -0,0 +1,541 @@
1
+ import { GenericData } from "@knocklabs/types";
2
+ import { Store } from "@tanstack/store";
3
+ import { Channel, Socket } from "phoenix";
4
+
5
+ import Knock from "../../knock";
6
+
7
+ const sortGuides = (guides: KnockGuide[]) => {
8
+ return [...guides].sort(
9
+ (a, b) =>
10
+ b.priority - a.priority ||
11
+ new Date(b.inserted_at).getTime() - new Date(a.inserted_at).getTime(),
12
+ );
13
+ };
14
+
15
+ //
16
+ // Guides API (via User client)
17
+ //
18
+
19
+ export const guidesApiRootPath = (userId: string | undefined | null) =>
20
+ `/v1/users/${userId}/guides`;
21
+
22
+ interface StepMessageState {
23
+ id: string;
24
+ seen_at: string | null;
25
+ read_at: string | null;
26
+ interacted_at: string | null;
27
+ archived_at: string | null;
28
+ link_clicked_at: string | null;
29
+ }
30
+
31
+ interface GuideStepData {
32
+ ref: string;
33
+ schema_key: string;
34
+ schema_semver: string;
35
+ schema_variant_key: string;
36
+ message: StepMessageState;
37
+ // eslint-disable-next-line
38
+ content: any;
39
+ }
40
+
41
+ interface GuideData {
42
+ __typename: "Guide";
43
+ channel_id: string;
44
+ id: string;
45
+ key: string;
46
+ priority: number;
47
+ type: string;
48
+ semver: string;
49
+ steps: GuideStepData[];
50
+ inserted_at: string;
51
+ updated_at: string;
52
+ }
53
+
54
+ export interface KnockGuideStep extends GuideStepData {
55
+ markAsSeen: () => void;
56
+ markAsInteracted: (params?: { metadata?: GenericData }) => void;
57
+ markAsArchived: () => void;
58
+ }
59
+
60
+ export interface KnockGuide extends GuideData {
61
+ steps: KnockGuideStep[];
62
+ }
63
+
64
+ type GetGuidesQueryParams = {
65
+ data?: string;
66
+ tenant?: string;
67
+ type?: string;
68
+ };
69
+
70
+ type GetGuidesResponse = {
71
+ entries: GuideData[];
72
+ };
73
+
74
+ export type GuideEngagementEventBaseParams = {
75
+ // Base params required for all engagement update events
76
+ message_id: string;
77
+ channel_id: string;
78
+ guide_key: string;
79
+ guide_id: string;
80
+ guide_step_ref: string;
81
+ };
82
+
83
+ type MarkAsSeenParams = GuideEngagementEventBaseParams & {
84
+ // Rendered step content seen by the recipient
85
+ content: GenericData;
86
+ // Target params
87
+ data?: GenericData;
88
+ tenant?: string;
89
+ };
90
+ type MarkAsInteractedParams = GuideEngagementEventBaseParams;
91
+ type MarkAsArchivedParams = GuideEngagementEventBaseParams;
92
+
93
+ type MarkGuideAsResponse = {
94
+ status: "ok";
95
+ };
96
+
97
+ type SocketEventType = "guide.added" | "guide.updated" | "guide.removed";
98
+
99
+ type SocketEventPayload<E extends SocketEventType, D> = {
100
+ topic: string;
101
+ event: E;
102
+ data: D;
103
+ };
104
+
105
+ type GuideAddedEvent = SocketEventPayload<
106
+ "guide.added",
107
+ { guide: GuideData; eligible: true }
108
+ >;
109
+
110
+ type GuideUpdatedEvent = SocketEventPayload<
111
+ "guide.updated",
112
+ { guide: GuideData; eligible: boolean }
113
+ >;
114
+
115
+ type GuideRemovedEvent = SocketEventPayload<
116
+ "guide.removed",
117
+ { guide: Pick<GuideData, "key"> }
118
+ >;
119
+
120
+ type GuideSocketEvent = GuideAddedEvent | GuideUpdatedEvent | GuideRemovedEvent;
121
+
122
+ //
123
+ // Guides client
124
+ //
125
+
126
+ type QueryKey = string;
127
+
128
+ type QueryStatus = {
129
+ status: "loading" | "ok" | "error";
130
+ error?: Error;
131
+ };
132
+
133
+ type StoreState = {
134
+ guides: KnockGuide[];
135
+ queries: Record<QueryKey, QueryStatus>;
136
+ };
137
+
138
+ type QueryFilterParams = Pick<GetGuidesQueryParams, "type">;
139
+
140
+ export type SelectFilterParams = {
141
+ key?: string;
142
+ type?: string;
143
+ };
144
+
145
+ export type TargetParams = {
146
+ data?: GenericData | undefined;
147
+ tenant?: string | undefined;
148
+ };
149
+
150
+ export class KnockGuideClient {
151
+ public store: Store<StoreState, (state: StoreState) => StoreState>;
152
+
153
+ // Phoenix channels for real time guide updates over websocket
154
+ private socket: Socket | undefined;
155
+ private socketChannel: Channel | undefined;
156
+ private socketChannelTopic: string;
157
+ private socketEventTypes = ["guide.added", "guide.updated", "guide.removed"];
158
+
159
+ constructor(
160
+ readonly knock: Knock,
161
+ readonly channelId: string,
162
+ readonly targetParams: TargetParams = {},
163
+ ) {
164
+ this.store = new Store<StoreState>({
165
+ guides: [],
166
+ queries: {},
167
+ });
168
+
169
+ // In server environments we might not have a socket connection.
170
+ const { socket: maybeSocket } = this.knock.client();
171
+ this.socket = maybeSocket;
172
+ this.socketChannelTopic = `guides:${channelId}`;
173
+
174
+ this.knock.log("[Guide] Initialized a guide client");
175
+ }
176
+
177
+ async fetch(opts?: { filters?: QueryFilterParams }) {
178
+ this.knock.failIfNotAuthenticated();
179
+ this.knock.log("[Guide] Loading all eligible guides");
180
+
181
+ const queryParams = this.buildQueryParams(opts?.filters);
182
+ const queryKey = this.formatQueryKey(queryParams);
183
+
184
+ // If already fetched before, then noop.
185
+ const maybeQueryStatus = this.store.state.queries[queryKey];
186
+ if (maybeQueryStatus) {
187
+ return maybeQueryStatus;
188
+ }
189
+
190
+ // Mark this query status as loading.
191
+ this.store.setState((state) => ({
192
+ ...state,
193
+ queries: { ...state.queries, [queryKey]: { status: "loading" } },
194
+ }));
195
+
196
+ let queryStatus: QueryStatus;
197
+ try {
198
+ const data = await this.knock.user.getGuides<
199
+ GetGuidesQueryParams,
200
+ GetGuidesResponse
201
+ >(this.channelId, queryParams);
202
+ queryStatus = { status: "ok" };
203
+
204
+ this.store.setState((state) => ({
205
+ ...state,
206
+ // For now assume a single fetch to get all eligible guides. When/if
207
+ // we implement incremental loads, then this will need to be a merge
208
+ // and sort operation.
209
+ guides: data.entries.map((g) => this.localCopy(g)),
210
+ queries: { ...state.queries, [queryKey]: queryStatus },
211
+ }));
212
+ } catch (e) {
213
+ queryStatus = { status: "error", error: e as Error };
214
+
215
+ this.store.setState((state) => ({
216
+ ...state,
217
+ queries: { ...state.queries, [queryKey]: queryStatus },
218
+ }));
219
+ }
220
+
221
+ return queryStatus;
222
+ }
223
+
224
+ subscribe() {
225
+ if (!this.socket) return;
226
+ this.knock.failIfNotAuthenticated();
227
+ this.knock.log("[Guide] Subscribing to real time updates");
228
+
229
+ // Ensure a live socket connection if not yet connected.
230
+ if (!this.socket.isConnected()) {
231
+ this.socket.connect();
232
+ }
233
+
234
+ // If there's an existing connected channel, then disconnect.
235
+ if (this.socketChannel) {
236
+ this.unsubscribe();
237
+ }
238
+
239
+ // Join the channel topic and subscribe to supported events.
240
+ const params = { ...this.targetParams, user_id: this.knock.userId };
241
+ const newChannel = this.socket.channel(this.socketChannelTopic, params);
242
+
243
+ for (const eventType of this.socketEventTypes) {
244
+ newChannel.on(eventType, (payload) => this.handleSocketEvent(payload));
245
+ }
246
+
247
+ if (["closed", "errored"].includes(newChannel.state)) {
248
+ newChannel.join();
249
+ }
250
+
251
+ // Track the joined channel.
252
+ this.socketChannel = newChannel;
253
+ }
254
+
255
+ unsubscribe() {
256
+ if (!this.socketChannel) return;
257
+ this.knock.log("[Guide] Unsubscribing from real time updates");
258
+
259
+ // Unsubscribe from the socket events and leave the channel.
260
+ for (const eventType of this.socketEventTypes) {
261
+ this.socketChannel.off(eventType);
262
+ }
263
+ this.socketChannel.leave();
264
+
265
+ // Unset the channel.
266
+ this.socketChannel = undefined;
267
+ }
268
+
269
+ private handleSocketEvent(payload: GuideSocketEvent) {
270
+ const { event, data } = payload;
271
+
272
+ switch (event) {
273
+ case "guide.added":
274
+ return this.addGuide(payload);
275
+
276
+ case "guide.updated":
277
+ return data.eligible
278
+ ? this.replaceOrAddGuide(payload)
279
+ : this.removeGuide(payload);
280
+
281
+ case "guide.removed":
282
+ return this.removeGuide(payload);
283
+
284
+ default:
285
+ return;
286
+ }
287
+ }
288
+
289
+ //
290
+ // Store selector
291
+ //
292
+
293
+ select(state: StoreState, filters: SelectFilterParams = {}) {
294
+ // TODO(KNO-7790): Need to evaluate activation rules also.
295
+
296
+ return state.guides.filter((guide) => {
297
+ if (filters.type && filters.type !== guide.type) {
298
+ return false;
299
+ }
300
+
301
+ if (filters.key && filters.key !== guide.key) {
302
+ return false;
303
+ }
304
+
305
+ return true;
306
+ });
307
+ }
308
+
309
+ //
310
+ // Engagement event handlers
311
+ //
312
+ // Make an optimistic update on the client side first, then send an engagement
313
+ // event to the backend.
314
+ //
315
+
316
+ async markAsSeen(guide: GuideData, step: GuideStepData) {
317
+ this.knock.log(
318
+ `[Guide] Marking as seen (Guide key: ${guide.key}, Step ref:${step.ref})`,
319
+ );
320
+
321
+ const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
322
+ seen_at: new Date().toISOString(),
323
+ });
324
+ if (!updatedStep) return;
325
+
326
+ const params = {
327
+ ...this.buildEngagementEventBaseParams(guide, updatedStep),
328
+ content: updatedStep.content,
329
+ data: this.targetParams.data,
330
+ tenant: this.targetParams.tenant,
331
+ };
332
+
333
+ this.knock.user.markGuideStepAs<MarkAsSeenParams, MarkGuideAsResponse>(
334
+ "seen",
335
+ params,
336
+ );
337
+
338
+ return updatedStep;
339
+ }
340
+
341
+ async markAsInteracted(
342
+ guide: GuideData,
343
+ step: GuideStepData,
344
+ metadata?: GenericData,
345
+ ) {
346
+ this.knock.log(
347
+ `[Guide] Marking as interacted (Guide key: ${guide.key}, Step ref:${step.ref})`,
348
+ );
349
+
350
+ const ts = new Date().toISOString();
351
+ const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
352
+ read_at: ts,
353
+ interacted_at: ts,
354
+ });
355
+ if (!updatedStep) return;
356
+
357
+ const params = {
358
+ ...this.buildEngagementEventBaseParams(guide, updatedStep),
359
+ metadata,
360
+ };
361
+
362
+ this.knock.user.markGuideStepAs<
363
+ MarkAsInteractedParams,
364
+ MarkGuideAsResponse
365
+ >("interacted", params);
366
+
367
+ return updatedStep;
368
+ }
369
+
370
+ async markAsArchived(guide: GuideData, step: GuideStepData) {
371
+ this.knock.log(
372
+ `[Guide] Marking as archived (Guide key: ${guide.key}, Step ref:${step.ref})`,
373
+ );
374
+
375
+ const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
376
+ archived_at: new Date().toISOString(),
377
+ });
378
+ if (!updatedStep) return;
379
+
380
+ const params = this.buildEngagementEventBaseParams(guide, updatedStep);
381
+
382
+ this.knock.user.markGuideStepAs<MarkAsArchivedParams, MarkGuideAsResponse>(
383
+ "archived",
384
+ params,
385
+ );
386
+
387
+ return updatedStep;
388
+ }
389
+
390
+ //
391
+ // Helpers
392
+ //
393
+
394
+ private localCopy(remoteGuide: GuideData) {
395
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
396
+ const self = this;
397
+
398
+ // Build a local copy with helper methods added.
399
+ const localGuide = { ...remoteGuide };
400
+
401
+ localGuide.steps = remoteGuide.steps.map(({ message, ...rest }) => {
402
+ const localStep = {
403
+ ...rest,
404
+ message: { ...message },
405
+ markAsSeen() {
406
+ // Send a seen event if it has not been previously seen.
407
+ if (this.message.seen_at) return;
408
+ return self.markAsSeen(localGuide, this);
409
+ },
410
+ markAsInteracted({ metadata }: { metadata?: GenericData } = {}) {
411
+ // Always send an interaction event through.
412
+ return self.markAsInteracted(localGuide, this, metadata);
413
+ },
414
+ markAsArchived() {
415
+ // Send an archived event if it has not been previously archived.
416
+ if (this.message.archived_at) return;
417
+ return self.markAsArchived(localGuide, this);
418
+ },
419
+ };
420
+
421
+ // Bind all engagement action handler methods to the local step object so
422
+ // they can operate on itself.
423
+ localStep.markAsSeen = localStep.markAsSeen.bind(localStep);
424
+ localStep.markAsInteracted = localStep.markAsInteracted.bind(localStep);
425
+ localStep.markAsArchived = localStep.markAsArchived.bind(localStep);
426
+
427
+ return localStep;
428
+ });
429
+
430
+ return localGuide as KnockGuide;
431
+ }
432
+
433
+ private buildQueryParams(filterParams: QueryFilterParams = {}) {
434
+ // Combine the target params with the given filter params.
435
+ const combinedParams = { ...this.targetParams, ...filterParams };
436
+
437
+ // Prune out any keys that have an undefined or null value.
438
+ let params = Object.fromEntries(
439
+ Object.entries(combinedParams).filter(
440
+ ([_k, v]) => v !== undefined && v !== null,
441
+ ),
442
+ );
443
+
444
+ // Encode target data as a JSON string, if provided.
445
+ params = params.data
446
+ ? { ...params, data: JSON.stringify(params.data) }
447
+ : params;
448
+
449
+ return params as GetGuidesQueryParams;
450
+ }
451
+
452
+ private formatQueryKey(queryParams: GenericData) {
453
+ const sortedKeys = Object.keys(queryParams).sort();
454
+
455
+ const queryStr = sortedKeys
456
+ .map(
457
+ (key) =>
458
+ `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`,
459
+ )
460
+ .join("&");
461
+
462
+ const basePath = guidesApiRootPath(this.knock.userId);
463
+ return queryStr ? `${basePath}?${queryStr}` : basePath;
464
+ }
465
+
466
+ private setStepMessageAttrs(
467
+ guideKey: string,
468
+ stepRef: string,
469
+ attrs: Partial<StepMessageState>,
470
+ ) {
471
+ let updatedStep: KnockGuideStep | undefined;
472
+
473
+ this.store.setState((state) => {
474
+ const guides = state.guides.map((guide) => {
475
+ if (guide.key !== guideKey) return guide;
476
+
477
+ const steps = guide.steps.map((step) => {
478
+ if (step.ref !== stepRef) return step;
479
+
480
+ // Mutate in place and maintain the same obj ref so to make it easier
481
+ // to use in hook deps.
482
+ step.message = { ...step.message, ...attrs };
483
+ updatedStep = step;
484
+
485
+ return step;
486
+ });
487
+ return { ...guide, steps };
488
+ });
489
+ return { ...state, guides };
490
+ });
491
+
492
+ return updatedStep;
493
+ }
494
+
495
+ private buildEngagementEventBaseParams(
496
+ guide: GuideData,
497
+ step: GuideStepData,
498
+ ) {
499
+ return {
500
+ message_id: step.message.id,
501
+ channel_id: guide.channel_id,
502
+ guide_key: guide.key,
503
+ guide_id: guide.id,
504
+ guide_step_ref: step.ref,
505
+ };
506
+ }
507
+
508
+ private addGuide({ data }: GuideAddedEvent) {
509
+ const guide = this.localCopy(data.guide);
510
+
511
+ this.store.setState((state) => {
512
+ return { ...state, guides: sortGuides([...state.guides, guide]) };
513
+ });
514
+ }
515
+
516
+ private replaceOrAddGuide({ data }: GuideUpdatedEvent) {
517
+ const guide = this.localCopy(data.guide);
518
+
519
+ this.store.setState((state) => {
520
+ let replaced = false;
521
+
522
+ const guides = state.guides.map((g) => {
523
+ if (g.key !== guide.key) return g;
524
+ replaced = true;
525
+ return guide;
526
+ });
527
+
528
+ return {
529
+ ...state,
530
+ guides: replaced ? sortGuides(guides) : sortGuides([...guides, guide]),
531
+ };
532
+ });
533
+ }
534
+
535
+ private removeGuide({ data }: GuideUpdatedEvent | GuideRemovedEvent) {
536
+ this.store.setState((state) => {
537
+ const guides = state.guides.filter((g) => g.key !== data.guide.key);
538
+ return { ...state, guides };
539
+ });
540
+ }
541
+ }
@@ -0,0 +1,7 @@
1
+ export { KnockGuideClient } from "./client";
2
+ export type {
3
+ KnockGuide,
4
+ KnockGuideStep,
5
+ TargetParams as KnockGuideTargetParams,
6
+ SelectFilterParams as KnockGuideFilterParams,
7
+ } from "./client";
@@ -3,6 +3,10 @@ import { GenericData } from "@knocklabs/types";
3
3
  import { ApiResponse } from "../../api";
4
4
  import { ChannelData, User } from "../../interfaces";
5
5
  import Knock from "../../knock";
6
+ import {
7
+ GuideEngagementEventBaseParams,
8
+ guidesApiRootPath,
9
+ } from "../guide/client";
6
10
  import {
7
11
  GetPreferencesOptions,
8
12
  PreferenceOptions,
@@ -112,6 +116,29 @@ class UserClient {
112
116
  return this.handleResponse<ChannelData<T>>(result);
113
117
  }
114
118
 
119
+ async getGuides<P, R>(channelId: string, params: P) {
120
+ const result = await this.instance.client().makeRequest({
121
+ method: "GET",
122
+ url: `${guidesApiRootPath(this.instance.userId)}/${channelId}`,
123
+ params,
124
+ });
125
+
126
+ return this.handleResponse<R>(result);
127
+ }
128
+
129
+ async markGuideStepAs<P extends GuideEngagementEventBaseParams, R>(
130
+ status: "seen" | "interacted" | "archived",
131
+ params: P,
132
+ ) {
133
+ const result = await this.instance.client().makeRequest({
134
+ method: "PUT",
135
+ url: `${guidesApiRootPath(this.instance.userId)}/messages/${params.message_id}/${status}`,
136
+ data: params,
137
+ });
138
+
139
+ return this.handleResponse<R>(result);
140
+ }
141
+
115
142
  private handleResponse<T>(response: ApiResponse) {
116
143
  if (response.statusCode === "error") {
117
144
  throw new Error(response.error || response.body);
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import FeedClient, { Feed } from "./clients/feed";
2
2
  import Knock from "./knock";
3
3
 
4
4
  export * from "./interfaces";
5
+ export * from "./clients/guide";
5
6
  export * from "./clients/feed/types";
6
7
  export * from "./clients/feed/interfaces";
7
8
  export * from "./clients/objects";