@arkstack/notifications 0.18.6 → 0.18.8

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 (3) hide show
  1. package/dist/index.d.ts +372 -138
  2. package/dist/index.js +249 -15
  3. package/package.json +7 -7
package/dist/index.d.ts CHANGED
@@ -8,6 +8,203 @@ import { User as User$1 } from "@arkstack/auth";
8
8
  import { Attachment } from "nodemailer/lib/mailer/index.js";
9
9
  import { PhoneNumber } from "@kanun-hq/plugin-phone";
10
10
  import { ArkormCollection, LengthAwarePaginator } from "arkormx";
11
+ //#region src/drivers/realtime/FirebaseRealtimeDriver.d.ts
12
+ /**
13
+ * The outcome of a token multicast: totals plus the tokens FCM rejected as dead.
14
+ */
15
+ interface FirebaseMulticastResult {
16
+ successCount: number;
17
+ failureCount: number;
18
+ /**
19
+ * Tokens FCM reported as unregistered/invalid — delete these from your store.
20
+ */
21
+ invalidTokens: string[];
22
+ }
23
+ /**
24
+ * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging).
25
+ * A single channel maps to an FCM topic; an array of channels is treated as
26
+ * device registration tokens and delivered via a multicast send (chunked to
27
+ * FCM's 500-token limit), returning the tokens that should be pruned.
28
+ *
29
+ * `firebase-admin` is an optional peer dependency, imported lazily so the
30
+ * package installs without it; it is only required when this transport is used.
31
+ * FCM data values must be strings, so the payload is JSON-encoded.
32
+ */
33
+ declare class FirebaseRealtimeDriver implements RealtimeDriver {
34
+ private options;
35
+ private messagingPromise?;
36
+ constructor(options?: FirebaseTransportConfig);
37
+ private messaging;
38
+ broadcast(channel: string | string[], event: string, payload: RealtimeBroadcastPayload, delivery?: RealtimeDeliveryOptions): Promise<string | FirebaseMulticastResult>;
39
+ /**
40
+ * Translate transport-agnostic delivery hints into FCM's platform blocks.
41
+ *
42
+ * Per-send options win over the transport defaults, and the raw `android` /
43
+ * `apns` / `webpush` escape hatches are merged last so a caller can always
44
+ * override what this derives.
45
+ *
46
+ * `priority` always maps to Android and Web Push. Whether it maps to APNs
47
+ * depends on the message rather than on the platform: Apple requires a
48
+ * background push to be priority 5 or 1, and a data-only message — what this
49
+ * driver sends by default — arrives on iOS as exactly that. So the header is
50
+ * derived only once the message is demonstrably a *visible* push, which a
51
+ * caller declares through the `apns` escape hatch. Guessing otherwise would
52
+ * mean sending APNs something it is documented to reject.
53
+ *
54
+ * @param delivery
55
+ * @returns
56
+ */
57
+ private overridesFor;
58
+ /**
59
+ * Whether the caller has built a push iOS will actually display.
60
+ *
61
+ * Apple allows `apns-priority: 10` only on a push that shows the user
62
+ * something; a background push must be 5 or 1. A data-only message is a
63
+ * background push, so this is false for the default this driver sends, and
64
+ * true once a caller has declared an alert through the `apns` escape hatch —
65
+ * by push type, or by supplying an alert payload.
66
+ *
67
+ * @param apns
68
+ * @returns
69
+ */
70
+ private isVisiblePush;
71
+ /**
72
+ * Fold a caller's raw override over the derived settings, dropping the block
73
+ * entirely when neither contributed anything.
74
+ *
75
+ * @param key
76
+ * @param derived
77
+ * @param override
78
+ * @returns
79
+ */
80
+ private flatBlock;
81
+ /**
82
+ * As `flatBlock`, but for the platforms whose settings live under `headers` —
83
+ * merged key by key, so an override adding one header keeps the rest.
84
+ *
85
+ * @param key
86
+ * @param derived
87
+ * @param override
88
+ * @returns
89
+ */
90
+ private headerBlock;
91
+ /**
92
+ * Firebase channels do not require server-side authorization.
93
+ *
94
+ * @param _socketId
95
+ * @param _channel
96
+ * @param _data
97
+ */
98
+ auth(_socketId: string, _channel: string, _data?: unknown): Promise<void>;
99
+ /**
100
+ * Firebase does not require an authorization route.
101
+ *
102
+ * @param _authEndpoint
103
+ * @param _middleware
104
+ * @param _channelPrefix
105
+ * @param _config
106
+ */
107
+ static registerAuthRoute(_authEndpoint?: string, _middleware?: unknown | unknown[], _channelPrefix?: string, _config?: FirebaseTransportConfig): Promise<void>;
108
+ /**
109
+ * Firebase does not require an authorization route.
110
+ *
111
+ * @param _authEndpoint
112
+ * @param _middleware
113
+ * @param _channelPrefix
114
+ */
115
+ registerAuthRoute(_authEndpoint?: string, _middleware?: unknown | unknown[], _channelPrefix?: string): Promise<void>;
116
+ /**
117
+ * Send to many device tokens at once, chunked to FCM's 500-token limit, and
118
+ * collect the tokens FCM rejects as dead so the caller can prune them.
119
+ */
120
+ private multicast;
121
+ }
122
+ //#endregion
123
+ //#region src/drivers/realtime/PusherRealtimeDriver.d.ts
124
+ /** The slice of the `pusher` server SDK this driver uses. */
125
+ interface PusherClient {
126
+ trigger(channel: string | string[], event: string, data: unknown): Promise<unknown>;
127
+ authorizeChannel(socketId: string, channel: string, data?: {
128
+ user_id: string;
129
+ user_info?: {
130
+ [key: string]: any;
131
+ };
132
+ }): {
133
+ auth: string;
134
+ channel_data?: string;
135
+ shared_secret?: string;
136
+ };
137
+ }
138
+ /**
139
+ * Broadcasts notifications over [Pusher Channels](https://pusher.com/channels).
140
+ *
141
+ * The `pusher` server SDK is an optional peer dependency, imported lazily so the
142
+ * package installs without it; it is only required when this transport is used.
143
+ */
144
+ declare class PusherRealtimeDriver implements RealtimeDriver {
145
+ private options;
146
+ private clientPromise?;
147
+ constructor(options?: PusherTransportConfig);
148
+ private client;
149
+ /**
150
+ * `delivery` is accepted for contract parity and ignored: Pusher pushes over a
151
+ * connection the client already holds open, so there is no dozing radio to
152
+ * wake and no per-message priority, TTL or collapse key to set.
153
+ *
154
+ * @param channel
155
+ * @param event
156
+ * @param payload
157
+ * @param _delivery
158
+ * @returns
159
+ */
160
+ broadcast(channel: string | string[], event: string, payload: RealtimeBroadcastPayload, _delivery?: RealtimeDeliveryOptions): Promise<unknown>;
161
+ /**
162
+ * Authourize a pusher channel
163
+ *
164
+ * @param socketId
165
+ * @param channel
166
+ * @returns
167
+ */
168
+ auth(socketId: string, channel: string, data?: Parameters<PusherClient['authorizeChannel']>[2]): Promise<ReturnType<PusherClient['authorizeChannel']>>;
169
+ /**
170
+ * Register a realtime autorization route
171
+ *
172
+ * @param authEndpoint
173
+ * @param middleware
174
+ * @param config
175
+ * @param channelPrefix
176
+ */
177
+ static registerAuthRoute(authEndpoint?: string, middleware?: unknown | unknown[], channelPrefix?: string, config?: PusherTransportConfig): Promise<void>;
178
+ /**
179
+ * Register a realtime autorization route
180
+ *
181
+ * @param authEndpoint
182
+ * @param middleware
183
+ * @param channelPrefix
184
+ */
185
+ registerAuthRoute(authEndpoint?: string, middleware?: unknown | unknown[], channelPrefix?: string): Promise<void>;
186
+ }
187
+ //#endregion
188
+ //#region src/Contracts/RealtimeDriver.d.ts
189
+ /**
190
+ * A realtime transport (Pusher, Firebase, …) broadcasts a notification payload
191
+ * to a channel/topic that connected clients subscribe to.
192
+ *
193
+ * `channel` may be an array: for Pusher it fans out to multiple channels, and
194
+ * for Firebase it is treated as a list of device registration tokens delivered
195
+ * via a multicast send.
196
+ *
197
+ * `delivery` carries transport-level hints — priority, TTL, collapse key. It is
198
+ * optional so existing implementations stay source-compatible, and transports
199
+ * without a notion of priority (Pusher) are free to ignore it.
200
+ */
201
+ interface RealtimeDriver {
202
+ broadcast(channel: string | string[], event: string, payload: RealtimeBroadcastPayload, delivery?: RealtimeDeliveryOptions): Promise<unknown>;
203
+ auth(socketId: string, channel: string, data?: unknown): unknown | Promise<unknown>;
204
+ registerAuthRoute(authEndpoint?: string, middleware?: unknown | unknown[], channelPrefix?: string): Promise<void>;
205
+ }
206
+ type RealtimeNotificationDriver<T extends RealtimeDriverName> = T extends 'firebase' ? FirebaseRealtimeDriver : T extends 'pusher' ? PusherRealtimeDriver : RealtimeDriver;
207
+ //#endregion
11
208
  //#region src/Contracts/UserNotification.d.ts
