@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 +9 -0
- package/package.json +64 -0
- package/src/Notification.ts +102 -0
- package/src/NotificationManager.ts +128 -0
- package/src/NotificationServiceProvider.ts +14 -0
- package/src/channels/BroadcastChannel.ts +51 -0
- package/src/channels/DatabaseChannel.ts +45 -0
- package/src/channels/MailChannel.ts +53 -0
- package/src/channels/SlackChannel.ts +127 -0
- package/src/channels/SmsChannel.ts +146 -0
- package/src/channels/WebhookChannel.ts +63 -0
- package/src/commands/MakeNotificationCommand.ts +41 -0
- package/src/contracts/Channel.ts +15 -0
- package/src/contracts/Notifiable.ts +19 -0
- package/src/contracts/NotifyConfig.ts +21 -0
- package/src/errors/NotifyError.ts +7 -0
- package/src/events/NotificationEvents.ts +27 -0
- package/src/helpers/notify.ts +8 -0
- package/src/index.ts +38 -0
- package/src/jobs/SendNotificationJob.ts +20 -0
- package/src/migrations/CreateNotificationsTable.ts +20 -0
- package/src/models/DatabaseNotification.ts +29 -0
- package/src/testing/NotificationFake.ts +107 -0
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,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
|
+
}
|