@luxexchange/notifications 1.0.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.
Files changed (45) hide show
  1. package/.depcheckrc +14 -0
  2. package/.eslintrc.js +20 -0
  3. package/README.md +548 -0
  4. package/package.json +42 -0
  5. package/project.json +30 -0
  6. package/src/getIsNotificationServiceLocalOverrideEnabled.ts +7 -0
  7. package/src/global.d.ts +2 -0
  8. package/src/index.ts +41 -0
  9. package/src/notification-data-source/NotificationDataSource.ts +8 -0
  10. package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
  11. package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +73 -0
  12. package/src/notification-data-source/implementations/createLocalTriggerDataSource.test.ts +492 -0
  13. package/src/notification-data-source/implementations/createLocalTriggerDataSource.ts +177 -0
  14. package/src/notification-data-source/implementations/createNotificationDataSource.ts +19 -0
  15. package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts +398 -0
  16. package/src/notification-data-source/implementations/createPollingNotificationDataSource.ts +74 -0
  17. package/src/notification-data-source/implementations/createReactiveDataSource.ts +113 -0
  18. package/src/notification-data-source/types/ReactiveCondition.ts +60 -0
  19. package/src/notification-processor/NotificationProcessor.ts +26 -0
  20. package/src/notification-processor/implementations/createBaseNotificationProcessor.test.ts +854 -0
  21. package/src/notification-processor/implementations/createBaseNotificationProcessor.ts +239 -0
  22. package/src/notification-processor/implementations/createNotificationProcessor.test.ts +130 -0
  23. package/src/notification-processor/implementations/createNotificationProcessor.ts +15 -0
  24. package/src/notification-renderer/NotificationRenderer.ts +8 -0
  25. package/src/notification-renderer/components/BannerTemplate.tsx +188 -0
  26. package/src/notification-renderer/components/InlineBannerNotification.tsx +123 -0
  27. package/src/notification-renderer/implementations/createNotificationRenderer.ts +16 -0
  28. package/src/notification-renderer/utils/iconUtils.ts +103 -0
  29. package/src/notification-service/NotificationService.ts +47 -0
  30. package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
  31. package/src/notification-service/implementations/createNotificationService.ts +364 -0
  32. package/src/notification-telemetry/NotificationTelemetry.ts +44 -0
  33. package/src/notification-telemetry/implementations/createNotificationTelemetry.test.ts +99 -0
  34. package/src/notification-telemetry/implementations/createNotificationTelemetry.ts +33 -0
  35. package/src/notification-tracker/NotificationTracker.ts +14 -0
  36. package/src/notification-tracker/implementations/createApiNotificationTracker.test.ts +465 -0
  37. package/src/notification-tracker/implementations/createApiNotificationTracker.ts +154 -0
  38. package/src/notification-tracker/implementations/createNoopNotificationTracker.ts +44 -0
  39. package/src/notification-tracker/implementations/createNotificationTracker.ts +31 -0
  40. package/src/utils/formatNotificationType.test.ts +25 -0
  41. package/src/utils/formatNotificationType.ts +25 -0
  42. package/tsconfig.json +24 -0
  43. package/tsconfig.lint.json +8 -0
  44. package/vitest-setup.ts +1 -0
  45. package/vitest.config.ts +14 -0
