@arkstack/notifications 0.16.6 → 0.16.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.
package/dist/index.d.ts CHANGED
@@ -28,7 +28,8 @@ type MailRecipientAddress = Record<string, string>;
28
28
  type MailRecipient = string | MailRecipientAddress | Array<string | MailRecipientAddress>;
29
29
  type NotificationData = Record<string, unknown>;
30
30
  type SmsDriverName = 'africastalking' | 'twilio';
31
- type NotificationChannel = 'mail' | 'sms' | 'db';
31
+ type RealtimeDriverName = 'pusher' | 'firebase';
32
+ type NotificationChannel = 'mail' | 'sms' | 'db' | 'realtime';
32
33
  type MailDriverOptions = {
33
34
  transport?: 'africastalking' | 'twilio' | 'file' | 'smtp';
34
35
  host?: string;
@@ -54,6 +55,45 @@ type SmsDriverOptions = {
54
55
  from?: string;
55
56
  };
56
57
  };
58
+ type PusherTransportConfig = {
59
+ app_id?: string;
60
+ key?: string;
61
+ secret?: string;
62
+ cluster?: string;
63
+ use_tls?: boolean;
64
+ };
65
+ type FirebaseTransportConfig = {
66
+ project_id?: string;
67
+ client_email?: string;
68
+ private_key?: string;
69
+ };
70
+ type RealtimeDriverOptions = {
71
+ transport?: RealtimeDriverName; /** Channel/topic to broadcast on. Defaults to `${channel_prefix}${user.id}`. */
72
+ channel?: string; /** Event name clients subscribe to. Defaults to config `event` or `notification`. */
73
+ event?: string; /** Also persist the notification to the database (requires a User recipient). */
74
+ store?: boolean;
75
+ pusher?: PusherTransportConfig;
76
+ firebase?: FirebaseTransportConfig;
77
+ };
78
+ /** The notification payload delivered to realtime clients. */
79
+ type RealtimeNotificationPayload = {
80
+ id: string;
81
+ type: DbNotificationType | null;
82
+ title: string;
83
+ description: string;
84
+ actionText?: string | null;
85
+ actionLink?: string | null;
86
+ meta?: NotificationData | null;
87
+ read_at: string | null;
88
+ created_at: string;
89
+ };
90
+ /** The result of a realtime broadcast (plus the stored record when `store` is on). */
91
+ type RealtimeBroadcastResult = {
92
+ channel: string;
93
+ event: string;
94
+ payload: RealtimeNotificationPayload;
95
+ stored?: UserNotification;
96
+ };
57
97
  type DbNotificationType = 'transaction' | 'pocket' | 'family' | 'security' | 'promo' | 'bill' | 'goal' | string;
