@arkstack/notifications 0.16.10 → 0.16.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ import { Model } from "@arkstack/database";
4
4
  import { User } from "@app/models/User";
5
5
  import { User as User$1 } from "@arkstack/auth";
6
6
  import { PhoneNumber } from "@kanun-hq/plugin-phone";
7
+ import { ArkormCollection, LengthAwarePaginator } from "arkormx";
7
8
 
8
9
  //#region src/Contracts/UserNotification.d.ts
9
10
  declare abstract class UserNotification extends Model {
@@ -67,6 +68,8 @@ type FirebaseTransportConfig = {
67
68
  project_id?: string;
68
69
  client_email?: string;
69
70
  private_key?: string;
71
+ app_name?: string;
72
+ admin_sdk_path?: string;
70
73
  };
71
74
  type RealtimeDriverOptions = {
72
75
  transport?: RealtimeDriverName; /** Channel/topic to broadcast on. Defaults to `${channel_prefix}${user.id}`. */
@@ -90,7 +93,7 @@ type RealtimeNotificationPayload = {
90
93
  };
91
94
  /** The result of a realtime broadcast (plus the stored record when `store` is on). */
92
95
  type RealtimeBroadcastResult = {
93
- channel: string;
96
+ channel: string | string[];
94
97
  event: string;
95
98
  payload: RealtimeNotificationPayload;
96
99
  stored?: UserNotification;
@@ -186,6 +189,10 @@ interface NotificationConfig {
186
189
  project_id: string;
187
190
  client_email: string;
188
191
  private_key: string;
192
+ app_name?: string;
193
+ } | {
194
+ app_name?: string;
195
+ admin_sdk_path: string;
189
196
  };
190
197
  };
191
198
  }
@@ -290,9 +297,13 @@ declare class MailNotification extends NotificationContract {
290
297
  /**
291
298
  * A realtime transport (Pusher, Firebase, …) broadcasts a notification payload
292
299
  * to a channel/topic that connected clients subscribe to.
300
+ *
301
+ * `channel` may be an array: for Pusher it fans out to multiple channels, and
302
+ * for Firebase it is treated as a list of device registration tokens delivered
303
+ * via a multicast send.
293
304
  */
294
305
  interface RealtimeDriver {
295
- broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
306
+ broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
296
307
  }
297
308
  //#endregion
298
309
  //#region src/drivers/RealtimeNotification.d.ts
@@ -316,7 +327,8 @@ declare class RealtimeNotification extends NotificationContract<RealtimeBroadcas
316
327
  from(_from: string): this;
317
328
  subject(subject: string): this;
318
329
  /**
319
- * Set the recipient: a `User` (derives the channel) or an explicit channel string.
330
+ * Set the recipient: a `User` (derives the channel), an explicit channel
331
+ * string, or an array of channels (Pusher) / device tokens (Firebase).
320
332
  *
321
333
  * @param recipient
322
334
  * @returns
@@ -324,11 +336,14 @@ declare class RealtimeNotification extends NotificationContract<RealtimeBroadcas
324
336
  recipient(recipient: NotificationRecipient | User): this;
325
337
  /**
326
338
  * Broadcast on an explicit channel/topic instead of the per-user default.
327
- *
328
- * @param channel
329
- * @returns
330
- */
339
+ * An array broadcasts to multiple Pusher channels, or — for Firebase — to a
340
+ * list of device registration tokens (multicast).
341
+ *
342
+ * @param channel
343
+ * @returns
344
+ */
331
345
  channel(channel: string): this;
346
+ channel(channel: string[]): this;
332
347
  /**
333
348
  * The event name clients subscribe to (default `notification`).
334
349
  *
@@ -343,7 +358,7 @@ declare class RealtimeNotification extends NotificationContract<RealtimeBroadcas
343
358
  * @returns
344
359
  */
345
360
  store(store?: boolean): this;
346
- type(type: DbNotificationType | null): this;
361
+ type(type?: DbNotificationType | null): this;
347
362
  action(text?: string | null, link?: string | null): this;
348
363
  meta(meta?: NotificationData | null): this;
349
364
  private resolveChannel;
@@ -407,12 +422,52 @@ declare class Notification<D extends keyof DriverMap = keyof DriverMap> {
407
422
  //#region src/UserNotificationCenter.d.ts
408
423
  declare class UserNotificationCenter {
409
424
  private static getModel;
425
+ /**
426
+ * Create a database notification then broadcast it using the configured realtime driver
427
+ *
428
+ * @param user
429
+ * @param payload
430
+ */
431
+ static send(user: User, payload: DbNotificationPayload, channel?: string[]): Promise<RealtimeBroadcastResult>;
432
+ /**
433
+ * Create a database notification
434
+ *
435
+ * @param user
436
+ * @param payload
437
+ */
410
438
  static create(user: User, payload: DbNotificationPayload): Promise<UserNotification>;
411
- static forUser(user: User): Promise<import("arkormx").ArkormCollection<UserNotification, UserNotification[]>>;
412
- static unreadForUser(user: User): Promise<import("arkormx").ArkormCollection<UserNotification, UserNotification[]>>;
413
- static markRead(notification: UserNotification | UserNotification['id']): Promise<void>;
414
- static markAllRead(user: User): Promise<void>;
415
- static delete(notification: UserNotification | UserNotification['id']): Promise<void>;
439
+ static forUser(user: User): Promise<ArkormCollection<UserNotification, UserNotification[]>>;
440
+ /**
441
+ * Fetch all the users unread messages
442
+ *
443
+ * @param user
444
+ */
445
+ static unreadForUser(user: User): Promise<ArkormCollection<UserNotification, UserNotification[]>>;
446
+ /**
447
+ * Fetch all the users unread messages with a lenght aware paginator instance
448
+ *
449
+ * @param user
450
+ * @param perPage
451
+ */
452
+ static unreadForUser(user: User, perPage: number): Promise<LengthAwarePaginator<UserNotification>>;
453
+ /**
454
+ * Mark the notification as read
455
+ *
456
+ * @param notification
457
+ */
458
+ static markRead(notification: UserNotification | string | number): Promise<void>;
459
+ /**
460
+ * Mark all unread notifications as read
461
+ *
462
+ * @param user
463
+ */
464
+ static markAllRead(user: User, ids?: string[] | number[]): Promise<void>;
465
+ /**
466
+ * Delete the indicated notifications
467
+ *
468
+ * @param notification
469
+ */
470
+ static delete(notification: UserNotification | string | number | UserNotification[] | string[] | number[]): Promise<void>;
416
471
  }
417
472
  //#endregion
418
473
  //#region src/config.d.ts
@@ -430,13 +485,26 @@ declare class PusherRealtimeDriver implements RealtimeDriver {
430
485
  private clientPromise?;
431
486
  constructor(options?: PusherTransportConfig);
432
487
  private client;
433
- broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
488
+ broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
434
489
  }
435
490
  //#endregion
436
491
  //#region src/drivers/realtime/FirebaseRealtimeDriver.d.ts
437
492
  /**
438
- * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging)
439
- * topics (the channel name maps to an FCM topic).
493
+ * The outcome of a token multicast: totals plus the tokens FCM rejected as dead.
494
+ */
495
+ interface FirebaseMulticastResult {
496
+ successCount: number;
497
+ failureCount: number;
498
+ /**
499
+ * Tokens FCM reported as unregistered/invalid — delete these from your store.
500
+ */
501
+ invalidTokens: string[];
502
+ }
503
+ /**
504
+ * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging).
505
+ * A single channel maps to an FCM topic; an array of channels is treated as
506
+ * device registration tokens and delivered via a multicast send (chunked to
507
+ * FCM's 500-token limit), returning the tokens that should be pruned.
440
508
  *
441
509
  * `firebase-admin` is an optional peer dependency, imported lazily so the
442
510
  * package installs without it; it is only required when this transport is used.
@@ -447,10 +515,15 @@ declare class FirebaseRealtimeDriver implements RealtimeDriver {
447
515
  private messagingPromise?;
448
516
  constructor(options?: FirebaseTransportConfig);
449
517
  private messaging;
450
- broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<string>;
518
+ broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<string | FirebaseMulticastResult>;
519
+ /**
520
+ * Send to many device tokens at once, chunked to FCM's 500-token limit, and
521
+ * collect the tokens FCM rejects as dead so the caller can prune them.
522
+ */
523
+ private multicast;
451
524
  }
452
525
  //#endregion
453
526
  //#region src/utils/template.d.ts
454
527
  declare const interpolate: (value: string, data?: NotificationData) => string;
455
528
  //#endregion
456
- export { AfricasTalkingSmsDriver, DbNotification, DbNotificationPayload, DbNotificationType, DriverResult, FirebaseRealtimeDriver, FirebaseTransportConfig, MailDriverOptions, MailNotification, MailRecipient, MailRecipientAddress, MergedTransportConfig, Notification, NotificationChannel, NotificationConfig, NotificationContract, NotificationData, NotificationDriverMap, NotificationRecipient, PusherRealtimeDriver, PusherTransportConfig, RealtimeBroadcastResult, RealtimeDriver, RealtimeDriverName, RealtimeDriverOptions, RealtimeNotification, RealtimeNotificationPayload, SmsDriverName, SmsDriverOptions, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
529
+ export { AfricasTalkingSmsDriver, DbNotification, DbNotificationPayload, DbNotificationType, DriverResult, FirebaseMulticastResult, FirebaseRealtimeDriver, FirebaseTransportConfig, MailDriverOptions, MailNotification, MailRecipient, MailRecipientAddress, MergedTransportConfig, Notification, NotificationChannel, NotificationConfig, NotificationContract, NotificationData, NotificationDriverMap, NotificationRecipient, PusherRealtimeDriver, PusherTransportConfig, RealtimeBroadcastResult, RealtimeDriver, RealtimeDriverName, RealtimeDriverOptions, RealtimeNotification, RealtimeNotificationPayload, SmsDriverName, SmsDriverOptions, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { config, env, getModel } from "@arkstack/common";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import path, { join } from "node:path";
4
4
  import nodemailer from "nodemailer";
5
5
  import { Arkstack } from "@arkstack/contract";
6
+ import { existsSync, readFileSync } from "node:fs";
6
7
  import { randomUUID } from "node:crypto";
7
8
  import africastalking from "africastalking";
8
9
  import twilio from "twilio";
@@ -28,6 +29,21 @@ var UserNotificationCenter = class {
28
29
  static async getModel() {
29
30
  return await getModel("UserNotification");
30
31
  }
32
+ /**
33
+ * Create a database notification then broadcast it using the configured realtime driver
34
+ *
35
+ * @param user
36
+ * @param payload
37
+ */
38
+ static async send(user, payload, channel = []) {
39
+ return await Notification.realtime().store().channel(user.pushTokens ?? channel).meta(payload.meta).type(payload.type).subject(payload.title).action(payload.actionText, payload.actionLink).send(payload.description, payload.title, void 0, payload);
40
+ }
41
+ /**
42
+ * Create a database notification
43
+ *
44
+ * @param user
45
+ * @param payload
46
+ */
31
47
  static async create(user, payload) {
32
48
  return await (await this.getModel()).query().create({
33
49
  userId: user.id,
@@ -42,12 +58,19 @@ var UserNotificationCenter = class {
42
58
  static async forUser(user) {
43
59
  return await (await this.getModel()).query().where({ userId: user.id }).get();
44
60
  }
45
- static async unreadForUser(user) {
46
- return await (await this.getModel()).query().where({
61
+ static async unreadForUser(user, perPage) {
62
+ const query = (await this.getModel()).query().where({
47
63
  userId: user.id,
48
64
  readAt: null
49
- }).get();
65
+ });
66
+ if (perPage) return await query.paginate();
67
+ return await query.get();
50
68
  }
69
+ /**
70
+ * Mark the notification as read
71
+ *
72
+ * @param notification
73
+ */
51
74
  static async markRead(notification) {
52
75
  const Model = await this.getModel();
53
76
  const id = typeof notification === "object" ? notification.id : notification;
@@ -55,16 +78,33 @@ var UserNotificationCenter = class {
55
78
  await Model.query().where({ id }).update({ readAt });
56
79
  if (typeof notification === "object") notification.readAt = readAt;
57
80
  }
58
- static async markAllRead(user) {
59
- await (await this.getModel()).query().where({
81
+ /**
82
+ * Mark all unread notifications as read
83
+ *
84
+ * @param user
85
+ */
86
+ static async markAllRead(user, ids) {
87
+ const query = (await this.getModel()).query().where({
60
88
  userId: user.id,
61
89
  readAt: null
62
- }).update({ readAt: /* @__PURE__ */ new Date() });
90
+ });
91
+ if (ids) query.whereIn("id", ids);
92
+ await query.update({ readAt: /* @__PURE__ */ new Date() });
63
93
  }
94
+ /**
95
+ * Delete the indicated notifications
96
+ *
97
+ * @param notification
98
+ */
64
99
  static async delete(notification) {
65
100
  const Model = await this.getModel();
66
- const id = typeof notification === "object" ? notification.id : notification;
67
- await Model.query().where({ id }).delete();
101
+ if (Array.isArray(notification)) {
102
+ const ids = notification.map((e) => typeof e === "object" ? e.id : e);
103
+ await Model.query().whereIn("id", ids).delete();
104
+ } else {
105
+ const id = typeof notification === "object" ? notification.id : notification;
106
+ await Model.query().where({ id }).delete();
107
+ }
68
108
  }
69
109
  };
70
110
  //#endregion
@@ -323,9 +363,19 @@ var MailNotification = class extends NotificationContract {
323
363
  };
324
364
  //#endregion
325
365
  //#region src/drivers/realtime/FirebaseRealtimeDriver.ts
366
+ /** FCM caps a multicast at 500 tokens per call. */
367
+ const MULTICAST_LIMIT = 500;
368
+ /** Error codes that mean a token is dead and should be pruned by the app. */
369
+ const DEAD_TOKEN_CODES = /* @__PURE__ */ new Set([
370
+ "messaging/registration-token-not-registered",
371
+ "messaging/invalid-registration-token",
372
+ "messaging/invalid-argument"
373
+ ]);
326
374
  /**
327
- * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging)
328
- * topics (the channel name maps to an FCM topic).
375
+ * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging).
376
+ * A single channel maps to an FCM topic; an array of channels is treated as
377
+ * device registration tokens and delivered via a multicast send (chunked to
378
+ * FCM's 500-token limit), returning the tokens that should be pruned.
329
379
  *
330
380
  * `firebase-admin` is an optional peer dependency, imported lazily so the
331
381
  * package installs without it; it is only required when this transport is used.
@@ -338,16 +388,23 @@ var FirebaseRealtimeDriver = class {
338
388
  this.options = options;
339
389
  }
340
390
  messaging() {
391
+ const appSpecifier = "firebase-admin/app";
392
+ const messagingSpecifier = "firebase-admin/messaging";
341
393
  this.messagingPromise ??= (async () => {
342
- const [app, messaging] = await Promise.all([import("firebase-admin/app"), import("firebase-admin/messaging")]).catch(() => {
394
+ const [app, messaging] = await Promise.all([import(appSpecifier), import(messagingSpecifier)]).catch(() => {
343
395
  throw new Error("The \"firebase-admin\" package is required for the Firebase realtime transport. Install it with `npm i firebase-admin`.");
344
396
  });
345
- const credential = app.cert({
397
+ const adminsdk = path.join(process.cwd(), this.options.admin_sdk_path ?? env("FIREBASE_ADMINSDK", "firebase-adminsdk.json"));
398
+ let serviceAccount;
399
+ try {
400
+ if (existsSync(adminsdk)) serviceAccount = JSON.parse(readFileSync(adminsdk, { encoding: "utf-8" }));
401
+ } catch {}
402
+ const credential = app.cert(serviceAccount ?? {
346
403
  projectId: this.options.project_id ?? env("FIREBASE_PROJECT_ID", ""),
347
404
  clientEmail: this.options.client_email ?? env("FIREBASE_CLIENT_EMAIL", ""),
348
405
  privateKey: (this.options.private_key ?? env("FIREBASE_PRIVATE_KEY", ""))?.replace(/\\n/g, "\n")
349
406
  });
350
- const name = "arkstack-realtime";
407
+ const name = String(this.options.app_name ?? env("FIREBASE_APP_NAME", "arkstack-realtime")).replaceAll(" ", "-").toLowerCase();
351
408
  const instance = app.getApps().find((a) => a.name === name) ?? app.initializeApp({ credential }, name);
352
409
  return messaging.getMessaging(instance);
353
410
  })();
@@ -355,15 +412,41 @@ var FirebaseRealtimeDriver = class {
355
412
  }
356
413
  async broadcast(channel, event, payload) {
357
414
  const messaging = await this.messaging();
415
+ const data = {
416
+ event,
417
+ payload: JSON.stringify(payload)
418
+ };
419
+ if (Array.isArray(channel)) return await this.multicast(messaging, channel, data);
358
420
  const topic = channel.replace(/[^a-zA-Z0-9-_.~%]/g, "_");
359
421
  return await messaging.send({
360
422
  topic,
361
- data: {
362
- event,
363
- payload: JSON.stringify(payload)
364
- }
423
+ data
365
424
  });
366
425
  }
426
+ /**
427
+ * Send to many device tokens at once, chunked to FCM's 500-token limit, and
428
+ * collect the tokens FCM rejects as dead so the caller can prune them.
429
+ */
430
+ async multicast(messaging, tokens, data) {
431
+ const result = {
432
+ successCount: 0,
433
+ failureCount: 0,
434
+ invalidTokens: []
435
+ };
436
+ for (let i = 0; i < tokens.length; i += MULTICAST_LIMIT) {
437
+ const batch = tokens.slice(i, i + MULTICAST_LIMIT);
438
+ const response = await messaging.sendEachForMulticast({
439
+ tokens: batch,
440
+ data
441
+ });
442
+ result.successCount += response.successCount;
443
+ result.failureCount += response.failureCount;
444
+ response.responses.forEach((res, index) => {
445
+ if (!res.success && res.error?.code && DEAD_TOKEN_CODES.has(res.error.code)) result.invalidTokens.push(batch[index]);
446
+ });
447
+ }
448
+ return result;
449
+ }
367
450
  };
368
451
  //#endregion
369
452
  //#region src/drivers/realtime/PusherRealtimeDriver.ts
@@ -380,8 +463,9 @@ var PusherRealtimeDriver = class {
380
463
  this.options = options;
381
464
  }
382
465
  client() {
466
+ const specifier = "pusher";
383
467
  this.clientPromise ??= (async () => {
384
- const mod = await import("pusher").catch(() => {
468
+ const mod = await import(specifier).catch(() => {
385
469
  throw new Error("The \"pusher\" package is required for the Pusher realtime transport. Install it with `npm i pusher`.");
386
470
  });
387
471
  return new (mod.default ?? mod)({
@@ -440,29 +524,24 @@ var RealtimeNotification = class extends NotificationContract {
440
524
  this.payload.title = subject;
441
525
  return this;
442
526
  }
443
- /**
444
- * Set the recipient: a `User` (derives the channel) or an explicit channel string.
445
- *
446
- * @param recipient
447
- * @returns
527
+ /**
528
+ * Set the recipient: a `User` (derives the channel), an explicit channel
529
+ * string, or an array of channels (Pusher) / device tokens (Firebase).
530
+ *
531
+ * @param recipient
532
+ * @returns
448
533
  */
449
534
  recipient(recipient) {
450
535
  if (typeof recipient === "object" && !Array.isArray(recipient) && typeof recipient.id !== "undefined") {
451
536
  this.user = recipient;
452
537
  return this;
453
538
  }
454
- if (typeof recipient === "string") {
539
+ if (typeof recipient === "string" || Array.isArray(recipient)) {
455
540
  this.channelName = recipient;
456
541
  return this;
457
542
  }
458
543
  throw new Error("Realtime notifications require a user recipient or a channel name");
459
544
  }
460
- /**
461
- * Broadcast on an explicit channel/topic instead of the per-user default.
462
- *
463
- * @param channel
464
- * @returns
465
- */
466
545
  channel(channel) {
467
546
  this.channelName = channel;
468
547
  return this;
@@ -501,7 +580,7 @@ var RealtimeNotification = class extends NotificationContract {
501
580
  return this;
502
581
  }
503
582
  resolveChannel() {
504
- if (this.channelName) return this.channelName;
583
+ if (this.channelName !== void 0) return this.channelName;
505
584
  if (this.user) return `${this.channelPrefix}${this.user.id}`;
506
585
  throw new Error("No channel resolved for realtime notification (provide a user or channel)");
507
586
  }
@@ -528,7 +607,7 @@ var RealtimeNotification = class extends NotificationContract {
528
607
  read_at: stored?.readAt ? new Date(stored.readAt).toISOString() : null,
529
608
  created_at: stored?.createdAt ? new Date(stored.createdAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
530
609
  };
531
- await this.driver.broadcast(channel, this.eventName, payload);
610
+ if (channel && channel.length > 0) await this.driver.broadcast(channel, this.eventName, payload);
532
611
  return {
533
612
  channel,
534
613
  event: this.eventName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkstack/notifications",
3
- "version": "0.16.10",
3
+ "version": "0.16.12",
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,14 +34,14 @@
34
34
  "africastalking": "^0.8.0",
35
35
  "nodemailer": "^8.0.7",
36
36
  "twilio": "^6.0.0",
37
- "@arkstack/common": "^0.16.10"
37
+ "@arkstack/common": "^0.16.12"
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.16.10",
44
- "@arkstack/database": "^0.16.10"
43
+ "@arkstack/contract": "^0.16.12",
44
+ "@arkstack/database": "^0.16.12"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "pusher": {