12
209
  declare abstract class UserNotification extends Model {
13
210
  [key: string]: any;
@@ -92,6 +289,53 @@ type FirebaseTransportConfig = {
92
289
  private_key?: string;
93
290
  app_name?: string;
94
291
  admin_sdk_path?: string;
292
+ /** Delivery options applied to every send on this transport. */
293
+ delivery?: RealtimeDeliveryOptions;
294
+ };
295
+ /**
296
+ * Per-send delivery hints for push transports.
297
+ *
298
+ * FCM defaults a data message to normal priority, which Doze and App Standby are
299
+ * free to defer until the next maintenance window — exactly the state a phone is
300
+ * in when something time-critical — an approval someone is waiting on, an alert
301
+ * about to go stale — needs to wake it.
302
+ * `priority: 'high'` is what exempts the message.
303
+ *
304
+ * Only the Firebase transport acts on these; Pusher holds an open connection and
305
+ * has no equivalent, so it ignores them.
306
+ */
307
+ type RealtimeDeliveryOptions = {
308
+ /**
309
+ * `high` wakes a dozing device (FCM `android.priority`, APNs priority 10).
310
+ * Defaults to `normal`. Budgeted by FCM — reserve it for messages a user is
311
+ * actually waiting on.
312
+ */
313
+ priority?: 'normal' | 'high';
314
+ /**
315
+ * How long, in **seconds**, the message stays worth delivering. FCM's own
316
+ * default is four weeks; anything time-critical wants far less, so a stale
317
+ * signal expires instead of surfacing long after the moment it described has
318
+ * passed. `0` asks for now-or-never.
319
+ */
320
+ ttl?: number;
321
+ /**
322
+ * Supersede an undelivered message with the same key rather than stacking a
323
+ * second one — e.g. the id of whatever is being signalled, so a retry replaces
324
+ * the message it repeats.
325
+ */
326
+ collapseKey?: string;
327
+ /**
328
+ * Merged over the derived `android` block; escape hatch for anything unmapped.
329
+ */
330
+ android?: Record<string, unknown>;
331
+ /**
332
+ * Merged over the derived `apns` block; escape hatch for anything unmapped.
333
+ */
334
+ apns?: Record<string, unknown>;
335
+ /**
336
+ * Merged over the derived `webpush` block; escape hatch for anything unmapped.
337
+ */
338
+ webpush?: Record<string, unknown>;
95
339
  };
96
340
  type RealtimeDriverOptions<T extends RealtimeDriverName = RealtimeDriverName> = {
97
341
  transport?: T;
@@ -101,6 +345,17 @@ type RealtimeDriverOptions<T extends RealtimeDriverName = RealtimeDriverName> =
101
345
  event?: string;
102
346
  /** Also persist the notification to the database (requires a User recipient). */
103
347
  store?: boolean;
348
+ /** Delivery hints (priority, TTL, collapse key) for push transports. */
349
+ delivery?: RealtimeDeliveryOptions;
350
+ /**
351
+ * Build the driver yourself, bypassing the built-ins. Anything satisfying
352
+ * {@link RealtimeDriver} works — a transport this package does not ship (APNs
353
+ * or PushKit, say), or a fake in a test. Supplying this ignores `transport`.
354
+ *
355
+ * Called synchronously; do connection setup lazily inside the driver, as the
356
+ * bundled ones do, so constructing a notification never blocks on a network.
357
+ */
358
+ driverFactory?: () => RealtimeDriver;
104
359
  pusher?: PusherTransportConfig;
105
360
  firebase?: FirebaseTransportConfig;
106
361
  };
@@ -113,9 +368,33 @@ type RealtimeNotificationPayload = {
113
368
  actionText?: string | null;
114
369
  actionLink?: string | null;
115
370
  meta?: NotificationData | null;
371
+ /**
372
+ * Stable identity for what this notification is *about* — an order, an
373
+ * incident, an approval. A later notification carrying the same tag supersedes
374
+ * this one rather than stacking beside it, so a client can replace what it
375
+ * already showed. Unbounded and client-side; unrelated to the transport's
376
+ * `collapseKey`, which is a queue slot with a much smaller budget.
377
+ */
378
+ tag?: string | null;
379
+ /**
380
+ * Marks this message as an instruction to *remove* the notification carrying
381
+ * `tag`, rather than as a notification to display. Absent means display.
382
+ *
383
+ * Prefer superseding with replacement content where there is any: a retraction
384
+ * has nothing to show, so it must travel as a silent push, and silent pushes
385
+ * are the ones platforms throttle hardest.
386
+ */
387
+ retracted?: boolean;
116
388
  read_at: string | null;
117
389
  created_at: string;
118
390
  };
391
+ /**
392
+ * What a transport will carry. Notifications are the common case and what the
393
+ * builder produces, but a channel is just a channel — an application can push its
394
+ * own event shapes over the same one and pick them up with the client's
395
+ * `listen()`, which has always accepted arbitrary events.
396
+ */
397
+ type RealtimeBroadcastPayload = RealtimeNotificationPayload | Record<string, unknown>;
119
398
  /** The result of a realtime broadcast (plus the stored record when `store` is on). */
120
399
  type RealtimeBroadcastResult = {
121
400
  channel: string | string[];
@@ -158,13 +437,17 @@ interface NotificationConfig<T = any> {
158
437
  table: string;
159
438
  };
160
439
  realtime?: {
161
- transport: RealtimeDriverName;
440
+ transport?: RealtimeDriverName;
162
441
  /** Prefix for the per-user channel/topic (default `user.`). */
163
442
  channel_prefix?: string;
164
443
  /** Event name clients subscribe to (default `notification`). */
165
444
  event?: string;
166
445
  /** Persist broadcasts to the database by default. */
167
446
  store?: boolean;
447
+ /** Delivery hints applied to every broadcast unless overridden per-send. */
448
+ delivery?: RealtimeDeliveryOptions;
449
+ /** Build the driver yourself for every realtime notification. */
450
+ driverFactory?: () => RealtimeDriver;
168
451
  };
169
452
  };
170
453
  transports: {
@@ -295,7 +578,7 @@ declare class DbNotification extends NotificationContract<UserNotification> {
295
578
  * @param realtime Set to false to disable realtime broadcast
296
579
  */
297
580
  broadcast(realtime?: boolean): void;
298
- create(user: User, payload: DbNotificationPayload): Promise<import("arkormx").Model<string | import("arkormx").ModelQuerySchemaLike | Record<string, unknown>, Record<string, unknown>>>;
581
+ create(user: User, payload: DbNotificationPayload): Promise<UserNotification>;
299
582
  /**
300
583
  * Send the database notification
301
584
  *
@@ -305,7 +588,7 @@ declare class DbNotification extends NotificationContract<UserNotification> {
305
588
  * @param data
306
589
  * @returns
307
590
  */
308
- send(message: string, subject?: string, _recipient?: NotificationRecipient, data?: NotificationData): Promise<import("arkormx").Model<string | import("arkormx").ModelQuerySchemaLike | Record<string, unknown>, Record<string, unknown>>>;
591
+ send(message: string, subject?: string, _recipient?: NotificationRecipient, data?: NotificationData): Promise<UserNotification>;
309
592
  }
310
593
  //#endregion
311
594
  //#region src/drivers/MailNotification.d.ts
@@ -397,136 +680,6 @@ declare class MailNotification extends NotificationContract {
397
680
  private normalizeRecipient;
398
681
  }
399
682
  //#endregion
400
- //#region src/drivers/realtime/FirebaseRealtimeDriver.d.ts
401
- /**
402
- * The outcome of a token multicast: totals plus the tokens FCM rejected as dead.
403
- */
404
- interface FirebaseMulticastResult {
405
- successCount: number;
406
- failureCount: number;
407
- /**
408
- * Tokens FCM reported as unregistered/invalid — delete these from your store.
409
- */
410
- invalidTokens: string[];
411
- }
412
- /**
413
- * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging).
414
- * A single channel maps to an FCM topic; an array of channels is treated as
415
- * device registration tokens and delivered via a multicast send (chunked to
416
- * FCM's 500-token limit), returning the tokens that should be pruned.
417
- *
418
- * `firebase-admin` is an optional peer dependency, imported lazily so the
419
- * package installs without it; it is only required when this transport is used.
420
- * FCM data values must be strings, so the payload is JSON-encoded.
421
- */
422
- declare class FirebaseRealtimeDriver implements RealtimeDriver {
423
- private options;
424
- private messagingPromise?;
425
- constructor(options?: FirebaseTransportConfig);
426
- private messaging;
427
- broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<string | FirebaseMulticastResult>;
428
- /**
429
- * Firebase channels do not require server-side authorization.
430
- *
431
- * @param _socketId
432
- * @param _channel
433
- * @param _data
434
- */
435
- auth(_socketId: string, _channel: string, _data?: unknown): Promise<void>;
436
- /**
437
- * Firebase does not require an authorization route.
438
- *
439
- * @param _authEndpoint
440
- * @param _middleware
441
- * @param _channelPrefix
442
- * @param _config
443
- */
444
- static registerAuthRoute(_authEndpoint?: string, _middleware?: unknown | unknown[], _channelPrefix?: string, _config?: FirebaseTransportConfig): Promise<void>;
445
- /**
446
- * Firebase does not require an authorization route.
447
- *
448
- * @param _authEndpoint
449
- * @param _middleware
450
- * @param _channelPrefix
451
- */
452
- registerAuthRoute(_authEndpoint?: string, _middleware?: unknown | unknown[], _channelPrefix?: string): Promise<void>;
453
- /**
454
- * Send to many device tokens at once, chunked to FCM's 500-token limit, and
455
- * collect the tokens FCM rejects as dead so the caller can prune them.
456
- */
457
- private multicast;
458
- }
459
- //#endregion
460
- //#region src/drivers/realtime/PusherRealtimeDriver.d.ts
461
- /** The slice of the `pusher` server SDK this driver uses. */
462
- interface PusherClient {
463
- trigger(channel: string | string[], event: string, data: unknown): Promise<unknown>;
464
- authorizeChannel(socketId: string, channel: string, data?: {
465
- user_id: string;
466
- user_info?: {
467
- [key: string]: any;
468
- };
469
- }): {
470
- auth: string;
471
- channel_data?: string;
472
- shared_secret?: string;
473
- };
474
- }
475
- /**
476
- * Broadcasts notifications over [Pusher Channels](https://pusher.com/channels).
477
- *
478
- * The `pusher` server SDK is an optional peer dependency, imported lazily so the
479
- * package installs without it; it is only required when this transport is used.
480
- */
481
- declare class PusherRealtimeDriver implements RealtimeDriver {
482
- private options;
483
- private clientPromise?;
484
- constructor(options?: PusherTransportConfig);
485
- private client;
486
- broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
487
- /**
488
- * Authourize a pusher channel
489
- *
490
- * @param socketId
491
- * @param channel
492
- * @returns
493
- */
494
- auth(socketId: string, channel: string, data?: Parameters<PusherClient['authorizeChannel']>[2]): Promise<ReturnType<PusherClient['authorizeChannel']>>;
495
- /**
496
- * Register a realtime autorization route
497
- *
498
- * @param authEndpoint
499
- * @param middleware
500
- * @param config
501
- * @param channelPrefix
502
- */
503
- static registerAuthRoute(authEndpoint?: string, middleware?: unknown | unknown[], channelPrefix?: string, config?: PusherTransportConfig): Promise<void>;
504
- /**
505
- * Register a realtime autorization route
506
- *
507
- * @param authEndpoint
508
- * @param middleware
509
- * @param channelPrefix
510
- */
511
- registerAuthRoute(authEndpoint?: string, middleware?: unknown | unknown[], channelPrefix?: string): Promise<void>;
512
- }
513
- //#endregion
514
- //#region src/Contracts/RealtimeDriver.d.ts
515
- /**
516
- * A realtime transport (Pusher, Firebase, …) broadcasts a notification payload
517
- * to a channel/topic that connected clients subscribe to.
518
- *
519
- * `channel` may be an array: for Pusher it fans out to multiple channels, and
520
- * for Firebase it is treated as a list of device registration tokens delivered
521
- * via a multicast send.
522
- */
523
- interface RealtimeDriver {
524
- broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
525
- auth(socketId: string, channel: string, data?: unknown): unknown | Promise<unknown>;
526
- registerAuthRoute(authEndpoint?: string, middleware?: unknown | unknown[], channelPrefix?: string): Promise<void>;
527
- }
528
- type RealtimeNotificationDriver<T extends RealtimeDriverName> = T extends 'firebase' ? FirebaseRealtimeDriver : T extends 'pusher' ? PusherRealtimeDriver : RealtimeDriver;
529
- //#endregion
530
683
  //#region src/drivers/RealtimeNotification.d.ts
531
684
  /**
532
685
  * Broadcasts a notification to connected clients over a realtime transport
@@ -535,7 +688,9 @@ type RealtimeNotificationDriver<T extends RealtimeDriverName> = T extends 'fireb
535
688
  */
536
689
  declare class RealtimeNotification<T extends RealtimeDriverName = RealtimeDriverName> extends NotificationContract<RealtimeBroadcastResult> {
537
690
  /**
538
- * The underlying transport; assignable so tests can inject a fake.
691
+ * The underlying transport. Assignable, and configurable up front through
692
+ * `driverFactory`, so a transport this package does not ship — or a fake in a
693
+ * test — can stand in for the built-ins.
539
694
  */
540
695
  driver: RealtimeNotificationDriver<T>;
541
696
  private user?;
@@ -543,6 +698,9 @@ declare class RealtimeNotification<T extends RealtimeDriverName = RealtimeDriver
543
698
  private eventName;
544
699
  private channelPrefix;
545
700
  private shouldStore;
701
+ private tagName?;
702
+ private isRetraction;
703
+ private deliveryOptions;
546
704
  private payload;
547
705
  constructor(options?: RealtimeDriverOptions<T>);
548
706
  from(_: string): this;
@@ -590,6 +748,82 @@ declare class RealtimeNotification<T extends RealtimeDriverName = RealtimeDriver
590
748
  type(type?: DbNotificationType | null): this;
591
749
  action(text?: string | null, link?: string | null): this;
592
750
  meta(meta?: NotificationData | null): this;
751
+ /**
752
+ * How hard the transport should work to deliver this message.
753
+ *
754
+ * FCM sends a data message at normal priority by default, and Doze and App
755
+ * Standby may hold a normal-priority message until the next maintenance
756
+ * window — which is precisely the state a phone is in when something needs to
757
+ * wake it. `high` is what exempts the message.
758
+ *
759
+ * FCM budgets high-priority sends, so reserve it for messages the user is
760
+ * actually waiting on. Pusher ignores this.
761
+ *
762
+ * @param priority
763
+ * @returns
764
+ */
765
+ priority(priority?: 'normal' | 'high'): this;
766
+ /**
767
+ * How long, in seconds, this message is still worth delivering.
768
+ *
769
+ * FCM keeps a message for four weeks by default. Anything time-critical wants
770
+ * far less — a signal that arrives after the moment has passed is worse than
771
+ * one that never arrives — so pair a short TTL with `priority('high')`. `0`
772
+ * means deliver now or drop it.
773
+ *
774
+ * @param seconds
775
+ * @returns
776
+ */
777
+ ttl(seconds: number): this;
778
+ /**
779
+ * Supersede an earlier undelivered message instead of stacking beside it.
780
+ * Key it by the thing being signalled — an approval id, say — so a repeat replaces
781
+ * the message it repeats.
782
+ *
783
+ * @param key
784
+ * @returns
785
+ */
786
+ collapseKey(key: string): this;
787
+ /**
788
+ * Merge raw delivery options, including the per-platform `android` / `apns` /
789
+ * `webpush` escape hatches for anything the helpers above do not cover.
790
+ *
791
+ * @param delivery
792
+ * @returns
793
+ */
794
+ delivery(delivery: RealtimeDeliveryOptions): this;
795
+ /**
796
+ * Give this notification a stable identity, so a later one can supersede it.
797
+ *
798
+ * Tag by the thing the notification is *about* — `order:42`, `incident:7` —
799
+ * not by the message. A client that already showed a notification with this
800
+ * tag replaces it in place instead of stacking a second one beside it.
801
+ *
802
+ * This is a client-side identity and is deliberately **not** wired to
803
+ * `collapseKey`: a collapse key is a transport queue slot, and FCM keeps only
804
+ * four per device, so auto-deriving one per tag would quietly evict others.
805
+ * When you want both — supersede what is queued *and* what was displayed —
806
+ * set both to the same value.
807
+ *
808
+ * @param tag
809
+ * @returns
810
+ */
811
+ tag(tag: string): this;
812
+ /**
813
+ * Send an instruction to remove the notification carrying this tag, rather
814
+ * than a notification to display. Nothing is persisted, even under `store()`.
815
+ *
816
+ * Reach for this only when there is genuinely nothing left to say. Where the
817
+ * outcome has its own content — an approval answered elsewhere, a check that
818
+ * went from failing to passing — supersede it by sending that content under
819
+ * the same `tag()`. A replacement travels as an ordinary notification, while a
820
+ * retraction has nothing to display and so must travel silently, which is
821
+ * exactly what Doze, App Standby and iOS throttle hardest.
822
+ *
823
+ * @param retract
824
+ * @returns
825
+ */
826
+ retract(retract?: boolean): this;
593
827
  private resolveChannel;
594
828
  /**
595
829
  * Send a realtime notification using the default realtime driver
@@ -600,7 +834,7 @@ declare class RealtimeNotification<T extends RealtimeDriverName = RealtimeDriver
600
834
  * @param data
601
835
  * @returns
602
836
  */
603
- send(message: string, subject?: string, _recipient?: NotificationRecipient, data?: NotificationData): Promise<RealtimeBroadcastResult>;
837
+ send(message?: string, subject?: string, _recipient?: NotificationRecipient, data?: NotificationData): Promise<RealtimeBroadcastResult>;
604
838
  }
605
839
  //#endregion
606
840
  //#region src/drivers/sms/AfricasTalkingSmsDriver.d.ts
@@ -724,8 +958,8 @@ declare class UserNotificationCenter {
724
958
  * @param user
725
959
  * @param payload
726
960
  */
727
- static create(user: User, payload: DbNotificationPayload): Promise<import("arkormx").Model<string | import("arkormx").ModelQuerySchemaLike | Record<string, unknown>, Record<string, unknown>>>;
728
- static forUser(user: User): Promise<ArkormCollection<import("arkormx").Model<string | import("arkormx").ModelQuerySchemaLike | Record<string, unknown>, Record<string, unknown>>, import("arkormx").Model<string | import("arkormx").ModelQuerySchemaLike | Record<string, unknown>, Record<string, unknown>>[]>>;
961
+ static create(user: User, payload: DbNotificationPayload): Promise<UserNotification>;
962
+ static forUser(user: User): Promise<ArkormCollection<UserNotification, UserNotification[]>>;
729
963
  /**
730
964
  * Fetch all the users unread messages
731
965
  *
@@ -765,4 +999,4 @@ declare const configure: <T extends DotPath<NotificationConfig>>(key: T, default
765
999
  //#region src/utils/template.d.ts
766
1000
  declare const interpolate: (value: string, data?: NotificationData) => string;
767
1001
  //#endregion
768
- export { AfricasTalkingSmsDriver, DbNotification, DbNotificationPayload, DbNotificationType, DriverResult, FirebaseMulticastResult, FirebaseRealtimeDriver, FirebaseTransportConfig, MailDriverOptions, MailNotification, MailNotificationOptions, MailRecipient, MailRecipientAddress, MergedTransportConfig, Notification, NotificationChannel, NotificationConfig, NotificationContract, NotificationData, NotificationDriverMap, NotificationRecipient, PusherRealtimeDriver, PusherTransportConfig, RealtimeBroadcastResult, RealtimeDriver, RealtimeDriverName, RealtimeDriverOptions, RealtimeNotification, RealtimeNotificationDriver, RealtimeNotificationPayload, SmsDriverName, SmsDriverOptions, SmsNotification, Transport, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
1002
+ export { AfricasTalkingSmsDriver, DbNotification, DbNotificationPayload, DbNotificationType, DriverResult, FirebaseMulticastResult, FirebaseRealtimeDriver, FirebaseTransportConfig, MailDriverOptions, MailNotification, MailNotificationOptions, MailRecipient, MailRecipientAddress, MergedTransportConfig, Notification, NotificationChannel, NotificationConfig, NotificationContract, NotificationData, NotificationDriverMap, NotificationRecipient, PusherRealtimeDriver, PusherTransportConfig, RealtimeBroadcastPayload, RealtimeBroadcastResult, RealtimeDeliveryOptions, RealtimeDriver, RealtimeDriverName, RealtimeDriverOptions, RealtimeNotification, RealtimeNotificationDriver, RealtimeNotificationPayload, SmsDriverName, SmsDriverOptions, SmsNotification, Transport, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { Job } from "@arkstack/jobs";
2
1
  import { RequestException, config as config$1, env, getModel } from "@arkstack/common";
2
+ import { Job } from "@arkstack/jobs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import path, { join } from "node:path";
5
5
  import nodemailer from "nodemailer";
@@ -74,7 +74,7 @@ var NotificationContract = class {
74
74
  //#region src/UserNotificationCenter.ts
75
75
  var UserNotificationCenter = class {
76
76
  static async getModel() {
77
- return await getModel("UserNotification");
77
+ return getModel("UserNotification");
78
78
  }
79
79
  /**
80
80
  * Create a database notification then broadcast it using the configured realtime driver
@@ -209,7 +209,7 @@ var DbNotification = class extends NotificationContract {
209
209
  this.realtime = realtime;
210
210
  }
211
211
  async create(user, payload) {
212
- await getModel("UserNotification");
212
+ getModel("UserNotification");
213
213
  if (this.realtime) return await UserNotificationCenter.send(user, payload);
214
214
  return await UserNotificationCenter.create(user, payload);
215
215
  }
@@ -547,20 +547,133 @@ var FirebaseRealtimeDriver = class {
547
547
  })();
548
548
  return this.messagingPromise;
549
549
  }
550
- async broadcast(channel, event, payload) {
550
+ async broadcast(channel, event, payload, delivery) {
551
551
  const messaging = await this.messaging();
552
552
  const data = {
553
553
  event,
554
554
  payload: JSON.stringify(payload)
555
555
  };
556
- if (Array.isArray(channel)) return await this.multicast(messaging, channel, data);
556
+ const overrides = this.overridesFor(delivery);
557
+ if (Array.isArray(channel)) return await this.multicast(messaging, channel, data, overrides);
557
558
  const topic = channel.replace(/[^a-zA-Z0-9-_.~%]/g, "_");
558
559
  return await messaging.send({
559
560
  topic,
560
- data
561
+ data,
562
+ ...overrides
561
563
  });
562
564
  }
563
565
  /**
566
+ * Translate transport-agnostic delivery hints into FCM's platform blocks.
567
+ *
568
+ * Per-send options win over the transport defaults, and the raw `android` /
569
+ * `apns` / `webpush` escape hatches are merged last so a caller can always
570
+ * override what this derives.
571
+ *
572
+ * `priority` always maps to Android and Web Push. Whether it maps to APNs
573
+ * depends on the message rather than on the platform: Apple requires a
574
+ * background push to be priority 5 or 1, and a data-only message — what this
575
+ * driver sends by default — arrives on iOS as exactly that. So the header is
576
+ * derived only once the message is demonstrably a *visible* push, which a
577
+ * caller declares through the `apns` escape hatch. Guessing otherwise would
578
+ * mean sending APNs something it is documented to reject.
579
+ *
580
+ * @param delivery
581
+ * @returns
582
+ */
583
+ overridesFor(delivery) {
584
+ const base = this.options.delivery ?? {};
585
+ const merged = {
586
+ ...base,
587
+ ...delivery,
588
+ android: {
589
+ ...base.android,
590
+ ...delivery?.android
591
+ },
592
+ apns: {
593
+ ...base.apns,
594
+ ...delivery?.apns
595
+ },
596
+ webpush: {
597
+ ...base.webpush,
598
+ ...delivery?.webpush
599
+ }
600
+ };
601
+ const android = {};
602
+ const apnsHeaders = {};
603
+ const webpushHeaders = {};
604
+ if (merged.priority) {
605
+ android.priority = merged.priority;
606
+ webpushHeaders.Urgency = merged.priority;
607
+ if (this.isVisiblePush(merged.apns)) apnsHeaders["apns-priority"] = merged.priority === "high" ? "10" : "5";
608
+ }
609
+ if (merged.collapseKey) {
610
+ android.collapseKey = merged.collapseKey;
611
+ apnsHeaders["apns-collapse-id"] = merged.collapseKey;
612
+ }
613
+ if (typeof merged.ttl === "number") {
614
+ android.ttl = merged.ttl * 1e3;
615
+ apnsHeaders["apns-expiration"] = merged.ttl === 0 ? "0" : String(Math.floor(Date.now() / 1e3) + merged.ttl);
616
+ webpushHeaders.TTL = String(merged.ttl);
617
+ }
618
+ return {
619
+ ...this.flatBlock("android", android, merged.android),
620
+ ...this.headerBlock("apns", apnsHeaders, merged.apns),
621
+ ...this.headerBlock("webpush", webpushHeaders, merged.webpush)
622
+ };
623
+ }
624
+ /**
625
+ * Whether the caller has built a push iOS will actually display.
626
+ *
627
+ * Apple allows `apns-priority: 10` only on a push that shows the user
628
+ * something; a background push must be 5 or 1. A data-only message is a
629
+ * background push, so this is false for the default this driver sends, and
630
+ * true once a caller has declared an alert through the `apns` escape hatch —
631
+ * by push type, or by supplying an alert payload.
632
+ *
633
+ * @param apns
634
+ * @returns
635
+ */
636
+ isVisiblePush(apns) {
637
+ const headers = apns?.headers;
638
+ const aps = (apns?.payload)?.aps;
639
+ return headers?.["apns-push-type"] === "alert" || aps?.alert !== void 0;
640
+ }
641
+ /**
642
+ * Fold a caller's raw override over the derived settings, dropping the block
643
+ * entirely when neither contributed anything.
644
+ *
645
+ * @param key
646
+ * @param derived
647
+ * @param override
648
+ * @returns
649
+ */
650
+ flatBlock(key, derived, override) {
651
+ const block = {
652
+ ...derived,
653
+ ...override
654
+ };
655
+ return Object.keys(block).length > 0 ? { [key]: block } : {};
656
+ }
657
+ /**
658
+ * As `flatBlock`, but for the platforms whose settings live under `headers` —
659
+ * merged key by key, so an override adding one header keeps the rest.
660
+ *
661
+ * @param key
662
+ * @param derived
663
+ * @param override
664
+ * @returns
665
+ */
666
+ headerBlock(key, derived, override) {
667
+ const { headers: overrideHeaders, ...rest } = override ?? {};
668
+ const headers = {
669
+ ...derived,
670
+ ...overrideHeaders
671
+ };
672
+ const block = { ...rest };
673
+ if (Object.keys(headers).length > 0) block.headers = headers;
674
+ return Object.keys(block).length > 0 ? { [key]: block } : {};
675
+ }
676
+ /**
564
677
  * Firebase channels do not require server-side authorization.
565
678
  *
566
679
  * @param _socketId
@@ -589,7 +702,7 @@ var FirebaseRealtimeDriver = class {
589
702
  * Send to many device tokens at once, chunked to FCM's 500-token limit, and
590
703
  * collect the tokens FCM rejects as dead so the caller can prune them.
591
704
  */
592
- async multicast(messaging, tokens, data) {
705
+ async multicast(messaging, tokens, data, overrides = {}) {
593
706
  const result = {
594
707
  successCount: 0,
595
708
  failureCount: 0,
@@ -599,7 +712,8 @@ var FirebaseRealtimeDriver = class {
599
712
  const batch = tokens.slice(i, i + MULTICAST_LIMIT);
600
713
  const response = await messaging.sendEachForMulticast({
601
714
  tokens: batch,
602
- data
715
+ data,
716
+ ...overrides
603
717
  });
604
718
  result.successCount += response.successCount;
605
719
  result.failureCount += response.failureCount;
@@ -640,7 +754,18 @@ var PusherRealtimeDriver = class PusherRealtimeDriver {
640
754
  })();
641
755
  return this.clientPromise;
642
756
  }
643
- async broadcast(channel, event, payload) {
757
+ /**
758
+ * `delivery` is accepted for contract parity and ignored: Pusher pushes over a
759
+ * connection the client already holds open, so there is no dozing radio to
760
+ * wake and no per-message priority, TTL or collapse key to set.
761
+ *
762
+ * @param channel
763
+ * @param event
764
+ * @param payload
765
+ * @param _delivery
766
+ * @returns
767
+ */
768
+ async broadcast(channel, event, payload, _delivery) {
644
769
  return await (await this.client()).trigger(channel, event, payload);
645
770
  }
646
771
  /**
@@ -704,8 +829,10 @@ var PusherRealtimeDriver = class PusherRealtimeDriver {
704
829
  * when `store` is enabled, is also persisted so the client can load history.
705
830
  */
706
831
  var RealtimeNotification = class extends NotificationContract {
707
- /**
708
- * The underlying transport; assignable so tests can inject a fake.
832
+ /**
833
+ * The underlying transport. Assignable, and configurable up front through
834
+ * `driverFactory`, so a transport this package does not ship — or a fake in a
835
+ * test — can stand in for the built-ins.
709
836
  */
710
837
  driver;
711
838
  user;
@@ -713,6 +840,9 @@ var RealtimeNotification = class extends NotificationContract {
713
840
  eventName;
714
841
  channelPrefix;
715
842
  shouldStore;
843
+ tagName;
844
+ isRetraction = false;
845
+ deliveryOptions;
716
846
  payload = {};
717
847
  constructor(options = {}) {
718
848
  super();
@@ -723,7 +853,12 @@ var RealtimeNotification = class extends NotificationContract {
723
853
  this.eventName = options.event ?? driverConfig?.event ?? "notification";
724
854
  this.channelPrefix = driverConfig?.channel_prefix ?? "user.";
725
855
  this.shouldStore = options.store ?? driverConfig?.store ?? false;
726
- this.driver = transport === "firebase" ? new FirebaseRealtimeDriver({
856
+ this.deliveryOptions = {
857
+ ...driverConfig?.delivery,
858
+ ...options.delivery
859
+ };
860
+ const factory = options.driverFactory ?? driverConfig?.driverFactory;
861
+ this.driver = factory ? factory() : transport === "firebase" ? new FirebaseRealtimeDriver({
727
862
  ...transportConfig,
728
863
  ...options.firebase
729
864
  }) : new PusherRealtimeDriver({
@@ -811,6 +946,103 @@ var RealtimeNotification = class extends NotificationContract {
811
946
  this.payload.meta = meta;
812
947
  return this;
813
948
  }
949
+ /**
950
+ * How hard the transport should work to deliver this message.
951
+ *
952
+ * FCM sends a data message at normal priority by default, and Doze and App
953
+ * Standby may hold a normal-priority message until the next maintenance
954
+ * window — which is precisely the state a phone is in when something needs to
955
+ * wake it. `high` is what exempts the message.
956
+ *
957
+ * FCM budgets high-priority sends, so reserve it for messages the user is
958
+ * actually waiting on. Pusher ignores this.
959
+ *
960
+ * @param priority
961
+ * @returns
962
+ */
963
+ priority(priority = "high") {
964
+ this.deliveryOptions.priority = priority;
965
+ return this;
966
+ }
967
+ /**
968
+ * How long, in seconds, this message is still worth delivering.
969
+ *
970
+ * FCM keeps a message for four weeks by default. Anything time-critical wants
971
+ * far less — a signal that arrives after the moment has passed is worse than
972
+ * one that never arrives — so pair a short TTL with `priority('high')`. `0`
973
+ * means deliver now or drop it.
974
+ *
975
+ * @param seconds
976
+ * @returns
977
+ */
978
+ ttl(seconds) {
979
+ this.deliveryOptions.ttl = seconds;
980
+ return this;
981
+ }
982
+ /**
983
+ * Supersede an earlier undelivered message instead of stacking beside it.
984
+ * Key it by the thing being signalled — an approval id, say — so a repeat replaces
985
+ * the message it repeats.
986
+ *
987
+ * @param key
988
+ * @returns
989
+ */
990
+ collapseKey(key) {
991
+ this.deliveryOptions.collapseKey = key;
992
+ return this;
993
+ }
994
+ /**
995
+ * Merge raw delivery options, including the per-platform `android` / `apns` /
996
+ * `webpush` escape hatches for anything the helpers above do not cover.
997
+ *
998
+ * @param delivery
999
+ * @returns
1000
+ */
1001
+ delivery(delivery) {
1002
+ this.deliveryOptions = {
1003
+ ...this.deliveryOptions,
1004
+ ...delivery
1005
+ };
1006
+ return this;
1007
+ }
1008
+ /**
1009
+ * Give this notification a stable identity, so a later one can supersede it.
1010
+ *
1011
+ * Tag by the thing the notification is *about* — `order:42`, `incident:7` —
1012
+ * not by the message. A client that already showed a notification with this
1013
+ * tag replaces it in place instead of stacking a second one beside it.
1014
+ *
1015
+ * This is a client-side identity and is deliberately **not** wired to
1016
+ * `collapseKey`: a collapse key is a transport queue slot, and FCM keeps only
1017
+ * four per device, so auto-deriving one per tag would quietly evict others.
1018
+ * When you want both — supersede what is queued *and* what was displayed —
1019
+ * set both to the same value.
1020
+ *
1021
+ * @param tag
1022
+ * @returns
1023
+ */
1024
+ tag(tag) {
1025
+ this.tagName = tag;
1026
+ return this;
1027
+ }
1028
+ /**
1029
+ * Send an instruction to remove the notification carrying this tag, rather
1030
+ * than a notification to display. Nothing is persisted, even under `store()`.
1031
+ *
1032
+ * Reach for this only when there is genuinely nothing left to say. Where the
1033
+ * outcome has its own content — an approval answered elsewhere, a check that
1034
+ * went from failing to passing — supersede it by sending that content under
1035
+ * the same `tag()`. A replacement travels as an ordinary notification, while a
1036
+ * retraction has nothing to display and so must travel silently, which is
1037
+ * exactly what Doze, App Standby and iOS throttle hardest.
1038
+ *
1039
+ * @param retract
1040
+ * @returns
1041
+ */
1042
+ retract(retract = true) {
1043
+ this.isRetraction = retract;
1044
+ return this;
1045
+ }
814
1046
  resolveChannel() {
815
1047
  if (this.channelName !== void 0) return this.channelName;
816
1048
  if (this.user) return `${this.channelPrefix}${this.user.id}`;
@@ -825,7 +1057,7 @@ var RealtimeNotification = class extends NotificationContract {
825
1057
  * @param data
826
1058
  * @returns
827
1059
  */
828
- async send(message, subject, _recipient, data) {
1060
+ async send(message = "", subject, _recipient, data) {
829
1061
  const channel = this.resolveChannel();
830
1062
  const mergedData = this.mergeData(data);
831
1063
  const base = {
@@ -836,7 +1068,7 @@ var RealtimeNotification = class extends NotificationContract {
836
1068
  actionLink: this.payload.actionLink ?? null,
837
1069
  meta: this.payload.meta ?? null
838
1070
  };
839
- const stored = this.shouldStore && this.user ? await UserNotificationCenter.create(this.user, base) : void 0;
1071
+ const stored = this.shouldStore && this.user && !this.isRetraction ? await UserNotificationCenter.create(this.user, base) : void 0;
840
1072
  const payload = {
841
1073
  id: stored ? String(stored.id) : randomUUID(),
842
1074
  type: base.type ?? null,
@@ -845,10 +1077,12 @@ var RealtimeNotification = class extends NotificationContract {
845
1077
  actionText: base.actionText ?? null,
846
1078
  actionLink: base.actionLink ?? null,
847
1079
  meta: base.meta ?? null,
1080
+ ...this.tagName !== void 0 ? { tag: this.tagName } : {},
1081
+ ...this.isRetraction ? { retracted: true } : {},
848
1082
  read_at: stored?.readAt ? new Date(stored.readAt).toISOString() : null,
849
1083
  created_at: stored?.createdAt ? new Date(stored.createdAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
850
1084
  };
851
- if (channel && channel.length > 0) await this.driver.broadcast(channel, this.eventName, payload);
1085
+ if (channel && channel.length > 0) await this.driver.broadcast(channel, this.eventName, payload, this.deliveryOptions);
852
1086
  return {
853
1087
  channel,
854
1088
  event: this.eventName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkstack/notifications",
3
- "version": "0.18.6",
3
+ "version": "0.18.8",
4
4
  "type": "module",
5
5
  "description": "Framework-agnostic notification module for Arkstack and Nodejs, providing support for multi-channel notification delivery.",
6
6
  "homepage": "https://arkstack.toneflix.net/guide/notifications",
@@ -34,17 +34,17 @@
34
34
  "africastalking": "^0.8.0",
35
35
  "nodemailer": "^9.0.3",
36
36
  "twilio": "^6.0.2",
37
- "@arkstack/common": "^0.18.6"
37
+ "@arkstack/common": "^0.18.8"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@kanun-hq/plugin-phone": "^0.1.8",
41
41
  "firebase-admin": "^13.0.0",
42
42
  "pusher": "^5.2.0",
43
- "@arkstack/contract": "^0.18.6",
44
- "@arkstack/database": "^0.18.6",
45
- "@arkstack/driver-express": "^0.18.6",
46
- "@arkstack/driver-h3": "^0.18.6",
47
- "@arkstack/jobs": "^0.18.6"
43
+ "@arkstack/database": "^0.18.8",
44
+ "@arkstack/driver-express": "^0.18.8",
45
+ "@arkstack/driver-h3": "^0.18.8",
46
+ "@arkstack/contract": "^0.18.8",
47
+ "@arkstack/jobs": "^0.18.8"
48
48
  },
49
49
  "peerDependenciesMeta": {
50
50
  "@arkstack/driver-express": {