58
98
  type DbNotificationPayload = {
59
99
  type?: DbNotificationType | null;
@@ -68,13 +108,17 @@ type NotificationDriverMap = {
68
108
  mail: DriverResult;
69
109
  sms: DriverResult;
70
110
  db: UserNotification;
111
+ realtime: RealtimeBroadcastResult;
71
112
  };
72
113
  interface NotificationConfig {
73
114
  default_driver: 'mail' | 'sms' | 'db';
74
115
  drivers: {
75
116
  mail: {
76
117
  transport: 'smtp' | 'file';
77
- from: string;
118
+ from: string | {
119
+ name: string;
120
+ address: string;
121
+ };
78
122
  test_address: string;
79
123
  };
80
124
  sms: {
@@ -84,6 +128,12 @@ interface NotificationConfig {
84
128
  db: {
85
129
  table: string;
86
130
  };
131
+ realtime?: {
132
+ transport: RealtimeDriverName; /** Prefix for the per-user channel/topic (default `user.`). */
133
+ channel_prefix?: string; /** Event name clients subscribe to (default `notification`). */
134
+ event?: string; /** Persist broadcasts to the database by default. */
135
+ store?: boolean;
136
+ };
87
137
  };
88
138
  transports: {
89
139
  smtp: {
@@ -124,6 +174,18 @@ interface NotificationConfig {
124
174
  authToken: string;
125
175
  from: string;
126
176
  };
177
+ pusher?: {
178
+ app_id: string;
179
+ key: string;
180
+ secret: string;
181
+ cluster: string;
182
+ use_tls?: boolean;
183
+ };
184
+ firebase?: {
185
+ project_id: string;
186
+ client_email: string;
187
+ private_key: string;
188
+ };
127
189
  };
128
190
  }
129
191
  type MergedTransportConfig = MergedConfig<NotificationConfig['transports'][NonNullable<MailDriverOptions['transport']>]>;
@@ -223,6 +285,70 @@ declare class MailNotification extends NotificationContract {
223
285
  private normalizeRecipient;
224
286
  }
225
287
  //#endregion
288
+ //#region src/Contracts/RealtimeDriver.d.ts
289
+ /**
290
+ * A realtime transport (Pusher, Firebase, …) broadcasts a notification payload
291
+ * to a channel/topic that connected clients subscribe to.
292
+ */
293
+ interface RealtimeDriver {
294
+ broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
295
+ }
296
+ //#endregion
297
+ //#region src/drivers/RealtimeNotification.d.ts
298
+ /**
299
+ * Broadcasts a notification to connected clients over a realtime transport
300
+ * (Pusher or Firebase). The notification is delivered on a per-user channel and,
301
+ * when `store` is enabled, is also persisted so the client can load history.
302
+ */
303
+ declare class RealtimeNotification extends NotificationContract<RealtimeBroadcastResult> {
304
+ /**
305
+ * The underlying transport; assignable so tests can inject a fake.
306
+ */
307
+ driver: RealtimeDriver;
308
+ private user?;
309
+ private channelName?;
310
+ private eventName;
311
+ private channelPrefix;
312
+ private shouldStore;
313
+ private payload;
314
+ constructor(options?: RealtimeDriverOptions);
315
+ from(_from: string): this;
316
+ subject(subject: string): this;
317
+ /**
318
+ * Set the recipient: a `User` (derives the channel) or an explicit channel string.
319
+ *
320
+ * @param recipient
321
+ * @returns
322
+ */
323
+ recipient(recipient: NotificationRecipient | User): this;
324
+ /**
325
+ * Broadcast on an explicit channel/topic instead of the per-user default.
326
+ *
327
+ * @param channel
328
+ * @returns
329
+ */
330
+ channel(channel: string): this;
331
+ /**
332
+ * The event name clients subscribe to (default `notification`).
333
+ *
334
+ * @param channel
335
+ * @returns
336
+ */
337
+ event(event: string): this;
338
+ /**
339
+ * Also persist the notification (requires a `User` recipient).
340
+ *
341
+ * @param channel
342
+ * @returns
343
+ */
344
+ store(store?: boolean): this;
345
+ type(type: DbNotificationType | null): this;
346
+ action(text?: string | null, link?: string | null): this;
347
+ meta(meta?: NotificationData | null): this;
348
+ private resolveChannel;
349
+ send(message: string, subject?: string, _recipient?: NotificationRecipient, data?: NotificationData): Promise<RealtimeBroadcastResult>;
350
+ }
351
+ //#endregion
226
352
  //#region src/drivers/sms/AfricasTalkingSmsDriver.d.ts
227
353
  declare class AfricasTalkingSmsDriver {
228
354
  private driver;
@@ -258,17 +384,20 @@ type DriverMap = {
258
384
  email: MailNotification;
259
385
  sms: SmsNotification;
260
386
  db: DbNotification;
387
+ realtime: RealtimeNotification;
261
388
  };
262
389
  //#endregion
263
390
  //#region src/Notification.d.ts
391
+ type DriverOptions = MailDriverOptions | SmsDriverOptions | RealtimeDriverOptions;
264
392
  declare class Notification<D extends keyof DriverMap = keyof DriverMap> {
265
393
  private driver;
266
- constructor(driver: D, options?: MailDriverOptions | SmsDriverOptions);
394
+ constructor(driver: D, options?: DriverOptions);
267
395
  static mail(options?: MailDriverOptions): MailNotification;
268
396
  static email(options?: MailDriverOptions): MailNotification;
269
397
  static sms(options?: SmsDriverOptions): SmsNotification;
270
398
  static db(): DbNotification;
271
- static channel(channel?: NotificationChannel | 'email', options?: MailDriverOptions | SmsDriverOptions): MailNotification | SmsNotification | DbNotification;
399
+ static realtime(options?: RealtimeDriverOptions): RealtimeNotification;
400
+ static channel(channel?: NotificationChannel | 'email', options?: DriverOptions): MailNotification | SmsNotification | DbNotification | RealtimeNotification;
272
401
  prepare(recipient?: null | MailRecipient | NotificationRecipient | User, data?: NotificationData): DriverMap[D];
273
402
  private static createDriver;
274
403
  }
@@ -287,7 +416,39 @@ declare class UserNotificationCenter {
287
416
  //#region src/config.d.ts
288
417
  declare const configure: <T extends DotPath<NotificationConfig>>(key: T, defaultValue: unknown) => DotPathValue<NotificationConfig, T>;
289
418
  //#endregion
419
+ //#region src/drivers/realtime/PusherRealtimeDriver.d.ts
420
+ /**
421
+ * Broadcasts notifications over [Pusher Channels](https://pusher.com/channels).
422
+ *
423
+ * The `pusher` server SDK is an optional peer dependency, imported lazily so the
424
+ * package installs without it; it is only required when this transport is used.
425
+ */
426
+ declare class PusherRealtimeDriver implements RealtimeDriver {
427
+ private options;
428
+ private clientPromise?;
429
+ constructor(options?: PusherTransportConfig);
430
+ private client;
431
+ broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
432
+ }
433
+ //#endregion
434
+ //#region src/drivers/realtime/FirebaseRealtimeDriver.d.ts
435
+ /**
436
+ * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging)
437
+ * topics (the channel name maps to an FCM topic).
438
+ *
439
+ * `firebase-admin` is an optional peer dependency, imported lazily so the
440
+ * package installs without it; it is only required when this transport is used.
441
+ * FCM data values must be strings, so the payload is JSON-encoded.
442
+ */
443
+ declare class FirebaseRealtimeDriver implements RealtimeDriver {
444
+ private options;
445
+ private messagingPromise?;
446
+ constructor(options?: FirebaseTransportConfig);
447
+ private messaging;
448
+ broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<string>;
449
+ }
450
+ //#endregion
290
451
  //#region src/utils/template.d.ts
291
452
  declare const interpolate: (value: string, data?: NotificationData) => string;
292
453
  //#endregion
293
- export { AfricasTalkingSmsDriver, DbNotification, DbNotificationPayload, DbNotificationType, DriverResult, MailDriverOptions, MailNotification, MailRecipient, MailRecipientAddress, MergedTransportConfig, Notification, NotificationChannel, NotificationConfig, NotificationContract, NotificationData, NotificationDriverMap, NotificationRecipient, SmsDriverName, SmsDriverOptions, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
454
+ 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 };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import nodemailer from "nodemailer";
5
5
  import { Arkstack } from "@arkstack/contract";
6
+ import { randomUUID } from "node:crypto";
6
7
  import africastalking from "africastalking";
7
8
  import twilio from "twilio";
8
9
  import { Model } from "@arkstack/database";
@@ -87,7 +88,7 @@ var DbNotification = class extends NotificationContract {
87
88
  return this;
88
89
  }
89
90
  recipient(recipient) {
90
- if (typeof recipient === "object" && !Array.isArray(recipient) && "id" in recipient) {
91
+ if (typeof recipient === "object" && !Array.isArray(recipient) && typeof recipient.id !== "undefined") {
91
92
  this.user = recipient;
92
93
  return this;
93
94
  }
@@ -321,6 +322,222 @@ var MailNotification = class extends NotificationContract {
321
322
  }
322
323
  };
323
324
  //#endregion
325
+ //#region src/drivers/realtime/FirebaseRealtimeDriver.ts
326
+ /**
327
+ * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging)
328
+ * topics (the channel name maps to an FCM topic).
329
+ *
330
+ * `firebase-admin` is an optional peer dependency, imported lazily so the
331
+ * package installs without it; it is only required when this transport is used.
332
+ * FCM data values must be strings, so the payload is JSON-encoded.
333
+ */
334
+ var FirebaseRealtimeDriver = class {
335
+ options;
336
+ messagingPromise;
337
+ constructor(options = {}) {
338
+ this.options = options;
339
+ }
340
+ messaging() {
341
+ this.messagingPromise ??= (async () => {
342
+ const [app, messaging] = await Promise.all([import("firebase-admin/app"), import("firebase-admin/messaging")]).catch(() => {
343
+ throw new Error("The \"firebase-admin\" package is required for the Firebase realtime transport. Install it with `npm i firebase-admin`.");
344
+ });
345
+ const credential = app.cert({
346
+ projectId: this.options.project_id ?? env("FIREBASE_PROJECT_ID", ""),
347
+ clientEmail: this.options.client_email ?? env("FIREBASE_CLIENT_EMAIL", ""),
348
+ privateKey: (this.options.private_key ?? env("FIREBASE_PRIVATE_KEY", ""))?.replace(/\\n/g, "\n")
349
+ });
350
+ const name = "arkstack-realtime";
351
+ const instance = app.getApps().find((a) => a.name === name) ?? app.initializeApp({ credential }, name);
352
+ return messaging.getMessaging(instance);
353
+ })();
354
+ return this.messagingPromise;
355
+ }
356
+ async broadcast(channel, event, payload) {
357
+ const messaging = await this.messaging();
358
+ const topic = channel.replace(/[^a-zA-Z0-9-_.~%]/g, "_");
359
+ return await messaging.send({
360
+ topic,
361
+ data: {
362
+ event,
363
+ payload: JSON.stringify(payload)
364
+ }
365
+ });
366
+ }
367
+ };
368
+ //#endregion
369
+ //#region src/drivers/realtime/PusherRealtimeDriver.ts
370
+ /**
371
+ * Broadcasts notifications over [Pusher Channels](https://pusher.com/channels).
372
+ *
373
+ * The `pusher` server SDK is an optional peer dependency, imported lazily so the
374
+ * package installs without it; it is only required when this transport is used.
375
+ */
376
+ var PusherRealtimeDriver = class {
377
+ options;
378
+ clientPromise;
379
+ constructor(options = {}) {
380
+ this.options = options;
381
+ }
382
+ client() {
383
+ this.clientPromise ??= (async () => {
384
+ const mod = await import("pusher").catch(() => {
385
+ throw new Error("The \"pusher\" package is required for the Pusher realtime transport. Install it with `npm i pusher`.");
386
+ });
387
+ return new (mod.default ?? mod)({
388
+ appId: this.options.app_id ?? env("PUSHER_APP_ID", ""),
389
+ key: this.options.key ?? env("PUSHER_KEY", ""),
390
+ secret: this.options.secret ?? env("PUSHER_SECRET", ""),
391
+ cluster: this.options.cluster ?? env("PUSHER_CLUSTER", "mt1"),
392
+ useTLS: this.options.use_tls ?? true
393
+ });
394
+ })();
395
+ return this.clientPromise;
396
+ }
397
+ async broadcast(channel, event, payload) {
398
+ return await (await this.client()).trigger(channel, event, payload);
399
+ }
400
+ };
401
+ //#endregion
402
+ //#region src/drivers/RealtimeNotification.ts
403
+ /**
404
+ * Broadcasts a notification to connected clients over a realtime transport
405
+ * (Pusher or Firebase). The notification is delivered on a per-user channel and,
406
+ * when `store` is enabled, is also persisted so the client can load history.
407
+ */
408
+ var RealtimeNotification = class extends NotificationContract {
409
+ /**
410
+ * The underlying transport; assignable so tests can inject a fake.
411
+ */
412
+ driver;
413
+ user;
414
+ channelName;
415
+ eventName;
416
+ channelPrefix;
417
+ shouldStore;
418
+ payload = {};
419
+ constructor(options = {}) {
420
+ super();
421
+ const driverConfig = configure("drivers.realtime", {});
422
+ const transport = options.transport ?? driverConfig?.transport ?? "pusher";
423
+ const transportConfig = configure(`transports.${transport}`, {});
424
+ this.channelName = options.channel;
425
+ this.eventName = options.event ?? driverConfig?.event ?? "notification";
426
+ this.channelPrefix = driverConfig?.channel_prefix ?? "user.";
427
+ this.shouldStore = options.store ?? driverConfig?.store ?? false;
428
+ this.driver = transport === "firebase" ? new FirebaseRealtimeDriver({
429
+ ...transportConfig,
430
+ ...options.firebase
431
+ }) : new PusherRealtimeDriver({
432
+ ...transportConfig,
433
+ ...options.pusher
434
+ });
435
+ }
436
+ from(_from) {
437
+ return this;
438
+ }
439
+ subject(subject) {
440
+ this.payload.title = subject;
441
+ return this;
442
+ }
443
+ /**
444
+ * Set the recipient: a `User` (derives the channel) or an explicit channel string.
445
+ *
446
+ * @param recipient
447
+ * @returns
448
+ */
449
+ recipient(recipient) {
450
+ if (typeof recipient === "object" && !Array.isArray(recipient) && "id" in recipient) {
451
+ this.user = recipient;
452
+ return this;
453
+ }
454
+ if (typeof recipient === "string") {
455
+ this.channelName = recipient;
456
+ return this;
457
+ }
458
+ throw new Error("Realtime notifications require a user recipient or a channel name");
459
+ }
460
+ /**
461
+ * Broadcast on an explicit channel/topic instead of the per-user default.
462
+ *
463
+ * @param channel
464
+ * @returns
465
+ */
466
+ channel(channel) {
467
+ this.channelName = channel;
468
+ return this;
469
+ }
470
+ /**
471
+ * The event name clients subscribe to (default `notification`).
472
+ *
473
+ * @param channel
474
+ * @returns
475
+ */
476
+ event(event) {
477
+ this.eventName = event;
478
+ return this;
479
+ }
480
+ /**
481
+ * Also persist the notification (requires a `User` recipient).
482
+ *
483
+ * @param channel
484
+ * @returns
485
+ */
486
+ store(store = true) {
487
+ this.shouldStore = store;
488
+ return this;
489
+ }
490
+ type(type) {
491
+ this.payload.type = type;
492
+ return this;
493
+ }
494
+ action(text, link) {
495
+ this.payload.actionText = text;
496
+ this.payload.actionLink = link;
497
+ return this;
498
+ }
499
+ meta(meta) {
500
+ this.payload.meta = meta;
501
+ return this;
502
+ }
503
+ resolveChannel() {
504
+ if (this.channelName) return this.channelName;
505
+ if (this.user) return `${this.channelPrefix}${this.user.id}`;
506
+ throw new Error("No channel resolved for realtime notification (provide a user or channel)");
507
+ }
508
+ async send(message, subject, _recipient, data) {
509
+ const channel = this.resolveChannel();
510
+ const mergedData = this.mergeData(data);
511
+ const base = {
512
+ type: this.payload.type ?? null,
513
+ title: interpolate(subject ?? this.payload.title ?? "", mergedData),
514
+ description: interpolate(message, mergedData),
515
+ actionText: this.payload.actionText ?? null,
516
+ actionLink: this.payload.actionLink ?? null,
517
+ meta: this.payload.meta ?? null
518
+ };
519
+ const stored = this.shouldStore && this.user ? await UserNotificationCenter.create(this.user, base) : void 0;
520
+ const payload = {
521
+ id: stored ? String(stored.id) : randomUUID(),
522
+ type: base.type ?? null,
523
+ title: base.title,
524
+ description: base.description,
525
+ actionText: base.actionText ?? null,
526
+ actionLink: base.actionLink ?? null,
527
+ meta: base.meta ?? null,
528
+ read_at: stored?.readAt ? new Date(stored.readAt).toISOString() : null,
529
+ created_at: stored?.createdAt ? new Date(stored.createdAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
530
+ };
531
+ await this.driver.broadcast(channel, this.eventName, payload);
532
+ return {
533
+ channel,
534
+ event: this.eventName,
535
+ payload,
536
+ stored
537
+ };
538
+ }
539
+ };
540
+ //#endregion
324
541
  //#region src/drivers/sms/AfricasTalkingSmsDriver.ts
325
542
  var AfricasTalkingSmsDriver = class {
326
543
  driver;
@@ -433,6 +650,9 @@ var Notification = class Notification {
433
650
  static db() {
434
651
  return new DbNotification();
435
652
  }
653
+ static realtime(options) {
654
+ return new RealtimeNotification(options);
655
+ }
436
656
  static channel(channel, options) {
437
657
  return Notification.createDriver(channel ?? configure("default_driver", "mail"), options);
438
658
  }
@@ -455,6 +675,7 @@ var Notification = class Notification {
455
675
  case "email": return new MailNotification(options);
456
676
  case "sms": return new SmsNotification(options);
457
677
  case "db": return new DbNotification();
678
+ case "realtime": return new RealtimeNotification(options);
458
679
  default: throw new Error(`Unsupported notification driver: ${driver}`);
459
680
  }
460
681
  }
@@ -466,4 +687,4 @@ var UserNotification = class extends Model {
466
687
  casts = { meta: "json" };
467
688
  };
468
689
  //#endregion
469
- export { AfricasTalkingSmsDriver, DbNotification, MailNotification, Notification, NotificationContract, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
690
+ export { AfricasTalkingSmsDriver, DbNotification, FirebaseRealtimeDriver, MailNotification, Notification, NotificationContract, PusherRealtimeDriver, RealtimeNotification, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkstack/notifications",
3
- "version": "0.16.6",
3
+ "version": "0.16.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,11 +34,21 @@
34
34
  "africastalking": "^0.8.0",
35
35
  "nodemailer": "^8.0.7",
36
36
  "twilio": "^6.0.0",
37
- "@arkstack/common": "^0.16.6"
37
+ "@arkstack/common": "^0.16.8"
38
38
  },
39
39
  "peerDependencies": {
40
- "@arkstack/database": "^0.16.6",
41
- "@arkstack/contract": "^0.16.6"
40
+ "pusher": "^5.2.0",
41
+ "firebase-admin": "^13.0.0",
42
+ "@arkstack/contract": "^0.16.8",
43
+ "@arkstack/database": "^0.16.8"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "pusher": {
47
+ "optional": true
48
+ },
49
+ "firebase-admin": {
50
+ "optional": true
51
+ }
42
52
  },
43
53
  "devDependencies": {
44
54
  "@types/nodemailer": "^7.0.11"