From 88cc92071a389d0cabf5bf85daef5a480cbf04bb Mon Sep 17 00:00:00 2001 From: 3m1n3nc3 Date: Sat, 25 Jul 2026 06:45:28 +0100 Subject: [PATCH] feat(notifications): add support for custom realtime and mail notifications transport, and improve driver type safety and context awareness. --- .../src/Contracts/RealtimeDriver.ts | 13 ++- packages/notifications/src/Notification.ts | 79 +++++++++++++++++-- .../src/drivers/MailNotification.ts | 17 ++-- .../src/drivers/RealtimeNotification.ts | 32 ++++++-- packages/notifications/src/types.ts | 15 ++-- packages/notifications/tests/realtime.test.ts | 4 +- 6 files changed, 133 insertions(+), 27 deletions(-) diff --git a/packages/notifications/src/Contracts/RealtimeDriver.ts b/packages/notifications/src/Contracts/RealtimeDriver.ts index 0fd32deb..d83173fb 100644 --- a/packages/notifications/src/Contracts/RealtimeDriver.ts +++ b/packages/notifications/src/Contracts/RealtimeDriver.ts @@ -1,4 +1,7 @@ -import type { RealtimeNotificationPayload } from '../types' +import type { RealtimeDriverName, RealtimeNotificationPayload } from '../types' + +import type { FirebaseRealtimeDriver } from '../drivers/realtime/FirebaseRealtimeDriver' +import type { PusherRealtimeDriver } from '../drivers/realtime/PusherRealtimeDriver' /** * A realtime transport (Pusher, Firebase, …) broadcasts a notification payload @@ -9,9 +12,15 @@ import type { RealtimeNotificationPayload } from '../types' * via a multicast send. */ export interface RealtimeDriver { - broadcast ( + broadcast( channel: string | string[], event: string, payload: RealtimeNotificationPayload, ): Promise } + +export type RealtimeNotificationDriver = T extends 'firebase' + ? FirebaseRealtimeDriver + : T extends 'firebase' + ? PusherRealtimeDriver + : RealtimeDriver \ No newline at end of file diff --git a/packages/notifications/src/Notification.ts b/packages/notifications/src/Notification.ts index 45e9e670..6e6ad91e 100644 --- a/packages/notifications/src/Notification.ts +++ b/packages/notifications/src/Notification.ts @@ -13,34 +13,95 @@ type DriverOptions = MailDriverOptions | SmsDriverOptions | RealtimeDriverOption export class Notification { private driver: DriverMap[D] - constructor(driver: D, options: DriverOptions = {}) { - this.driver = Notification.createDriver(driver, options) as DriverMap[D] + constructor(driver: 'sms', options?: SmsDriverOptions) + constructor(driver: 'realtime', options?: RealtimeDriverOptions) + constructor(driver: 'mail' | 'email', options?: MailDriverOptions) + constructor(driver: 'db') + constructor( + driver: NotificationChannel | 'email', + options = {} + ) { + this.driver = Notification.createDriver(driver as never, options) as DriverMap[D] } + /** + * Send an email notification + * + * @param options + * @returns + */ static mail(options?: MailDriverOptions) { return new MailNotification(options) } + /** + * Send an email notification + * + * @param options + * @alias {@link mail} + * @returns + */ static email(options?: MailDriverOptions) { return this.mail(options) } + /** + * Send an sms notification + * + * @param options + * @returns + */ static sms(options?: SmsDriverOptions) { return new SmsNotification(options) } + /** + * Send a database notification + * + * @param options + * @returns + */ static db() { return new DbNotification() } + /** + * Send a realtime notification + * + * @param options + * @returns + */ static realtime(options?: RealtimeDriverOptions) { return new RealtimeNotification(options) } - static channel(channel?: NotificationChannel | 'email', options?: DriverOptions) { - return Notification.createDriver(channel ?? configure('default_driver', 'mail'), options) + /** + * Use a specific channel to send this notification + * + * @param channel + * @param options + */ + static channel(channel: 'sms', options?: SmsDriverOptions): SmsNotification + static channel(channel: 'realtime', options?: RealtimeDriverOptions): RealtimeNotification + static channel(channel: 'mail' | 'email', options?: MailDriverOptions): MailNotification + static channel(channel: 'db'): DbNotification + static channel(): MailNotification | SmsNotification | DbNotification | RealtimeNotification + static channel( + channel?: D, + options?: DriverOptions + ): MailNotification | SmsNotification | DbNotification | RealtimeNotification { + channel ??= configure('default_driver', 'mail') as D + + return Notification.createDriver(channel as never, options as never) } + /** + * Prepare the notification + * + * @param recipient + * @param data + * @returns + */ prepare(recipient?: null | MailRecipient | NotificationRecipient | User, data: NotificationData = {}) { this.driver.data(data) @@ -67,10 +128,14 @@ export class Notification { return this.driver } - private static createDriver( - driver: NotificationChannel | 'email', + private static createDriver(driver: 'sms', options?: SmsDriverOptions): SmsNotification + private static createDriver(driver: 'realtime', options?: RealtimeDriverOptions): RealtimeNotification + private static createDriver(driver: 'mail' | 'email', options?: MailDriverOptions): MailNotification + private static createDriver(driver: 'db'): DbNotification + private static createDriver( + driver: D, options: DriverOptions = {} - ) { + ): MailNotification | SmsNotification | DbNotification | RealtimeNotification { switch (driver) { case 'mail': case 'email': diff --git a/packages/notifications/src/drivers/MailNotification.ts b/packages/notifications/src/drivers/MailNotification.ts index e2ff8d72..8aaae519 100644 --- a/packages/notifications/src/drivers/MailNotification.ts +++ b/packages/notifications/src/drivers/MailNotification.ts @@ -1,13 +1,14 @@ import { config, env } from '@arkstack/common' import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import nodemailer, { type Transporter } from 'nodemailer' +import nodemailer, { Transport, type Transporter } from 'nodemailer' import { NotificationContract } from '../Contracts/NotificationContract' import { interpolate } from '../utils/template' import { configure } from '../config' import type { MailDriverOptions, + MailNotificationOptions, MailRecipient, MailRecipientAddress, MergedTransportConfig, @@ -21,7 +22,7 @@ export class MailNotification extends NotificationContract { driver!: Transporter private options: MailDriverOptions = {} private ViewName: string = '~arkstack/notifications.mail' - private transport: NonNullable = 'smtp' + private transport: NonNullable | Transport = 'smtp' private sesOptions?: Record private recipients?: MailRecipient private fromAddress?: string | { name: string; address: string } @@ -31,7 +32,7 @@ export class MailNotification extends NotificationContract { private textTemplate?: string private fileDirectory?: string - constructor(options: MailDriverOptions = {}) { + constructor(options: MailNotificationOptions = {}) { super() this.driverConfig = configure('drivers.mail', {}) @@ -43,6 +44,12 @@ export class MailNotification extends NotificationContract { * Prepare the mail driver so we can use it to relay the message */ private async prepareDriver() { + if (typeof this.transport !== 'string') { + this.driver = nodemailer.createTransport(this.transport) + + return + } + const options = this.options const transport = configure(`transports.${this.transport}`, {}) as MergedTransportConfig @@ -359,10 +366,10 @@ export class MailNotification extends NotificationContract { const driver = configure('drivers.mail', {}) const trpt = driver.transport ?? 'smtp' - const transport = configure( + const transport = typeof trpt === 'string' ? configure( `transports.${trpt}`, {}, - ) as MergedTransportConfig + ) as MergedTransportConfig : { test_address: '' } const testAddress = driver.test_address ?? diff --git a/packages/notifications/src/drivers/RealtimeNotification.ts b/packages/notifications/src/drivers/RealtimeNotification.ts index d9807cfe..fb777e95 100644 --- a/packages/notifications/src/drivers/RealtimeNotification.ts +++ b/packages/notifications/src/drivers/RealtimeNotification.ts @@ -12,7 +12,7 @@ import type { import { FirebaseRealtimeDriver } from './realtime/FirebaseRealtimeDriver' import { NotificationContract } from '../Contracts/NotificationContract' import { PusherRealtimeDriver } from './realtime/PusherRealtimeDriver' -import type { RealtimeDriver } from '../Contracts/RealtimeDriver' +import type { RealtimeNotificationDriver } from '../Contracts/RealtimeDriver' import type { User } from '@app/models/User' import { UserNotificationCenter } from '../UserNotificationCenter' import { configure } from '../config' @@ -24,11 +24,12 @@ import { randomUUID } from 'node:crypto' * (Pusher or Firebase). The notification is delivered on a per-user channel and, * when `store` is enabled, is also persisted so the client can load history. */ -export class RealtimeNotification extends NotificationContract { +export class RealtimeNotification + extends NotificationContract { /** * The underlying transport; assignable so tests can inject a fake. */ - driver: RealtimeDriver + driver: RealtimeNotificationDriver private user?: User private channelName?: string | string[] private eventName: string @@ -36,7 +37,7 @@ export class RealtimeNotification extends NotificationContract = {} - constructor(options: RealtimeDriverOptions = {}) { + constructor(options: RealtimeDriverOptions = {}) { super() const driverConfig = configure('drivers.realtime', {}) as { @@ -53,12 +54,13 @@ export class RealtimeNotification extends NotificationContract } - from(_from: string): this { + from(_: string): this { return this } @@ -68,6 +70,24 @@ export class RealtimeNotification extends NotificationContract + + this.driver = (transport === 'firebase' + ? new FirebaseRealtimeDriver({ ...transportConfig, ...options as any }) + : new PusherRealtimeDriver({ ...transportConfig, ...options as any }) + ) as RealtimeNotificationDriver + + return this + } + /** * Set the recipient: a `User` (derives the channel), an explicit channel * string, or an array of channels (Pusher) / device tokens (Firebase). diff --git a/packages/notifications/src/types.ts b/packages/notifications/src/types.ts index 88cd0d7f..f0d62b13 100644 --- a/packages/notifications/src/types.ts +++ b/packages/notifications/src/types.ts @@ -1,5 +1,6 @@ import type { Logger } from 'nodemailer/lib/shared' import type { MergedConfig } from '@arkstack/common' +import { Transport } from 'nodemailer' import type { UserNotification } from '@app/models/UserNotification' export type NotificationRecipient = string | string[] @@ -70,8 +71,8 @@ export type FirebaseTransportConfig = { admin_sdk_path?: string } -export type RealtimeDriverOptions = { - transport?: RealtimeDriverName +export type RealtimeDriverOptions = { + transport?: T /** Channel/topic to broadcast on. Defaults to `${channel_prefix}${user.id}`. */ channel?: string /** Event name clients subscribe to. Defaults to config `event` or `notification`. */ @@ -123,11 +124,11 @@ export type NotificationDriverMap = { realtime: RealtimeBroadcastResult } -export interface NotificationConfig { +export interface NotificationConfig { default_driver: 'mail' | 'sms' | 'db' drivers: { mail: { - transport: 'smtp' | 'file' | 'sendmail' | 'ses'; + transport: 'smtp' | 'file' | 'sendmail' | 'ses' | Transport; from: string | { name: string; address: string; @@ -225,4 +226,8 @@ export interface NotificationConfig { export type MergedTransportConfig = MergedConfig[ NonNullable -]> \ No newline at end of file +]> + +export type MailNotificationOptions = MailDriverOptions & { + transport?: MailDriverOptions['transport'] | Transport +} \ No newline at end of file diff --git a/packages/notifications/tests/realtime.test.ts b/packages/notifications/tests/realtime.test.ts index 8bbbb918..642c60bf 100644 --- a/packages/notifications/tests/realtime.test.ts +++ b/packages/notifications/tests/realtime.test.ts @@ -116,8 +116,8 @@ describe('RealtimeNotification', () => { describe('FirebaseRealtimeDriver multicast', () => { /** Inject a fake `firebase-admin` messaging into the lazy promise. */ const withMessaging = (messaging: unknown) => { - const driver = new FirebaseRealtimeDriver() - ;(driver as unknown as { messagingPromise: Promise }).messagingPromise = Promise.resolve(messaging) + const driver = new FirebaseRealtimeDriver(); + (driver as any).messagingPromise = Promise.resolve(messaging) return driver }