@@ -0,0 +1,465 @@
1
+ import type { QueryClient } from '@tanstack/react-query'
2
+ import type { NotificationsApiClient } from '@luxfi/api'
3
+ import { createApiNotificationTracker } from '@luxfi/notifications/src/notification-tracker/implementations/createApiNotificationTracker'
4
+ import type { TrackingMetadata } from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
5
+ import { describe, expect, it, type Mock, vi } from 'vitest'
6
+
7
+ describe('createApiNotificationTracker', () => {
8
+ const createMockApiClient = (): NotificationsApiClient =>
9
+ ({
10
+ ackNotification: vi.fn().mockResolvedValue(undefined),
11
+ }) as unknown as NotificationsApiClient
12
+
13
+ const createMockQueryClient = (): QueryClient =>
14
+ ({
15
+ fetchQuery: vi.fn((options) => options.queryFn()),
16
+ }) as unknown as QueryClient
17
+
18
+ const createMockStorage = (): {
19
+ has: Mock<(notificationId: string) => Promise<boolean>>
20
+ add: Mock<(notificationId: string, metadata?: { timestamp: number }) => Promise<void>>
21
+ getAll: Mock<() => Promise<Set<string>>>
22
+ deleteOlderThan: Mock<(timestamp: number) => Promise<void>>
23
+ } => ({
24
+ has: vi.fn().mockResolvedValue(false),
25
+ add: vi.fn().mockResolvedValue(undefined),
26
+ getAll: vi.fn().mockResolvedValue(new Set<string>()),
27
+ deleteOlderThan: vi.fn().mockResolvedValue(undefined),
28
+ })
29
+
30
+ const mockMetadata: TrackingMetadata = {
31
+ timestamp: Date.now(),
32
+ }
33
+
34
+ describe('isProcessed', () => {
35
+ it('returns true when storage has the notification', async () => {
36
+ const mockApiClient = createMockApiClient()
37
+ const mockStorage = createMockStorage()
38
+ mockStorage.has.mockResolvedValue(true)
39
+
40
+ const tracker = createApiNotificationTracker({
41
+ notificationsApiClient: mockApiClient,
42
+ queryClient: createMockQueryClient(),
43
+ storage: mockStorage,
44
+ })
45
+
46
+ const result = await tracker.isProcessed('notif-1')
47
+
48
+ expect(result).toBe(true)
49
+ expect(mockStorage.has).toHaveBeenCalledWith('notif-1')
50
+ })
51
+
52
+ it('returns false when storage does not have the notification', async () => {
53
+ const mockApiClient = createMockApiClient()
54
+ const mockStorage = createMockStorage()
55
+ mockStorage.has.mockResolvedValue(false)
56
+
57
+ const tracker = createApiNotificationTracker({
58
+ notificationsApiClient: mockApiClient,
59
+ queryClient: createMockQueryClient(),
60
+ storage: mockStorage,
61
+ })
62
+
63
+ const result = await tracker.isProcessed('notif-1')
64
+
65
+ expect(result).toBe(false)
66
+ expect(mockStorage.has).toHaveBeenCalledWith('notif-1')
67
+ })
68
+ })
69
+
70
+ describe('getProcessedIds', () => {
71
+ it('returns all IDs from storage when storage is provided', async () => {
72
+ const mockApiClient = createMockApiClient()
73
+ const mockStorage = createMockStorage()
74
+ const expectedIds = new Set(['notif-1', 'notif-2', 'notif-3'])
75
+ mockStorage.getAll.mockResolvedValue(expectedIds)
76
+
77
+ const tracker = createApiNotificationTracker({
78
+ notificationsApiClient: mockApiClient,
79
+ queryClient: createMockQueryClient(),
80
+ storage: mockStorage,
81
+ })
82
+
83
+ const result = await tracker.getProcessedIds()
84
+
85
+ expect(result).toBe(expectedIds)
86
+ expect(mockStorage.getAll).toHaveBeenCalled()
87
+ })
88
+ })
89
+
90
+ describe('track', () => {
91
+ it('calls API and updates storage on successful acknowledgment', async () => {
92
+ const mockApiClient = createMockApiClient()
93
+ const mockStorage = createMockStorage()
94
+
95
+ const tracker = createApiNotificationTracker({
96
+ notificationsApiClient: mockApiClient,
97
+ queryClient: createMockQueryClient(),
98
+ storage: mockStorage,
99
+ })
100
+
101
+ await tracker.track('notif-1', mockMetadata)
102
+
103
+ expect(mockApiClient.ackNotification).toHaveBeenCalledWith({
104
+ ids: ['notif-1'],
105
+ })
106
+ expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
107
+ timestamp: mockMetadata.timestamp,
108
+ })
109
+ })
110
+
111
+ it('updates storage and logs error when API call fails', async () => {
112
+ const mockApiClient = createMockApiClient()
113
+ const mockStorage = createMockStorage()
114
+ const apiError = new Error('API request failed')
115
+ mockApiClient.ackNotification = vi.fn().mockRejectedValue(apiError)
116
+
117
+ const tracker = createApiNotificationTracker({
118
+ notificationsApiClient: mockApiClient,
119
+ queryClient: createMockQueryClient(),
120
+ storage: mockStorage,
121
+ })
122
+
123
+ // Should not throw - errors are caught and logged
124
+ await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
125
+
126
+ // Verify storage was still updated despite API failure
127
+ expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
128
+ timestamp: mockMetadata.timestamp,
129
+ })
130
+ })
131
+
132
+ it('logs error when API fails and no storage provided', async () => {
133
+ const mockApiClient = createMockApiClient()
134
+ const apiError = new Error('Network error')
135
+ mockApiClient.ackNotification = vi.fn().mockRejectedValue(apiError)
136
+
137
+ const tracker = createApiNotificationTracker({
138
+ notificationsApiClient: mockApiClient,
139
+ storage: createMockStorage(),
140
+ })
141
+
142
+ // Should not throw - errors are caught and logged
143
+ await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
144
+ })
145
+
146
+ it('calls API and updates storage even when API fails', async () => {
147
+ const mockApiClient = createMockApiClient()
148
+ const mockStorage = createMockStorage()
149
+ const apiError = new Error('Connection timeout')
150
+ mockApiClient.ackNotification = vi.fn().mockRejectedValue(apiError)
151
+
152
+ const tracker = createApiNotificationTracker({
153
+ notificationsApiClient: mockApiClient,
154
+ queryClient: createMockQueryClient(),
155
+ storage: mockStorage,
156
+ })
157
+
158
+ // Should not throw - errors are caught and logged
159
+ await tracker.track('notif-1', mockMetadata)
160
+
161
+ // Verify API was called
162
+ expect(mockApiClient.ackNotification).toHaveBeenCalledWith({
163
+ ids: ['notif-1'],
164
+ })
165
+
166
+ // Verify storage was updated despite API failure
167
+ expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
168
+ timestamp: mockMetadata.timestamp,
169
+ })
170
+ })
171
+
172
+ it('handles non-Error objects thrown by API', async () => {
173
+ const mockApiClient = createMockApiClient()
174
+ const mockStorage = createMockStorage()
175
+ mockApiClient.ackNotification = vi.fn().mockRejectedValue('String error')
176
+
177
+ const tracker = createApiNotificationTracker({
178
+ notificationsApiClient: mockApiClient,
179
+ queryClient: createMockQueryClient(),
180
+ storage: mockStorage,
181
+ })
182
+
183
+ // Should not throw - errors are caught and logged
184
+ await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
185
+
186
+ // Verify storage was updated despite API failure
187
+ expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
188
+ timestamp: mockMetadata.timestamp,
189
+ })
190
+ })
191
+
192
+ it('preserves metadata with different tracking strategies', async () => {
193
+ const mockApiClient = createMockApiClient()
194
+ const mockStorage = createMockStorage()
195
+
196
+ const tracker = createApiNotificationTracker({
197
+ notificationsApiClient: mockApiClient,
198
+ queryClient: createMockQueryClient(),
199
+ storage: mockStorage,
200
+ })
201
+
202
+ const notificationIds = ['notif-1', 'notif-2', 'notif-3', 'notif-4']
203
+
204
+ for (let i = 0; i < notificationIds.length; i++) {
205
+ const metadata: TrackingMetadata = {
206
+ timestamp: Date.now() + i,
207
+ }
208
+
209
+ await tracker.track(notificationIds[i], metadata)
210
+
211
+ expect(mockStorage.add).toHaveBeenCalledWith(notificationIds[i], {
212
+ timestamp: metadata.timestamp,
213
+ })
214
+ }
215
+ })
216
+
217
+ it('makes separate API calls for separate track invocations', async () => {
218
+ const mockApiClient = createMockApiClient()
219
+ const mockStorage = createMockStorage()
220
+
221
+ const tracker = createApiNotificationTracker({
222
+ notificationsApiClient: mockApiClient,
223
+ queryClient: createMockQueryClient(),
224
+ storage: mockStorage,
225
+ })
226
+
227
+ await tracker.track('notif-1', mockMetadata)
228
+ await tracker.track('notif-2', mockMetadata)
229
+ await tracker.track('notif-3', mockMetadata)
230
+
231
+ expect(mockApiClient.ackNotification).toHaveBeenCalledTimes(3)
232
+ expect(mockApiClient.ackNotification).toHaveBeenNthCalledWith(1, { ids: ['notif-1'] })
233
+ expect(mockApiClient.ackNotification).toHaveBeenNthCalledWith(2, { ids: ['notif-2'] })
234
+ expect(mockApiClient.ackNotification).toHaveBeenNthCalledWith(3, { ids: ['notif-3'] })
235
+ })
236
+ })
237
+
238
+ describe('cleanup', () => {
239
+ it('calls storage deleteOlderThan when provided', async () => {
240
+ const mockApiClient = createMockApiClient()
241
+ const mockStorage = createMockStorage()
242
+
243
+ const tracker = createApiNotificationTracker({
244
+ notificationsApiClient: mockApiClient,
245
+ queryClient: createMockQueryClient(),
246
+ storage: mockStorage,
247
+ })
248
+
249
+ expect(tracker.cleanup).toBeDefined()
250
+
251
+ const timestamp = Date.now() - 7 * 24 * 60 * 60 * 1000 // 7 days ago
252
+ await tracker.cleanup?.(timestamp)
253
+
254
+ expect(mockStorage.deleteOlderThan).toHaveBeenCalledWith(timestamp)
255
+ })
256
+
257
+ it('handles multiple cleanup calls with different timestamps', async () => {
258
+ const mockApiClient = createMockApiClient()
259
+ const mockStorage = createMockStorage()
260
+
261
+ const tracker = createApiNotificationTracker({
262
+ notificationsApiClient: mockApiClient,
263
+ queryClient: createMockQueryClient(),
264
+ storage: mockStorage,
265
+ })
266
+
267
+ const timestamp1 = Date.now() - 7 * 24 * 60 * 60 * 1000
268
+ const timestamp2 = Date.now() - 14 * 24 * 60 * 60 * 1000
269
+ const timestamp3 = Date.now() - 30 * 24 * 60 * 60 * 1000
270
+
271
+ await tracker.cleanup?.(timestamp1)
272
+ await tracker.cleanup?.(timestamp2)
273
+ await tracker.cleanup?.(timestamp3)
274
+
275
+ expect(mockStorage.deleteOlderThan).toHaveBeenCalledTimes(3)
276
+ expect(mockStorage.deleteOlderThan).toHaveBeenNthCalledWith(1, timestamp1)
277
+ expect(mockStorage.deleteOlderThan).toHaveBeenNthCalledWith(2, timestamp2)
278
+ expect(mockStorage.deleteOlderThan).toHaveBeenNthCalledWith(3, timestamp3)
279
+ })
280
+ })
281
+
282
+ describe('local notifications', () => {
283
+ it('skips API call for local: prefixed notifications but persists to storage', async () => {
284
+ const mockApiClient = createMockApiClient()
285
+ const mockStorage = createMockStorage()
286
+
287
+ const tracker = createApiNotificationTracker({
288
+ notificationsApiClient: mockApiClient,
289
+ queryClient: createMockQueryClient(),
290
+ storage: mockStorage,
291
+ })
292
+
293
+ await tracker.track('local:backup_reminder', mockMetadata)
294
+
295
+ // API should NOT be called for local notifications
296
+ expect(mockApiClient.ackNotification).not.toHaveBeenCalled()
297
+
298
+ // Storage should still be updated (persists across sessions)
299
+ expect(mockStorage.add).toHaveBeenCalledWith('local:backup_reminder', {
300
+ timestamp: mockMetadata.timestamp,
301
+ })
302
+ })
303
+
304
+ it('skips both API and storage for local:session: prefixed notifications (memory-only)', async () => {
305
+ const mockApiClient = createMockApiClient()
306
+ const mockStorage = createMockStorage()
307
+
308
+ const tracker = createApiNotificationTracker({
309
+ notificationsApiClient: mockApiClient,
310
+ queryClient: createMockQueryClient(),
311
+ storage: mockStorage,
312
+ })
313
+
314
+ await tracker.track('local:session:offline', mockMetadata)
315
+
316
+ // API should NOT be called
317
+ expect(mockApiClient.ackNotification).not.toHaveBeenCalled()
318
+
319
+ // Storage should NOT be updated (session-scoped = memory only)
320
+ expect(mockStorage.add).not.toHaveBeenCalled()
321
+ })
322
+
323
+ it('marks session-scoped notification as processed via pendingAcks', async () => {
324
+ const mockApiClient = createMockApiClient()
325
+ const mockStorage = createMockStorage()
326
+ mockStorage.has.mockResolvedValue(false) // Not in storage
327
+
328
+ const tracker = createApiNotificationTracker({
329
+ notificationsApiClient: mockApiClient,
330
+ queryClient: createMockQueryClient(),
331
+ storage: mockStorage,
332
+ })
333
+
334
+ // Initially not processed
335
+ expect(await tracker.isProcessed('local:session:offline')).toBe(false)
336
+
337
+ // Track it (memory-only)
338
+ await tracker.track('local:session:offline', mockMetadata)
339
+
340
+ // Now processed (via pendingAcks in-memory set)
341
+ expect(await tracker.isProcessed('local:session:offline')).toBe(true)
342
+
343
+ // But storage was NOT written to
344
+ expect(mockStorage.add).not.toHaveBeenCalled()
345
+ })
346
+ })
347
+
348
+ describe('integration scenarios', () => {
349
+ it('prevents race condition: isProcessed returns true immediately after track is called', async () => {
350
+ const mockApiClient = createMockApiClient()
351
+ const mockStorage = createMockStorage()
352
+
353
+ // Simulate slow storage writes (e.g., IndexedDB or localStorage)
354
+ let storageWriteResolver: (() => void) | undefined
355
+ const storageWritePromise = new Promise<void>((resolve) => {
356
+ storageWriteResolver = resolve
357
+ })
358
+ mockStorage.add.mockReturnValue(storageWritePromise)
359
+
360
+ // Storage hasn't been written to yet
361
+ mockStorage.has.mockResolvedValue(false)
362
+
363
+ const tracker = createApiNotificationTracker({
364
+ notificationsApiClient: mockApiClient,
365
+ queryClient: createMockQueryClient(),
366
+ storage: mockStorage,
367
+ })
368
+
369
+ // Initially not processed
370
+ expect(await tracker.isProcessed('notif-1')).toBe(false)
371
+
372
+ // Start tracking (but don't await - simulates in-flight acknowledgment)
373
+ const trackPromise = tracker.track('notif-1', mockMetadata)
374
+
375
+ // CRITICAL: Even though storage.has still returns false and storage write hasn't completed,
376
+ // isProcessed should return true due to in-memory pending set
377
+ // This prevents notifications from re-appearing when data source refetches
378
+ expect(await tracker.isProcessed('notif-1')).toBe(true)
379
+
380
+ // Complete the storage write
381
+ storageWriteResolver?.()
382
+ await trackPromise
383
+
384
+ // Still processed after storage write completes
385
+ mockStorage.has.mockResolvedValue(true)
386
+ expect(await tracker.isProcessed('notif-1')).toBe(true)
387
+ })
388
+
389
+ it('handles complete notification lifecycle', async () => {
390
+ const mockApiClient = createMockApiClient()
391
+ const mockStorage = createMockStorage()
392
+
393
+ const tracker = createApiNotificationTracker({
394
+ notificationsApiClient: mockApiClient,
395
+ queryClient: createMockQueryClient(),
396
+ storage: mockStorage,
397
+ })
398
+
399
+ // Initially not processed
400
+ mockStorage.has.mockResolvedValue(false)
401
+ expect(await tracker.isProcessed('notif-1')).toBe(false)
402
+
403
+ // Track notification
404
+ await tracker.track('notif-1', mockMetadata)
405
+
406
+ // Now marked as processed
407
+ mockStorage.has.mockResolvedValue(true)
408
+ expect(await tracker.isProcessed('notif-1')).toBe(true)
409
+
410
+ // Appears in processed IDs
411
+ mockStorage.getAll.mockResolvedValue(new Set(['notif-1']))
412
+ expect(await tracker.getProcessedIds()).toEqual(new Set(['notif-1']))
413
+
414
+ // Clean up old entries
415
+ await tracker.cleanup?.(Date.now() - 1000)
416
+ expect(mockStorage.deleteOlderThan).toHaveBeenCalled()
417
+ })
418
+
419
+ it('marks notification as processed even when storage fails (UX priority)', async () => {
420
+ const mockApiClient = createMockApiClient()
421
+ const mockStorage = createMockStorage()
422
+
423
+ const tracker = createApiNotificationTracker({
424
+ notificationsApiClient: mockApiClient,
425
+ queryClient: createMockQueryClient(),
426
+ storage: mockStorage,
427
+ })
428
+
429
+ // Simulate storage failure
430
+ mockStorage.add.mockRejectedValue(new Error('Storage quota exceeded'))
431
+ mockStorage.has.mockResolvedValue(false)
432
+
433
+ // Track should not throw - errors are caught and logged
434
+ await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
435
+
436
+ // Verify API was called
437
+ expect(mockApiClient.ackNotification).toHaveBeenCalledWith({
438
+ ids: ['notif-1'],
439
+ })
440
+
441
+ // CRITICAL: Even though storage failed, the notification should still be
442
+ // considered processed due to in-memory pending set (UX priority)
443
+ expect(await tracker.isProcessed('notif-1')).toBe(true)
444
+ })
445
+
446
+ it('tracks same notification multiple times independently without deduplication', async () => {
447
+ const mockApiClient = createMockApiClient()
448
+ const mockStorage = createMockStorage()
449
+
450
+ const tracker = createApiNotificationTracker({
451
+ notificationsApiClient: mockApiClient,
452
+ queryClient: createMockQueryClient(),
453
+ storage: mockStorage,
454
+ })
455
+
456
+ await tracker.track('notif-1', mockMetadata)
457
+ await tracker.track('notif-1', { ...mockMetadata, timestamp: Date.now() + 1000 })
458
+ await tracker.track('notif-1', { ...mockMetadata, timestamp: Date.now() + 2000 })
459
+
460
+ // Each call should independently call the API and storage
461
+ expect(mockApiClient.ackNotification).toHaveBeenCalledTimes(3)
462
+ expect(mockStorage.add).toHaveBeenCalledTimes(3)
463
+ })
464
+ })
465
+ })
@@ -0,0 +1,154 @@
1
+ import type { FetchQueryOptions, QueryClient } from '@tanstack/react-query'
2
+ import type { NotificationsApiClient } from '@luxfi/api'
3
+ import { createNotificationTracker } from '@luxfi/notifications/src/notification-tracker/implementations/createNotificationTracker'
4
+ import {
5
+ NotificationTracker,
6
+ TrackingMetadata,
7
+ } from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
8
+ import { getLogger } from '@luxfi/utilities/src/logger/logger'
9
+ import { ReactQueryCacheKey } from '@luxfi/utilities/src/reactQuery/cache'
10
+
11
+ /**
12
+ * Context for creating an API-based notification tracker
13
+ */
14
+ export interface ApiNotificationTrackerContext {
15
+ notificationsApiClient: NotificationsApiClient
16
+ queryClient: QueryClient
17
+ /**
18
+ * Optional local storage for tracking state (e.g., to avoid duplicate API calls)
19
+ * If not provided, the tracker will always call the API
20
+ */
21
+ storage: {
22
+ has: (notificationId: string) => Promise<boolean>
23
+ add: (notificationId: string, metadata?: { timestamp: number }) => Promise<void>
24
+ getAll: () => Promise<Set<string>>
25
+ deleteOlderThan: (timestamp: number) => Promise<void>
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Creates a notification tracker that uses the backend API to mark notifications as tracked.
31
+ *
32
+ * This implementation calls the AckNotifications API endpoint when a notification is tracked,
33
+ * allowing the backend to record that the user has seen/interacted with the notification.
34
+ *
35
+ * Example usage:
36
+ * ```typescript
37
+ * import { createApiNotificationTracker } from '@luxfi/notifications'
38
+ * import { createNotificationsApiClient } from '@luxfi/api'
39
+ *
40
+ * const apiClient = createNotificationsApiClient({
41
+ * fetchClient: myFetchClient,
42
+ * getApiPathPrefix: () => '/notifications/v1'
43
+ * })
44
+ *
45
+ * const tracker = createApiNotificationTracker({
46
+ * notificationsApiClient: apiClient,
47
+ * queryClient: myQueryClient
48
+ * })
49
+ *
50
+ * // Track a notification (sends request to backend with automatic retries)
51
+ * await tracker.track('notif-123', {
52
+ * timestamp: Date.now()
53
+ * })
54
+ * ```
55
+ */
56
+ export function createApiNotificationTracker(ctx: ApiNotificationTrackerContext): NotificationTracker {
57
+ const { notificationsApiClient, queryClient, storage } = ctx
58
+
59
+ // Track in-flight acknowledgments to prevent race conditions
60
+ // If a notification is being acknowledged but storage write hasn't completed yet,
61
+ // this ensures isProcessed() returns true immediately to prevent re-appearing notifications
62
+ const pendingAcks = new Set<string>()
63
+
64
+ const ackNotificationOptions = (notificationId: string): FetchQueryOptions => ({
65
+ queryKey: [ReactQueryCacheKey.AckNotification, notificationId],
66
+ queryFn: () =>
67
+ notificationsApiClient.ackNotification({
68
+ ids: [notificationId],
69
+ }),
70
+ // Retry up to 3 times for network errors or 5xx server errors
71
+ retry: 3,
72
+ // Exponential backoff: 1s, 2s, 4s, capped at 30s
73
+ retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
74
+ // Don't cache the result - this is a write operation
75
+ gcTime: 0,
76
+ })
77
+
78
+ const isProcessed = async (notificationId: string): Promise<boolean> => {
79
+ if (pendingAcks.has(notificationId)) {
80
+ return true
81
+ }
82
+ return storage.has(notificationId)
83
+ }
84
+
85
+ const getProcessedIds = async (): Promise<Set<string>> => {
86
+ return storage.getAll()
87
+ }
88
+
89
+ const track = async (notificationId: string, metadata: TrackingMetadata): Promise<void> => {
90
+ // Immediately mark as pending (synchronous) to prevent race condition
91
+ // where data source refetches before storage write completes
92
+ pendingAcks.add(notificationId)
93
+
94
+ // Check notification type by prefix
95
+ const isLocalNotification = notificationId.startsWith('local:')
96
+ const isSessionScopedNotification = notificationId.startsWith('local:session:')
97
+
98
+ // Session-scoped notifications only track in memory (pendingAcks), not storage.
99
+ // They will reset on app restart, allowing the notification to show again if
100
+ // the condition is still true (e.g., offline banner shows again after restart).
101
+ if (isSessionScopedNotification) {
102
+ return
103
+ }
104
+
105
+ // Attempt to call the backend API to acknowledge the notification
106
+ try {
107
+ await Promise.all([
108
+ // Skip API call for local notifications since the backend has no knowledge of them
109
+ isLocalNotification ? Promise.resolve() : queryClient.fetchQuery(ackNotificationOptions(notificationId)),
110
+ // Even if API fails, update localStorage so the notification stays dismissed
111
+ // from the user's perspective. This prioritizes UX over perfect backend consistency.
112
+ //
113
+ // Tradeoff: If localStorage is later cleared but the backend never received the ack,
114
+ // the notification could reappear. However, by the time localStorage is cleaned up
115
+ // or cleared, the notification will typically be expired on the backend anyway.
116
+ storage
117
+ .add(notificationId, { timestamp: metadata.timestamp })
118
+ .catch((storageError) => {
119
+ getLogger().error(
120
+ `Storage write failed for notification ${notificationId}: ${storageError instanceof Error ? storageError.message : String(storageError)}`,
121
+ {
122
+ tags: { file: 'createApiNotificationTracker', function: 'track' },
123
+ },
124
+ )
125
+ }),
126
+ ])
127
+ } catch (error) {
128
+ getLogger().error(
129
+ `Failed to acknowledge notification ${notificationId} after retries: ${error instanceof Error ? error.message : String(error)}`,
130
+ {
131
+ tags: {
132
+ file: 'createApiNotificationTracker',
133
+ function: 'track',
134
+ },
135
+ },
136
+ )
137
+ // Keep notification in pendingAcks even on error - prioritize UX
138
+ // The user dismissed it, so it should stay dismissed from their perspective
139
+ }
140
+ // Note: We intentionally don't remove from pendingAcks after completion
141
+ // Once acknowledged, it should always be considered processed
142
+ }
143
+
144
+ const cleanup = async (olderThan: number): Promise<void> => {
145
+ await storage.deleteOlderThan(olderThan)
146
+ }
147
+
148
+ return createNotificationTracker({
149
+ isProcessed,
150
+ getProcessedIds,
151
+ track,
152
+ cleanup,
153
+ })
154
+ }
@@ -0,0 +1,44 @@
1
+ import { createNotificationTracker } from '@luxfi/notifications/src/notification-tracker/implementations/createNotificationTracker'
2
+ import {
3
+ NotificationTracker,
4
+ TrackingMetadata,
5
+ } from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
6
+
7
+ /**
8
+ * Creates a no-op notification tracker that doesn't persist any state.
9
+ *
10
+ * Example usage:
11
+ * ```typescript
12
+ * import { createNoopNotificationTracker } from '@luxfi/notifications'
13
+ *
14
+ * const tracker = createNoopNotificationTracker()
15
+ *
16
+ * // All operations are no-ops
17
+ * const processed = await tracker.isProcessed('notif-123') // always false
18
+ * await tracker.track('notif-123', { timestamp: Date.now(), strategy: 'render' }) // no-op
19
+ * ```
20
+ */
21
+ export function createNoopNotificationTracker(): NotificationTracker {
22
+ const isProcessed = async (_notificationId: string): Promise<boolean> => {
23
+ return false
24
+ }
25
+
26
+ const getProcessedIds = async (): Promise<Set<string>> => {
27
+ return new Set()
28
+ }
29
+
30
+ const track = async (_notificationId: string, _metadata: TrackingMetadata): Promise<void> => {
31
+ // no-op
32
+ }
33
+
34
+ const cleanup = async (_olderThan: number): Promise<void> => {
35
+ // no-op
36
+ }
37
+
38
+ return createNotificationTracker({
39
+ isProcessed,
40
+ getProcessedIds,
41
+ track,
42
+ cleanup,
43
+ })
44
+ }
@@ -0,0 +1,31 @@
1
+ import {
2
+ NotificationTracker,
3
+ TrackingMetadata,
4
+ } from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
5
+
6
+ /**
7
+ * Basic implementation of the NotificationTracker interface.
8
+ */
9
+ export function createNotificationTracker(ctx: {
10
+ isProcessed: (notificationId: string) => Promise<boolean>
11
+ getProcessedIds: () => Promise<Set<string>>
12
+ track: (notificationId: string, metadata: TrackingMetadata) => Promise<void>
13
+ cleanup?: (olderThan: number) => Promise<void>
14
+ }): NotificationTracker {
15
+ return {
16
+ isProcessed: async (notificationId: string): Promise<boolean> => {
17
+ return ctx.isProcessed(notificationId)
18
+ },
19
+ getProcessedIds: async (): Promise<Set<string>> => {
20
+ return ctx.getProcessedIds()
21
+ },
22
+ track: async (notificationId: string, metadata: TrackingMetadata): Promise<void> => {
23
+ return ctx.track(notificationId, metadata)
24
+ },
25
+ cleanup: ctx.cleanup
26
+ ? async (olderThan: number): Promise<void> => {
27
+ return ctx.cleanup?.(olderThan)
28
+ }
29
+ : undefined,
30
+ }
31
+ }