@mantiq/notify 0.1.0

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/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @mantiq/notify
2
+
3
+ Multi-channel notifications for [MantiqJS](https://github.com/mantiqjs/mantiq).
4
+
5
+ Built-in channels: mail, database, broadcast, SMS, Slack, webhook. Extensible — any package can register custom channels.
6
+
7
+ ```bash
8
+ bun add @mantiq/notify
9
+ ```
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@mantiq/notify",
3
+ "version": "0.1.0",
4
+ "description": "Multi-channel notifications — mail, database, broadcast, SMS, Slack, webhook",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Abdullah Khan",
8
+ "homepage": "https://github.com/mantiqjs/mantiq/tree/main/packages/notify",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/mantiqjs/mantiq.git",
12
+ "directory": "packages/notify"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/mantiqjs/mantiq/issues"
16
+ },
17
+ "keywords": [
18
+ "mantiq",
19
+ "mantiqjs",
20
+ "bun",
21
+ "typescript",
22
+ "framework",
23
+ "notifications",
24
+ "mail",
25
+ "sms",
26
+ "slack"
27
+ ],
28
+ "engines": {
29
+ "bun": ">=1.1.0"
30
+ },
31
+ "main": "./src/index.ts",
32
+ "types": "./src/index.ts",
33
+ "exports": {
34
+ ".": {
35
+ "bun": "./src/index.ts",
36
+ "default": "./src/index.ts"
37
+ }
38
+ },
39
+ "files": [
40
+ "src/",
41
+ "package.json",
42
+ "README.md",
43
+ "LICENSE"
44
+ ],
45
+ "scripts": {
46
+ "build": "bun build ./src/index.ts --outdir ./dist --target bun",
47
+ "test": "bun test",
48
+ "typecheck": "tsc --noEmit",
49
+ "clean": "rm -rf dist"
50
+ },
51
+ "devDependencies": {
52
+ "bun-types": "latest",
53
+ "typescript": "^5.7.0"
54
+ },
55
+ "peerDependencies": {
56
+ "@mantiq/core": "^0.1.0",
57
+ "@mantiq/database": "^0.1.0",
58
+ "@mantiq/mail": "^0.2.0",
59
+ "@mantiq/events": "^0.1.0",
60
+ "@mantiq/realtime": "^0.1.0",
61
+ "@mantiq/queue": "^0.1.0",
62
+ "@mantiq/cli": "^0.1.0"
63
+ }
64
+ }
@@ -0,0 +1,102 @@
1
+ import type { Notifiable } from './contracts/Notifiable.ts'
2
+ import type { Mailable } from '@mantiq/mail'
3
+
4
+ export interface SlackMessage {
5
+ text?: string
6
+ blocks?: any[]
7
+ channel?: string
8
+ username?: string
9
+ iconEmoji?: string
10
+ iconUrl?: string
11
+ }
12
+
13
+ export interface BroadcastPayload {
14
+ event: string
15
+ data: any
16
+ channel?: string
17
+ }
18
+
19
+ export interface SmsPayload {
20
+ to?: string
21
+ body: string
22
+ }
23
+
24
+ export interface WebhookPayload {
25
+ url: string
26
+ body: any
27
+ method?: 'POST' | 'PUT' | 'PATCH'
28
+ headers?: Record<string, string>
29
+ }
30
+
31
+ /**
32
+ * Base notification class. Users extend this and implement via() + to{Channel}() methods.
33
+ *
34
+ * @example
35
+ * class OrderShipped extends Notification {
36
+ * constructor(private order: Order) { super() }
37
+ *
38
+ * via(notifiable: Notifiable) { return ['mail', 'database'] }
39
+ *
40
+ * toMail(notifiable: Notifiable) {
41
+ * return new OrderShippedEmail(this.order)
42
+ * }
43
+ *
44
+ * toDatabase(notifiable: Notifiable) {
45
+ * return { order_id: this.order.id, message: 'Your order shipped' }
46
+ * }
47
+ * }
48
+ *
49
+ * Convention: channel "foo" calls this.toFoo(notifiable).
50
+ * Any method matching to{ChannelName} will be called by that channel.
51
+ * This makes the system infinitely extensible — no core changes needed.
52
+ */
53
+ export abstract class Notification {
54
+ /** Unique ID for this notification instance */
55
+ id: string = crypto.randomUUID()
56
+
57
+ /** Which channels to deliver through */
58
+ abstract via(notifiable: Notifiable): string[]
59
+
60
+ // ── Built-in channel methods (optional) ─────────────────────────────────
61
+
62
+ toMail?(notifiable: Notifiable): Mailable
63
+ toDatabase?(notifiable: Notifiable): Record<string, any>
64
+ toBroadcast?(notifiable: Notifiable): BroadcastPayload
65
+ toSms?(notifiable: Notifiable): SmsPayload
66
+ toSlack?(notifiable: Notifiable): SlackMessage
67
+ toWebhook?(notifiable: Notifiable): WebhookPayload
68
+
69
+ // ── Queueing ──────────────────────────────────────────────────────────────
70
+
71
+ /** Override to true to queue this notification instead of sending immediately */
72
+ shouldQueue = false
73
+
74
+ /** Queue name override */
75
+ queue?: string
76
+
77
+ /** Queue connection override */
78
+ connection?: string
79
+
80
+ /** Max retry attempts */
81
+ tries = 3
82
+
83
+ // ── Internal helpers ──────────────────────────────────────────────────────
84
+
85
+ /** Get the notification type name (used in database channel) */
86
+ get type(): string {
87
+ return this.constructor.name
88
+ }
89
+
90
+ /**
91
+ * Get the channel-specific payload via convention.
92
+ * Channel "foo" → calls this.toFoo(notifiable).
93
+ */
94
+ getPayloadFor(channel: string, notifiable: Notifiable): any {
95
+ const methodName = `to${channel.charAt(0).toUpperCase()}${channel.slice(1)}`
96
+ const method = (this as any)[methodName]
97
+ if (typeof method === 'function') {
98
+ return method.call(this, notifiable)
99
+ }
100
+ return undefined
101
+ }
102
+ }
@@ -0,0 +1,128 @@
1
+ import type { NotificationChannel } from './contracts/Channel.ts'
2
+ import type { Notifiable } from './contracts/Notifiable.ts'
3
+ import type { NotifyConfig } from './contracts/NotifyConfig.ts'
4
+ import { DEFAULT_CONFIG } from './contracts/NotifyConfig.ts'
5
+ import { Notification } from './Notification.ts'
6
+ import { NotifyError } from './errors/NotifyError.ts'
7
+ import { MailChannel } from './channels/MailChannel.ts'
8
+ import { DatabaseChannel } from './channels/DatabaseChannel.ts'
9
+ import { BroadcastChannel } from './channels/BroadcastChannel.ts'
10
+ import { SmsChannel } from './channels/SmsChannel.ts'
11
+ import { SlackChannel } from './channels/SlackChannel.ts'
12
+ import { WebhookChannel } from './channels/WebhookChannel.ts'
13
+
14
+ /**
15
+ * NotificationManager — routes notifications through channels.
16
+ *
17
+ * Built-in channels: mail, database, broadcast, sms, slack, webhook.
18
+ * Extensible via extend() — any package can register custom channels.
19
+ *
20
+ * @example
21
+ * await notify().send(user, new OrderShipped(order))
22
+ * await notify().send([user1, user2], new OrderShipped(order))
23
+ */
24
+ export class NotificationManager {
25
+ private _channels = new Map<string, NotificationChannel>()
26
+ private _factories = new Map<string, () => NotificationChannel>()
27
+ private config: NotifyConfig
28
+
29
+ constructor(config?: Partial<NotifyConfig>) {
30
+ this.config = { ...DEFAULT_CONFIG, ...config }
31
+ this.registerBuiltInChannels()
32
+ }
33
+
34
+ /** Send a notification to one or many notifiables */
35
+ async send(notifiable: Notifiable | Notifiable[], notification: Notification): Promise<void> {
36
+ const notifiables = Array.isArray(notifiable) ? notifiable : [notifiable]
37
+
38
+ if (notification.shouldQueue) {
39
+ for (const n of notifiables) {
40
+ await this.queueNotification(n, notification)
41
+ }
42
+ return
43
+ }
44
+
45
+ for (const n of notifiables) {
46
+ await this.sendNow(n, notification)
47
+ }
48
+ }
49
+
50
+ /** Send immediately, bypassing queue */
51
+ async sendNow(notifiable: Notifiable, notification: Notification, channelNames?: string[]): Promise<void> {
52
+ const channels = channelNames ?? notification.via(notifiable)
53
+
54
+ for (const channelName of channels) {
55
+ try {
56
+ const channel = this.channel(channelName)
57
+ await channel.send(notifiable, notification)
58
+ } catch (err) {
59
+ // Log but don't fail other channels
60
+ console.error(`[Mantiq] Notification channel "${channelName}" failed:`, (err as Error).message)
61
+ }
62
+ }
63
+ }
64
+
65
+ /** Get a channel by name */
66
+ channel(name: string): NotificationChannel {
67
+ if (this._channels.has(name)) return this._channels.get(name)!
68
+
69
+ const factory = this._factories.get(name)
70
+ if (factory) {
71
+ const channel = factory()
72
+ this._channels.set(name, channel)
73
+ return channel
74
+ }
75
+
76
+ throw new NotifyError(`Notification channel "${name}" is not registered.`, {
77
+ available: this.channelNames(),
78
+ })
79
+ }
80
+
81
+ /** Register a custom channel — this is the extension point */
82
+ extend(name: string, channel: NotificationChannel | (() => NotificationChannel)): void {
83
+ if (typeof channel === 'function') {
84
+ this._factories.set(name, channel)
85
+ this._channels.delete(name) // clear cache
86
+ } else {
87
+ this._channels.set(name, channel)
88
+ }
89
+ }
90
+
91
+ /** Check if a channel is registered */
92
+ hasChannel(name: string): boolean {
93
+ return this._channels.has(name) || this._factories.has(name)
94
+ }
95
+
96
+ /** List all registered channel names */
97
+ channelNames(): string[] {
98
+ return [...new Set([...this._channels.keys(), ...this._factories.keys()])]
99
+ }
100
+
101
+ // ── Private ───────────────────────────────────────────────────────────────
102
+
103
+ private registerBuiltInChannels(): void {
104
+ this._channels.set('mail', new MailChannel())
105
+ this._channels.set('database', new DatabaseChannel())
106
+ this._channels.set('broadcast', new BroadcastChannel())
107
+ this._channels.set('webhook', new WebhookChannel())
108
+
109
+ // Lazy-load SMS and Slack (need config)
110
+ if (this.config.channels?.sms) {
111
+ this._factories.set('sms', () => new SmsChannel(this.config.channels!.sms!))
112
+ }
113
+ if (this.config.channels?.slack) {
114
+ this._factories.set('slack', () => new SlackChannel(this.config.channels!.slack!))
115
+ }
116
+ }
117
+
118
+ private async queueNotification(notifiable: Notifiable, notification: Notification): Promise<void> {
119
+ try {
120
+ const { dispatch } = await import('@mantiq/queue')
121
+ const { SendNotificationJob } = await import('./jobs/SendNotificationJob.ts')
122
+ await dispatch(new SendNotificationJob(notifiable, notification))
123
+ } catch {
124
+ // Queue not available — send synchronously
125
+ await this.sendNow(notifiable, notification)
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,14 @@
1
+ import { ServiceProvider, ConfigRepository } from '@mantiq/core'
2
+ import { NotificationManager } from './NotificationManager.ts'
3
+ import { NOTIFY_MANAGER } from './helpers/notify.ts'
4
+ import type { NotifyConfig } from './contracts/NotifyConfig.ts'
5
+ import { DEFAULT_CONFIG } from './contracts/NotifyConfig.ts'
6
+
7
+ export class NotificationServiceProvider extends ServiceProvider {
8
+ override register(): void {
9
+ const config = this.app.make(ConfigRepository).get<NotifyConfig>('notify', DEFAULT_CONFIG)
10
+
11
+ this.app.singleton(NotificationManager, () => new NotificationManager(config))
12
+ this.app.alias(NotificationManager, NOTIFY_MANAGER)
13
+ }
14
+ }
@@ -0,0 +1,51 @@
1
+ import type { NotificationChannel } from '../contracts/Channel.ts'
2
+ import type { Notifiable } from '../contracts/Notifiable.ts'
3
+ import type { Notification } from '../Notification.ts'
4
+ import { NotifyError } from '../errors/NotifyError.ts'
5
+
6
+ /**
7
+ * Broadcasts notifications via server-sent events using @mantiq/realtime.
8
+ *
9
+ * The notification's `toBroadcast(notifiable)` method should return a
10
+ * `BroadcastPayload` with `{ event, data, channel? }`.
11
+ *
12
+ * If no channel is specified, defaults to `App.User.{notifiable.getKey()}`.
13
+ * If @mantiq/realtime is not installed, the channel skips silently.
14
+ */
15
+ export class BroadcastChannel implements NotificationChannel {
16
+ readonly name = 'broadcast'
17
+
18
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
19
+ const payload = notification.getPayloadFor('broadcast', notifiable)
20
+ if (!payload) return
21
+
22
+ // Try to import SSEManager from @mantiq/realtime — skip silently if unavailable
23
+ let SSEManager: any
24
+ let Application: any
25
+ try {
26
+ const realtimeModule = await import('@mantiq/realtime')
27
+ SSEManager = realtimeModule.SSEManager
28
+ const coreModule = await import('@mantiq/core')
29
+ Application = coreModule.Application
30
+ } catch {
31
+ // @mantiq/realtime is not available — skip silently
32
+ return
33
+ }
34
+
35
+ const channel = payload.channel ?? `App.User.${notifiable.getKey()}`
36
+ const event = payload.event
37
+ const data = payload.data
38
+
39
+ try {
40
+ const sseManager = Application.getInstance().make(SSEManager)
41
+ sseManager.broadcast(channel, event, data)
42
+ } catch (error) {
43
+ throw new NotifyError(`Failed to broadcast notification: ${error instanceof Error ? error.message : String(error)}`, {
44
+ channel: this.name,
45
+ notificationType: notification.type,
46
+ broadcastChannel: channel,
47
+ event,
48
+ })
49
+ }
50
+ }
51
+ }
@@ -0,0 +1,45 @@
1
+ import type { NotificationChannel } from '../contracts/Channel.ts'
2
+ import type { Notifiable } from '../contracts/Notifiable.ts'
3
+ import type { Notification } from '../Notification.ts'
4
+ import { DatabaseNotification } from '../models/DatabaseNotification.ts'
5
+ import { NotifyError } from '../errors/NotifyError.ts'
6
+
7
+ /**
8
+ * Persists notifications to the database.
9
+ *
10
+ * The notification's `toDatabase(notifiable)` method should return a plain
11
+ * `Record<string, any>` which will be JSON-stringified and stored in the
12
+ * `data` column of the `notifications` table.
13
+ */
14
+ export class DatabaseChannel implements NotificationChannel {
15
+ readonly name = 'database'
16
+
17
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
18
+ const data = notification.getPayloadFor('database', notifiable)
19
+ if (data === undefined) return
20
+
21
+ const notifiableType = typeof notifiable.getMorphClass === 'function'
22
+ ? notifiable.getMorphClass()
23
+ : notifiable.constructor.name
24
+
25
+ const notifiableId = notifiable.getKey()
26
+
27
+ try {
28
+ const record = new DatabaseNotification()
29
+ record.setAttribute('id', notification.id)
30
+ record.setAttribute('type', notification.type)
31
+ record.setAttribute('notifiable_type', notifiableType)
32
+ record.setAttribute('notifiable_id', notifiableId)
33
+ record.setAttribute('data', typeof data === 'string' ? data : JSON.stringify(data))
34
+ record.setAttribute('read_at', null)
35
+ await record.save()
36
+ } catch (error) {
37
+ throw new NotifyError(`Failed to store database notification: ${error instanceof Error ? error.message : String(error)}`, {
38
+ channel: this.name,
39
+ notificationType: notification.type,
40
+ notifiableType,
41
+ notifiableId,
42
+ })
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,53 @@
1
+ import type { NotificationChannel } from '../contracts/Channel.ts'
2
+ import type { Notifiable } from '../contracts/Notifiable.ts'
3
+ import type { Notification } from '../Notification.ts'
4
+ import { NotifyError } from '../errors/NotifyError.ts'
5
+
6
+ /**
7
+ * Delivers notifications via email using the @mantiq/mail package.
8
+ *
9
+ * The notification's `toMail(notifiable)` method should return a Mailable instance.
10
+ * If the mailable has no recipients set, the channel falls back to
11
+ * `notifiable.routeNotificationFor('mail')` as the recipient address.
12
+ */
13
+ export class MailChannel implements NotificationChannel {
14
+ readonly name = 'mail'
15
+
16
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
17
+ const mailable = notification.getPayloadFor('mail', notifiable)
18
+ if (!mailable) return
19
+
20
+ // Resolve the mail() helper from @mantiq/mail
21
+ let mail: () => { send(m: any): Promise<any> }
22
+ try {
23
+ const mailModule = await import('@mantiq/mail')
24
+ mail = mailModule.mail as any
25
+ } catch {
26
+ throw new NotifyError('MailChannel requires @mantiq/mail to be installed', {
27
+ channel: this.name,
28
+ notificationType: notification.type,
29
+ })
30
+ }
31
+
32
+ // If the mailable has no recipients, use the notifiable's route
33
+ if (typeof mailable.getTo === 'function' && mailable.getTo().length === 0) {
34
+ const address = notifiable.routeNotificationFor('mail')
35
+ if (!address) {
36
+ throw new NotifyError('No mail recipient: mailable has no recipients and notifiable returned null for mail route', {
37
+ channel: this.name,
38
+ notificationType: notification.type,
39
+ })
40
+ }
41
+ mailable.to(address)
42
+ }
43
+
44
+ try {
45
+ await mail().send(mailable)
46
+ } catch (error) {
47
+ throw new NotifyError(`Failed to send mail notification: ${error instanceof Error ? error.message : String(error)}`, {
48
+ channel: this.name,
49
+ notificationType: notification.type,
50
+ })
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,127 @@
1
+ import type { NotificationChannel } from '../contracts/Channel.ts'
2
+ import type { Notifiable } from '../contracts/Notifiable.ts'
3
+ import type { Notification } from '../Notification.ts'
4
+ import type { SlackConfig } from '../contracts/NotifyConfig.ts'
5
+ import type { SlackMessage } from '../Notification.ts'
6
+ import { NotifyError } from '../errors/NotifyError.ts'
7
+
8
+ /**
9
+ * Sends notifications to Slack via webhook URL or the Slack Web API.
10
+ *
11
+ * The notification's `toSlack(notifiable)` method should return a `SlackMessage`
12
+ * with at minimum `text` or `blocks`.
13
+ *
14
+ * Two delivery modes:
15
+ * 1. **Webhook** — POST the payload directly to `config.webhookUrl`
16
+ * 2. **API** — POST to `https://slack.com/api/chat.postMessage` with a Bearer token
17
+ *
18
+ * Uses native `fetch()` — no Slack SDK required.
19
+ */
20
+ export class SlackChannel implements NotificationChannel {
21
+ readonly name = 'slack'
22
+
23
+ constructor(private readonly config: SlackConfig) {}
24
+
25
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
26
+ const payload = notification.getPayloadFor('slack', notifiable) as SlackMessage | undefined
27
+ if (!payload) return
28
+
29
+ if (this.config.webhookUrl) {
30
+ await this.sendViaWebhook(payload, notifiable, notification)
31
+ } else if (this.config.token) {
32
+ await this.sendViaApi(payload, notifiable, notification)
33
+ } else {
34
+ throw new NotifyError('Slack configuration requires either webhookUrl or token', {
35
+ channel: this.name,
36
+ notificationType: notification.type,
37
+ })
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Send via incoming webhook URL.
43
+ * Simply POSTs the SlackMessage JSON to the webhook URL.
44
+ */
45
+ private async sendViaWebhook(
46
+ payload: SlackMessage,
47
+ _notifiable: Notifiable,
48
+ notification: Notification,
49
+ ): Promise<void> {
50
+ const body: Record<string, any> = {}
51
+ if (payload.text) body.text = payload.text
52
+ if (payload.blocks) body.blocks = payload.blocks
53
+ if (payload.username) body.username = payload.username
54
+ if (payload.iconEmoji) body.icon_emoji = payload.iconEmoji
55
+ if (payload.iconUrl) body.icon_url = payload.iconUrl
56
+ if (payload.channel) body.channel = payload.channel
57
+
58
+ const response = await fetch(this.config.webhookUrl!, {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json' },
61
+ body: JSON.stringify(body),
62
+ })
63
+
64
+ if (!response.ok) {
65
+ const errorBody = await response.text().catch(() => 'unknown error')
66
+ throw new NotifyError(`Slack webhook error (${response.status}): ${errorBody}`, {
67
+ channel: this.name,
68
+ notificationType: notification.type,
69
+ statusCode: response.status,
70
+ })
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Send via the Slack Web API (chat.postMessage).
76
+ * Requires a bot token with `chat:write` scope.
77
+ */
78
+ private async sendViaApi(
79
+ payload: SlackMessage,
80
+ notifiable: Notifiable,
81
+ notification: Notification,
82
+ ): Promise<void> {
83
+ // Determine channel: payload.channel > notifiable route > error
84
+ const channel = payload.channel ?? notifiable.routeNotificationFor('slack')
85
+ if (!channel) {
86
+ throw new NotifyError('No Slack channel: payload.channel is empty and notifiable returned null for slack route', {
87
+ channel: this.name,
88
+ notificationType: notification.type,
89
+ })
90
+ }
91
+
92
+ const body: Record<string, any> = { channel }
93
+ if (payload.text) body.text = payload.text
94
+ if (payload.blocks) body.blocks = payload.blocks
95
+ if (payload.username) body.username = payload.username
96
+ if (payload.iconEmoji) body.icon_emoji = payload.iconEmoji
97
+ if (payload.iconUrl) body.icon_url = payload.iconUrl
98
+
99
+ const response = await fetch('https://slack.com/api/chat.postMessage', {
100
+ method: 'POST',
101
+ headers: {
102
+ 'Authorization': `Bearer ${this.config.token}`,
103
+ 'Content-Type': 'application/json',
104
+ },
105
+ body: JSON.stringify(body),
106
+ })
107
+
108
+ if (!response.ok) {
109
+ const errorBody = await response.text().catch(() => 'unknown error')
110
+ throw new NotifyError(`Slack API HTTP error (${response.status}): ${errorBody}`, {
111
+ channel: this.name,
112
+ notificationType: notification.type,
113
+ statusCode: response.status,
114
+ })
115
+ }
116
+
117
+ // Slack API returns 200 even on failure — check the `ok` field
118
+ const result = await response.json().catch(() => null)
119
+ if (result && !result.ok) {
120
+ throw new NotifyError(`Slack API error: ${result.error ?? 'unknown error'}`, {
121
+ channel: this.name,
122
+ notificationType: notification.type,
123
+ slackError: result.error,
124
+ })
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,146 @@
1
+ import type { NotificationChannel } from '../contracts/Channel.ts'
2
+ import type { Notifiable } from '../contracts/Notifiable.ts'
3
+ import type { Notification } from '../Notification.ts'
4
+ import type { SmsConfig } from '../contracts/NotifyConfig.ts'
5
+ import { NotifyError } from '../errors/NotifyError.ts'
6
+
7
+ /**
8
+ * Sends SMS notifications via Twilio or Vonage.
9
+ *
10
+ * The notification's `toSms(notifiable)` method should return an `SmsPayload`
11
+ * with `{ to?, body }`. If `to` is not set, falls back to
12
+ * `notifiable.routeNotificationFor('sms')`.
13
+ *
14
+ * Uses native `fetch()` — no SDK dependencies required.
15
+ */
16
+ export class SmsChannel implements NotificationChannel {
17
+ readonly name = 'sms'
18
+
19
+ constructor(private readonly config: SmsConfig) {}
20
+
21
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
22
+ const payload = notification.getPayloadFor('sms', notifiable)
23
+ if (!payload) return
24
+
25
+ const to = payload.to ?? notifiable.routeNotificationFor('sms')
26
+ if (!to) {
27
+ throw new NotifyError('No SMS recipient: payload.to is empty and notifiable returned null for sms route', {
28
+ channel: this.name,
29
+ notificationType: notification.type,
30
+ })
31
+ }
32
+
33
+ const body: string = payload.body
34
+ if (!body) {
35
+ throw new NotifyError('SMS body is required', {
36
+ channel: this.name,
37
+ notificationType: notification.type,
38
+ })
39
+ }
40
+
41
+ switch (this.config.driver) {
42
+ case 'twilio':
43
+ await this.sendViaTwilio(to, body)
44
+ break
45
+ case 'vonage':
46
+ await this.sendViaVonage(to, body)
47
+ break
48
+ default:
49
+ throw new NotifyError(`Unsupported SMS driver: ${this.config.driver}`, {
50
+ channel: this.name,
51
+ notificationType: notification.type,
52
+ })
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Send an SMS via the Twilio REST API.
58
+ * POST https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json
59
+ * with form-encoded body and HTTP Basic auth (sid:token).
60
+ */
61
+ private async sendViaTwilio(to: string, body: string): Promise<void> {
62
+ const twilio = this.config.twilio
63
+ if (!twilio) {
64
+ throw new NotifyError('Twilio configuration is missing (twilio.sid, twilio.token, twilio.from)', {
65
+ channel: this.name,
66
+ driver: 'twilio',
67
+ })
68
+ }
69
+
70
+ const url = `https://api.twilio.com/2010-04-01/Accounts/${twilio.sid}/Messages.json`
71
+ const credentials = btoa(`${twilio.sid}:${twilio.token}`)
72
+
73
+ const formData = new URLSearchParams()
74
+ formData.set('To', to)
75
+ formData.set('From', twilio.from)
76
+ formData.set('Body', body)
77
+
78
+ const response = await fetch(url, {
79
+ method: 'POST',
80
+ headers: {
81
+ 'Authorization': `Basic ${credentials}`,
82
+ 'Content-Type': 'application/x-www-form-urlencoded',
83
+ },
84
+ body: formData.toString(),
85
+ })
86
+
87
+ if (!response.ok) {
88
+ const errorBody = await response.text().catch(() => 'unknown error')
89
+ throw new NotifyError(`Twilio API error (${response.status}): ${errorBody}`, {
90
+ channel: this.name,
91
+ driver: 'twilio',
92
+ statusCode: response.status,
93
+ })
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Send an SMS via the Vonage (Nexmo) REST API.
99
+ * POST https://rest.nexmo.com/sms/json with JSON body.
100
+ */
101
+ private async sendViaVonage(to: string, body: string): Promise<void> {
102
+ const vonage = this.config.vonage
103
+ if (!vonage) {
104
+ throw new NotifyError('Vonage configuration is missing (vonage.apiKey, vonage.apiSecret, vonage.from)', {
105
+ channel: this.name,
106
+ driver: 'vonage',
107
+ })
108
+ }
109
+
110
+ const url = 'https://rest.nexmo.com/sms/json'
111
+
112
+ const response = await fetch(url, {
113
+ method: 'POST',
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ },
117
+ body: JSON.stringify({
118
+ api_key: vonage.apiKey,
119
+ api_secret: vonage.apiSecret,
120
+ from: vonage.from,
121
+ to,
122
+ text: body,
123
+ }),
124
+ })
125
+
126
+ if (!response.ok) {
127
+ const errorBody = await response.text().catch(() => 'unknown error')
128
+ throw new NotifyError(`Vonage API error (${response.status}): ${errorBody}`, {
129
+ channel: this.name,
130
+ driver: 'vonage',
131
+ statusCode: response.status,
132
+ })
133
+ }
134
+
135
+ // Vonage returns 200 even on failure — check the response body
136
+ const result = await response.json().catch(() => null)
137
+ if (result?.messages?.[0]?.status !== '0') {
138
+ const errorText = result?.messages?.[0]?.['error-text'] ?? 'Unknown Vonage error'
139
+ throw new NotifyError(`Vonage delivery failed: ${errorText}`, {
140
+ channel: this.name,
141
+ driver: 'vonage',
142
+ vonageStatus: result?.messages?.[0]?.status,
143
+ })
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,63 @@
1
+ import type { NotificationChannel } from '../contracts/Channel.ts'
2
+ import type { Notifiable } from '../contracts/Notifiable.ts'
3
+ import type { Notification } from '../Notification.ts'
4
+ import type { WebhookPayload } from '../Notification.ts'
5
+ import { NotifyError } from '../errors/NotifyError.ts'
6
+
7
+ /**
8
+ * Delivers notifications via outgoing HTTP webhooks.
9
+ *
10
+ * The notification's `toWebhook(notifiable)` method should return a
11
+ * `WebhookPayload` with `{ url, body, method?, headers? }`.
12
+ *
13
+ * Sends JSON to the specified URL using native `fetch()`.
14
+ * Defaults to POST if no method is specified.
15
+ */
16
+ export class WebhookChannel implements NotificationChannel {
17
+ readonly name = 'webhook'
18
+
19
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
20
+ const payload = notification.getPayloadFor('webhook', notifiable) as WebhookPayload | undefined
21
+ if (!payload) return
22
+
23
+ if (!payload.url) {
24
+ throw new NotifyError('Webhook URL is required', {
25
+ channel: this.name,
26
+ notificationType: notification.type,
27
+ })
28
+ }
29
+
30
+ const method = payload.method ?? 'POST'
31
+ const headers: Record<string, string> = {
32
+ 'Content-Type': 'application/json',
33
+ ...payload.headers,
34
+ }
35
+
36
+ let response: Response
37
+ try {
38
+ response = await fetch(payload.url, {
39
+ method,
40
+ headers,
41
+ body: JSON.stringify(payload.body),
42
+ })
43
+ } catch (error) {
44
+ throw new NotifyError(`Webhook request failed: ${error instanceof Error ? error.message : String(error)}`, {
45
+ channel: this.name,
46
+ notificationType: notification.type,
47
+ url: payload.url,
48
+ method,
49
+ })
50
+ }
51
+
52
+ if (!response.ok) {
53
+ const errorBody = await response.text().catch(() => 'unknown error')
54
+ throw new NotifyError(`Webhook returned ${response.status}: ${errorBody}`, {
55
+ channel: this.name,
56
+ notificationType: notification.type,
57
+ url: payload.url,
58
+ method,
59
+ statusCode: response.status,
60
+ })
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,41 @@
1
+ import { GeneratorCommand } from '@mantiq/cli'
2
+ import type { ParsedArgs } from '@mantiq/cli'
3
+
4
+ export class MakeNotificationCommand extends GeneratorCommand {
5
+ override name = 'make:notification'
6
+ override description = 'Create a new notification class'
7
+ override usage = 'make:notification <name>'
8
+
9
+ override directory() { return 'app/Notifications' }
10
+ override suffix() { return '' }
11
+
12
+ override stub(name: string, _args: ParsedArgs): string {
13
+ const className = name
14
+
15
+ return `import { Notification } from '@mantiq/notify'
16
+ import type { Notifiable } from '@mantiq/notify'
17
+ import type { Mailable } from '@mantiq/mail'
18
+
19
+ export class ${className} extends Notification {
20
+ constructor(private data: Record<string, any> = {}) { super() }
21
+
22
+ via(notifiable: Notifiable): string[] {
23
+ return ['mail', 'database']
24
+ }
25
+
26
+ toMail(notifiable: Notifiable): Mailable {
27
+ // Return a Mailable instance
28
+ // Example: return new ${className}Email(this.data)
29
+ throw new Error('toMail() not implemented — create a mailable or use markdown')
30
+ }
31
+
32
+ toDatabase(notifiable: Notifiable): Record<string, any> {
33
+ return {
34
+ type: '${className}',
35
+ ...this.data,
36
+ }
37
+ }
38
+ }
39
+ `
40
+ }
41
+ }
@@ -0,0 +1,15 @@
1
+ import type { Notifiable } from './Notifiable.ts'
2
+ import type { Notification } from '../Notification.ts'
3
+
4
+ /**
5
+ * The extension protocol for notification channels.
6
+ *
7
+ * Third-party packages implement this interface and register via:
8
+ * notify().extend('discord', new DiscordChannel())
9
+ *
10
+ * Convention: channel named "foo" calls notification.toFoo(notifiable).
11
+ */
12
+ export interface NotificationChannel {
13
+ readonly name: string
14
+ send(notifiable: Notifiable, notification: Notification): Promise<void>
15
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Notifiable entity — any model that can receive notifications.
3
+ *
4
+ * Implement routeNotificationFor() to tell each channel where to deliver:
5
+ * - 'mail' → return email address
6
+ * - 'sms' → return phone number
7
+ * - 'slack' → return Slack channel/webhook
8
+ * - 'broadcast' → return channel name (defaults to `App.Model.{id}`)
9
+ */
10
+ export interface Notifiable {
11
+ /** Return the routing value for a given channel */
12
+ routeNotificationFor(channel: string): string | null
13
+
14
+ /** Get the entity's primary key */
15
+ getKey(): any
16
+
17
+ /** Get the entity's type name (e.g., 'User') */
18
+ getMorphClass?(): string
19
+ }
@@ -0,0 +1,21 @@
1
+ export interface SmsConfig {
2
+ driver: 'twilio' | 'vonage'
3
+ twilio?: { sid: string; token: string; from: string }
4
+ vonage?: { apiKey: string; apiSecret: string; from: string }
5
+ }
6
+
7
+ export interface SlackConfig {
8
+ webhookUrl?: string
9
+ token?: string
10
+ }
11
+
12
+ export interface NotifyConfig {
13
+ channels?: {
14
+ sms?: SmsConfig
15
+ slack?: SlackConfig
16
+ }
17
+ }
18
+
19
+ export const DEFAULT_CONFIG: NotifyConfig = {
20
+ channels: {},
21
+ }
@@ -0,0 +1,7 @@
1
+ import { MantiqError } from '@mantiq/core'
2
+
3
+ export class NotifyError extends MantiqError {
4
+ constructor(message: string, context?: Record<string, any>) {
5
+ super(message, context)
6
+ }
7
+ }
@@ -0,0 +1,27 @@
1
+ import type { Notifiable } from '../contracts/Notifiable.ts'
2
+ import type { Notification } from '../Notification.ts'
3
+
4
+ export class NotificationSending {
5
+ constructor(
6
+ public readonly notifiable: Notifiable,
7
+ public readonly notification: Notification,
8
+ public readonly channel: string,
9
+ ) {}
10
+ }
11
+
12
+ export class NotificationSent {
13
+ constructor(
14
+ public readonly notifiable: Notifiable,
15
+ public readonly notification: Notification,
16
+ public readonly channel: string,
17
+ ) {}
18
+ }
19
+
20
+ export class NotificationFailed {
21
+ constructor(
22
+ public readonly notifiable: Notifiable,
23
+ public readonly notification: Notification,
24
+ public readonly channel: string,
25
+ public readonly error: Error,
26
+ ) {}
27
+ }
@@ -0,0 +1,8 @@
1
+ import { Application } from '@mantiq/core'
2
+ import type { NotificationManager } from '../NotificationManager.ts'
3
+
4
+ export const NOTIFY_MANAGER = Symbol('NotificationManager')
5
+
6
+ export function notify(): NotificationManager {
7
+ return Application.getInstance().make<NotificationManager>(NOTIFY_MANAGER)
8
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // ── Contracts ────────────────────────────────────────────────────────────────
2
+ export type { NotificationChannel } from './contracts/Channel.ts'
3
+ export type { Notifiable } from './contracts/Notifiable.ts'
4
+ export type { NotifyConfig, SmsConfig, SlackConfig } from './contracts/NotifyConfig.ts'
5
+
6
+ // ── Core ─────────────────────────────────────────────────────────────────────
7
+ export { Notification } from './Notification.ts'
8
+ export type { SlackMessage, BroadcastPayload, SmsPayload, WebhookPayload } from './Notification.ts'
9
+ export { NotificationManager } from './NotificationManager.ts'
10
+
11
+ // ── Channels ─────────────────────────────────────────────────────────────────
12
+ export { MailChannel } from './channels/MailChannel.ts'
13
+ export { DatabaseChannel } from './channels/DatabaseChannel.ts'
14
+ export { BroadcastChannel } from './channels/BroadcastChannel.ts'
15
+ export { SmsChannel } from './channels/SmsChannel.ts'
16
+ export { SlackChannel } from './channels/SlackChannel.ts'
17
+ export { WebhookChannel } from './channels/WebhookChannel.ts'
18
+
19
+ // ── Models ───────────────────────────────────────────────────────────────────
20
+ export { DatabaseNotification } from './models/DatabaseNotification.ts'
21
+
22
+ // ── Events ───────────────────────────────────────────────────────────────────
23
+ export { NotificationSending, NotificationSent, NotificationFailed } from './events/NotificationEvents.ts'
24
+
25
+ // ── Helpers ──────────────────────────────────────────────────────────────────
26
+ export { notify, NOTIFY_MANAGER } from './helpers/notify.ts'
27
+
28
+ // ── Service Provider ─────────────────────────────────────────────────────────
29
+ export { NotificationServiceProvider } from './NotificationServiceProvider.ts'
30
+
31
+ // ── Testing ──────────────────────────────────────────────────────────────────
32
+ export { NotificationFake } from './testing/NotificationFake.ts'
33
+
34
+ // ── Errors ───────────────────────────────────────────────────────────────────
35
+ export { NotifyError } from './errors/NotifyError.ts'
36
+
37
+ // ── Commands ─────────────────────────────────────────────────────────────────
38
+ export { MakeNotificationCommand } from './commands/MakeNotificationCommand.ts'
@@ -0,0 +1,20 @@
1
+ import { Job } from '@mantiq/queue'
2
+ import type { Notifiable } from '../contracts/Notifiable.ts'
3
+ import type { Notification } from '../Notification.ts'
4
+
5
+ export class SendNotificationJob extends Job {
6
+ override name = 'notify:send'
7
+ override tries = 3
8
+
9
+ constructor(
10
+ private notifiable: Notifiable,
11
+ private notification: Notification,
12
+ ) {
13
+ super()
14
+ }
15
+
16
+ override async handle(): Promise<void> {
17
+ const { notify } = await import('../helpers/notify.ts')
18
+ await notify().sendNow(this.notifiable, this.notification)
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ import { Migration } from '@mantiq/database'
2
+ import type { SchemaBuilder } from '@mantiq/database'
3
+
4
+ export default class CreateNotificationsTable extends Migration {
5
+ override async up(schema: SchemaBuilder) {
6
+ await schema.create('notifications', (t) => {
7
+ t.string('id', 36).primary()
8
+ t.string('type', 255)
9
+ t.string('notifiable_type', 255)
10
+ t.integer('notifiable_id').unsigned()
11
+ t.text('data')
12
+ t.timestamp('read_at').nullable()
13
+ t.timestamps()
14
+ })
15
+ }
16
+
17
+ override async down(schema: SchemaBuilder) {
18
+ await schema.dropIfExists('notifications')
19
+ }
20
+ }
@@ -0,0 +1,29 @@
1
+ import { Model } from '@mantiq/database'
2
+
3
+ export class DatabaseNotification extends Model {
4
+ static override table = 'notifications'
5
+ static override primaryKey = 'id'
6
+ static override fillable = ['id', 'type', 'notifiable_type', 'notifiable_id', 'data', 'read_at']
7
+ static override timestamps = true
8
+ static override casts = { data: 'json' as const }
9
+
10
+ get isRead(): boolean {
11
+ return this.getAttribute('read_at') != null
12
+ }
13
+
14
+ get isUnread(): boolean {
15
+ return !this.isRead
16
+ }
17
+
18
+ async markAsRead(): Promise<this> {
19
+ this.setAttribute('read_at', new Date().toISOString())
20
+ await this.save()
21
+ return this
22
+ }
23
+
24
+ async markAsUnread(): Promise<this> {
25
+ this.setAttribute('read_at', null)
26
+ await this.save()
27
+ return this
28
+ }
29
+ }
@@ -0,0 +1,107 @@
1
+ import type { Notifiable } from '../contracts/Notifiable.ts'
2
+ import type { Notification } from '../Notification.ts'
3
+
4
+ interface SentRecord {
5
+ notifiable: Notifiable
6
+ notification: Notification
7
+ channels: string[]
8
+ }
9
+
10
+ /**
11
+ * In-memory notification fake for testing.
12
+ *
13
+ * @example
14
+ * const fake = new NotificationFake()
15
+ * // ... run code that sends notifications ...
16
+ * fake.assertSentTo(user, OrderShipped)
17
+ * fake.assertNotSentTo(user, InvoiceEmail)
18
+ * fake.assertCount(OrderShipped, 2)
19
+ */
20
+ export class NotificationFake {
21
+ private _sent: SentRecord[] = []
22
+
23
+ /** Record a sent notification (called by test harness) */
24
+ async send(notifiable: Notifiable | Notifiable[], notification: Notification): Promise<void> {
25
+ const notifiables = Array.isArray(notifiable) ? notifiable : [notifiable]
26
+ for (const n of notifiables) {
27
+ this._sent.push({
28
+ notifiable: n,
29
+ notification,
30
+ channels: notification.via(n),
31
+ })
32
+ }
33
+ }
34
+
35
+ async sendNow(notifiable: Notifiable, notification: Notification): Promise<void> {
36
+ await this.send(notifiable, notification)
37
+ }
38
+
39
+ // ── Assertions ──────────────────────────────────────────────────────────
40
+
41
+ assertSentTo(notifiable: Notifiable, notificationClass: new (...args: any[]) => Notification, count?: number): void {
42
+ const matches = this._sent.filter(r =>
43
+ r.notifiable.getKey() === notifiable.getKey() &&
44
+ r.notification instanceof notificationClass
45
+ )
46
+ if (matches.length === 0) {
47
+ throw new Error(`Expected [${notificationClass.name}] to be sent to notifiable [${notifiable.getKey()}], but it was not.`)
48
+ }
49
+ if (count !== undefined && matches.length !== count) {
50
+ throw new Error(`Expected [${notificationClass.name}] to be sent ${count} time(s) to [${notifiable.getKey()}], but was sent ${matches.length} time(s).`)
51
+ }
52
+ }
53
+
54
+ assertNotSentTo(notifiable: Notifiable, notificationClass: new (...args: any[]) => Notification): void {
55
+ const matches = this._sent.filter(r =>
56
+ r.notifiable.getKey() === notifiable.getKey() &&
57
+ r.notification instanceof notificationClass
58
+ )
59
+ if (matches.length > 0) {
60
+ throw new Error(`Expected [${notificationClass.name}] NOT to be sent to [${notifiable.getKey()}], but it was sent ${matches.length} time(s).`)
61
+ }
62
+ }
63
+
64
+ assertSent(notificationClass: new (...args: any[]) => Notification, count?: number): void {
65
+ const matches = this._sent.filter(r => r.notification instanceof notificationClass)
66
+ if (matches.length === 0) {
67
+ throw new Error(`Expected [${notificationClass.name}] to be sent, but it was not.`)
68
+ }
69
+ if (count !== undefined && matches.length !== count) {
70
+ throw new Error(`Expected [${notificationClass.name}] to be sent ${count} time(s), but was sent ${matches.length} time(s).`)
71
+ }
72
+ }
73
+
74
+ assertNothingSent(): void {
75
+ if (this._sent.length > 0) {
76
+ const types = [...new Set(this._sent.map(r => r.notification.type))].join(', ')
77
+ throw new Error(`Expected no notifications sent, but ${this._sent.length} were sent: ${types}`)
78
+ }
79
+ }
80
+
81
+ assertCount(notificationClass: new (...args: any[]) => Notification, expected: number): void {
82
+ this.assertSent(notificationClass, expected)
83
+ }
84
+
85
+ assertSentToVia(notifiable: Notifiable, notificationClass: new (...args: any[]) => Notification, channel: string): void {
86
+ const matches = this._sent.filter(r =>
87
+ r.notifiable.getKey() === notifiable.getKey() &&
88
+ r.notification instanceof notificationClass &&
89
+ r.channels.includes(channel)
90
+ )
91
+ if (matches.length === 0) {
92
+ throw new Error(`Expected [${notificationClass.name}] to be sent to [${notifiable.getKey()}] via [${channel}], but it was not.`)
93
+ }
94
+ }
95
+
96
+ // ── Inspection ──────────────────────────────────────────────────────────
97
+
98
+ sent(): SentRecord[] { return [...this._sent] }
99
+
100
+ sentTo(notifiable: Notifiable): SentRecord[] {
101
+ return this._sent.filter(r => r.notifiable.getKey() === notifiable.getKey())
102
+ }
103
+
104
+ reset(): void {
105
+ this._sent = []
106
+ }
107
+ }