Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/notifications/src/Contracts/RealtimeDriver.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<unknown>
}

export type RealtimeNotificationDriver<T extends RealtimeDriverName> = T extends 'firebase'
? FirebaseRealtimeDriver
: T extends 'firebase'
? PusherRealtimeDriver
: RealtimeDriver
79 changes: 72 additions & 7 deletions packages/notifications/src/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,95 @@ type DriverOptions = MailDriverOptions | SmsDriverOptions | RealtimeDriverOption
export class Notification<D extends keyof DriverMap = keyof DriverMap> {
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<D extends NotificationChannel | 'email'>(
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)

Expand All @@ -67,10 +128,14 @@ export class Notification<D extends keyof DriverMap = keyof DriverMap> {
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<D extends NotificationChannel | 'email'>(
driver: D,
options: DriverOptions = {}
) {
): MailNotification | SmsNotification | DbNotification | RealtimeNotification {
switch (driver) {
case 'mail':
case 'email':
Expand Down
17 changes: 12 additions & 5 deletions packages/notifications/src/drivers/MailNotification.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,7 +22,7 @@ export class MailNotification extends NotificationContract {
driver!: Transporter
private options: MailDriverOptions = {}
private ViewName: string = '~arkstack/notifications.mail'
private transport: NonNullable<MailDriverOptions['transport']> = 'smtp'
private transport: NonNullable<MailDriverOptions['transport']> | Transport = 'smtp'
private sesOptions?: Record<string, any>
private recipients?: MailRecipient
private fromAddress?: string | { name: string; address: string }
Expand All @@ -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', {})
Expand All @@ -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

Expand Down Expand Up @@ -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 ??
Expand Down
32 changes: 26 additions & 6 deletions packages/notifications/src/drivers/RealtimeNotification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -24,19 +24,20 @@ 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<RealtimeBroadcastResult> {
export class RealtimeNotification
<T extends RealtimeDriverName = RealtimeDriverName> extends NotificationContract<RealtimeBroadcastResult> {
/**
* The underlying transport; assignable so tests can inject a fake.
*/
driver: RealtimeDriver
driver: RealtimeNotificationDriver<T>
private user?: User
private channelName?: string | string[]
private eventName: string
private channelPrefix: string
private shouldStore: boolean
private payload: Partial<DbNotificationPayload> = {}

constructor(options: RealtimeDriverOptions = {}) {
constructor(options: RealtimeDriverOptions<T> = {}) {
super()

const driverConfig = configure('drivers.realtime', {}) as {
Expand All @@ -53,12 +54,13 @@ export class RealtimeNotification extends NotificationContract<RealtimeBroadcast
this.channelPrefix = driverConfig?.channel_prefix ?? 'user.'
this.shouldStore = options.store ?? driverConfig?.store ?? false

this.driver = transport === 'firebase'
this.driver = (transport === 'firebase'
? new FirebaseRealtimeDriver({ ...transportConfig, ...options.firebase })
: new PusherRealtimeDriver({ ...transportConfig, ...options.pusher })
) as RealtimeNotificationDriver<T>
}

from(_from: string): this {
from(_: string): this {
return this
}

Expand All @@ -68,6 +70,24 @@ export class RealtimeNotification extends NotificationContract<RealtimeBroadcast
return this
}

/**
* Overide the default transport
*
* @param transport
* @param options
* @returns
*/
transport(transport: T, options?: RealtimeDriverOptions[T]): this {
const transportConfig = configure(`transports.${transport}` as never, {}) as Record<string, never>

this.driver = (transport === 'firebase'
? new FirebaseRealtimeDriver({ ...transportConfig, ...options as any })
: new PusherRealtimeDriver({ ...transportConfig, ...options as any })
) as RealtimeNotificationDriver<T>

return this
}

/**
* Set the recipient: a `User` (derives the channel), an explicit channel
* string, or an array of channels (Pusher) / device tokens (Firebase).
Expand Down
15 changes: 10 additions & 5 deletions packages/notifications/src/types.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand Down Expand Up @@ -70,8 +71,8 @@ export type FirebaseTransportConfig = {
admin_sdk_path?: string
}

export type RealtimeDriverOptions = {
transport?: RealtimeDriverName
export type RealtimeDriverOptions<T extends RealtimeDriverName = RealtimeDriverName> = {
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`. */
Expand Down Expand Up @@ -123,11 +124,11 @@ export type NotificationDriverMap = {
realtime: RealtimeBroadcastResult
}

export interface NotificationConfig {
export interface NotificationConfig<T = any> {
default_driver: 'mail' | 'sms' | 'db'
drivers: {
mail: {
transport: 'smtp' | 'file' | 'sendmail' | 'ses';
transport: 'smtp' | 'file' | 'sendmail' | 'ses' | Transport<T>;
from: string | {
name: string;
address: string;
Expand Down Expand Up @@ -225,4 +226,8 @@ export interface NotificationConfig {

export type MergedTransportConfig = MergedConfig<Required<NotificationConfig['transports']>[
NonNullable<MailDriverOptions['transport']>
]>
]>

export type MailNotificationOptions = MailDriverOptions & {
transport?: MailDriverOptions['transport'] | Transport
}
4 changes: 2 additions & 2 deletions packages/notifications/tests/realtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> }).messagingPromise = Promise.resolve(messaging)
const driver = new FirebaseRealtimeDriver();
(driver as any).messagingPromise = Promise.resolve(messaging)

return driver
}
Expand Down
Loading