@strav/signal 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/package.json +48 -0
- package/src/broadcast/broadcast_manager.ts +424 -0
- package/src/broadcast/client.ts +308 -0
- package/src/broadcast/index.ts +58 -0
- package/src/index.ts +4 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +23 -0
- package/src/mail/mail_manager.ts +111 -0
- package/src/mail/transports/alibaba_transport.ts +88 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/mailgun_transport.ts +74 -0
- package/src/mail/transports/resend_transport.ts +58 -0
- package/src/mail/transports/sendgrid_transport.ts +80 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +98 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +48 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +50 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +127 -0
- package/src/notification/types.ts +122 -0
- package/src/providers/broadcast_provider.ts +22 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/mail_provider.ts +16 -0
- package/src/providers/notification_provider.ts +29 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { NotificationChannel, Notifiable, NotificationPayload } from '../types.ts'
|
|
2
|
+
import Database from '@stravigor/database/database/database'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stores notifications in the `_strav_notifications` table.
|
|
6
|
+
*
|
|
7
|
+
* The database channel enables in-app notification features
|
|
8
|
+
* (unread badges, notification feeds, etc.).
|
|
9
|
+
*/
|
|
10
|
+
export class DatabaseChannel implements NotificationChannel {
|
|
11
|
+
readonly name = 'database'
|
|
12
|
+
|
|
13
|
+
async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
|
|
14
|
+
const envelope = payload.database
|
|
15
|
+
if (!envelope) return
|
|
16
|
+
|
|
17
|
+
const sql = Database.raw
|
|
18
|
+
|
|
19
|
+
await sql`
|
|
20
|
+
INSERT INTO "_strav_notifications"
|
|
21
|
+
("notifiable_type", "notifiable_id", "type", "data")
|
|
22
|
+
VALUES (
|
|
23
|
+
${notifiable.notifiableType()},
|
|
24
|
+
${String(notifiable.notifiableId())},
|
|
25
|
+
${envelope.type},
|
|
26
|
+
${JSON.stringify(envelope.data)}
|
|
27
|
+
)
|
|
28
|
+
`
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/kernel/exceptions/errors'
|
|
2
|
+
import type {
|
|
3
|
+
NotificationChannel,
|
|
4
|
+
Notifiable,
|
|
5
|
+
NotificationPayload,
|
|
6
|
+
NotificationConfig,
|
|
7
|
+
} from '../types.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Delivers notifications via Discord webhook.
|
|
11
|
+
*
|
|
12
|
+
* URL resolution order:
|
|
13
|
+
* 1. `DiscordEnvelope.url` (per-notification override)
|
|
14
|
+
* 2. `notifiable.routeNotificationForDiscord()` (per-recipient)
|
|
15
|
+
* 3. Config discord `default` entry
|
|
16
|
+
*/
|
|
17
|
+
export class DiscordChannel implements NotificationChannel {
|
|
18
|
+
readonly name = 'discord'
|
|
19
|
+
|
|
20
|
+
constructor(private config: NotificationConfig) {}
|
|
21
|
+
|
|
22
|
+
async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
|
|
23
|
+
const envelope = payload.discord
|
|
24
|
+
if (!envelope) return
|
|
25
|
+
|
|
26
|
+
const url =
|
|
27
|
+
envelope.url ??
|
|
28
|
+
notifiable.routeNotificationForDiscord?.() ??
|
|
29
|
+
this.config.discord?.default ??
|
|
30
|
+
null
|
|
31
|
+
|
|
32
|
+
if (!url) return
|
|
33
|
+
|
|
34
|
+
const body: Record<string, unknown> = {}
|
|
35
|
+
if (envelope.content) body.content = envelope.content
|
|
36
|
+
if (envelope.embeds?.length) body.embeds = envelope.embeds
|
|
37
|
+
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new ExternalServiceError('Discord', response.status, await response.text())
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NotificationChannel, Notifiable, NotificationPayload } from '../types.ts'
|
|
2
|
+
import { PendingMail } from '../../mail/helpers.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Delivers notifications via the existing Mail module.
|
|
6
|
+
*
|
|
7
|
+
* Reads the email address from `notifiable.routeNotificationForEmail()`
|
|
8
|
+
* and builds a {@link PendingMail} from the {@link MailEnvelope}.
|
|
9
|
+
*/
|
|
10
|
+
export class EmailChannel implements NotificationChannel {
|
|
11
|
+
readonly name = 'email'
|
|
12
|
+
|
|
13
|
+
async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
|
|
14
|
+
const envelope = payload.mail
|
|
15
|
+
if (!envelope) return
|
|
16
|
+
|
|
17
|
+
const address = notifiable.routeNotificationForEmail?.()
|
|
18
|
+
if (!address) return
|
|
19
|
+
|
|
20
|
+
const pending = new PendingMail(address).subject(envelope.subject)
|
|
21
|
+
|
|
22
|
+
if (envelope.from) pending.from(envelope.from)
|
|
23
|
+
if (envelope.cc) pending.cc(envelope.cc)
|
|
24
|
+
if (envelope.bcc) pending.bcc(envelope.bcc)
|
|
25
|
+
if (envelope.replyTo) pending.replyTo(envelope.replyTo)
|
|
26
|
+
|
|
27
|
+
if (envelope.template) {
|
|
28
|
+
pending.template(envelope.template, envelope.templateData ?? {})
|
|
29
|
+
} else if (envelope.html) {
|
|
30
|
+
pending.html(envelope.html)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (envelope.text) pending.text(envelope.text)
|
|
34
|
+
|
|
35
|
+
await pending.send()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ExternalServiceError } from '@stravigor/kernel/exceptions/errors'
|
|
2
|
+
import type {
|
|
3
|
+
NotificationChannel,
|
|
4
|
+
Notifiable,
|
|
5
|
+
NotificationPayload,
|
|
6
|
+
NotificationConfig,
|
|
7
|
+
} from '../types.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Delivers notifications via HTTP POST to a webhook URL.
|
|
11
|
+
*
|
|
12
|
+
* URL resolution order:
|
|
13
|
+
* 1. `WebhookEnvelope.url` (per-notification override)
|
|
14
|
+
* 2. `notifiable.routeNotificationForWebhook()` (per-recipient)
|
|
15
|
+
* 3. Config webhooks `default` entry
|
|
16
|
+
*/
|
|
17
|
+
export class WebhookChannel implements NotificationChannel {
|
|
18
|
+
readonly name = 'webhook'
|
|
19
|
+
|
|
20
|
+
constructor(private config: NotificationConfig) {}
|
|
21
|
+
|
|
22
|
+
async send(notifiable: Notifiable, payload: NotificationPayload): Promise<void> {
|
|
23
|
+
const envelope = payload.webhook
|
|
24
|
+
if (!envelope) return
|
|
25
|
+
|
|
26
|
+
const url =
|
|
27
|
+
envelope.url ??
|
|
28
|
+
notifiable.routeNotificationForWebhook?.() ??
|
|
29
|
+
this.config.webhooks?.default?.url ??
|
|
30
|
+
null
|
|
31
|
+
|
|
32
|
+
if (!url) return
|
|
33
|
+
|
|
34
|
+
const headers: Record<string, string> = {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
...this.config.webhooks?.default?.headers,
|
|
37
|
+
...envelope.headers,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const response = await fetch(url, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers,
|
|
43
|
+
body: JSON.stringify(envelope.payload),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new ExternalServiceError('Webhook', response.status, await response.text())
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import NotificationManager from './notification_manager.ts'
|
|
2
|
+
import type { BaseNotification } from './base_notification.ts'
|
|
3
|
+
import type { Notifiable, NotificationRecord } from './types.ts'
|
|
4
|
+
import Queue from '@stravigor/queue/queue/queue'
|
|
5
|
+
import Emitter from '@stravigor/kernel/events/emitter'
|
|
6
|
+
import Database from '@stravigor/database/database/database'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Send a notification to one or more recipients.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* import { notify } from '@stravigor/signal/notification'
|
|
13
|
+
*
|
|
14
|
+
* await notify(user, new TaskAssignedNotification(task, assigner))
|
|
15
|
+
* await notify([user1, user2], new InvoicePaidNotification(invoice))
|
|
16
|
+
*/
|
|
17
|
+
export async function notify(
|
|
18
|
+
notifiable: Notifiable | Notifiable[],
|
|
19
|
+
notification: BaseNotification
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const recipients = Array.isArray(notifiable) ? notifiable : [notifiable]
|
|
22
|
+
|
|
23
|
+
for (const recipient of recipients) {
|
|
24
|
+
if (notification.shouldQueue()) {
|
|
25
|
+
const payload = notification.buildPayload(recipient)
|
|
26
|
+
|
|
27
|
+
// Pre-resolve routing info so channels can deliver from the queue worker
|
|
28
|
+
const routing: Record<string, string> = {}
|
|
29
|
+
const email = recipient.routeNotificationForEmail?.()
|
|
30
|
+
if (email) routing.email = email
|
|
31
|
+
const webhook = recipient.routeNotificationForWebhook?.()
|
|
32
|
+
if (webhook) routing.webhook = webhook
|
|
33
|
+
const discord = recipient.routeNotificationForDiscord?.()
|
|
34
|
+
if (discord) routing.discord = discord
|
|
35
|
+
|
|
36
|
+
await Queue.push(
|
|
37
|
+
'strav:send-notification',
|
|
38
|
+
{
|
|
39
|
+
notifiable: { id: recipient.notifiableId(), type: recipient.notifiableType() },
|
|
40
|
+
routing,
|
|
41
|
+
payload,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
queue: notification.queueOptions().queue ?? NotificationManager.config.queue,
|
|
45
|
+
delay: notification.queueOptions().delay,
|
|
46
|
+
attempts: notification.queueOptions().attempts,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
} else {
|
|
50
|
+
await sendNow(recipient, notification)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Send a notification immediately (bypasses queue). */
|
|
56
|
+
async function sendNow(notifiable: Notifiable, notification: BaseNotification): Promise<void> {
|
|
57
|
+
const payload = notification.buildPayload(notifiable)
|
|
58
|
+
|
|
59
|
+
for (const channelName of payload.channels) {
|
|
60
|
+
const channel = NotificationManager.channel(channelName)
|
|
61
|
+
await channel.send(notifiable, payload)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// In-app notification query helpers
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Notification query helper — convenience API for in-app notification reads.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* import { notifications } from '@stravigor/signal/notification'
|
|
74
|
+
*
|
|
75
|
+
* const unread = await notifications.unread('user', userId)
|
|
76
|
+
* await notifications.markAsRead(notificationId)
|
|
77
|
+
* await notifications.markAllAsRead('user', userId)
|
|
78
|
+
*/
|
|
79
|
+
export const notifications = {
|
|
80
|
+
/** Get all notifications for a notifiable, newest first. */
|
|
81
|
+
async all(type: string, id: string | number, limit = 50): Promise<NotificationRecord[]> {
|
|
82
|
+
const sql = Database.raw
|
|
83
|
+
const rows = await sql`
|
|
84
|
+
SELECT * FROM "_strav_notifications"
|
|
85
|
+
WHERE "notifiable_type" = ${type} AND "notifiable_id" = ${String(id)}
|
|
86
|
+
ORDER BY "created_at" DESC
|
|
87
|
+
LIMIT ${limit}
|
|
88
|
+
`
|
|
89
|
+
return rows.map(hydrateNotification)
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** Get unread notifications for a notifiable. */
|
|
93
|
+
async unread(type: string, id: string | number, limit = 50): Promise<NotificationRecord[]> {
|
|
94
|
+
const sql = Database.raw
|
|
95
|
+
const rows = await sql`
|
|
96
|
+
SELECT * FROM "_strav_notifications"
|
|
97
|
+
WHERE "notifiable_type" = ${type}
|
|
98
|
+
AND "notifiable_id" = ${String(id)}
|
|
99
|
+
AND "read_at" IS NULL
|
|
100
|
+
ORDER BY "created_at" DESC
|
|
101
|
+
LIMIT ${limit}
|
|
102
|
+
`
|
|
103
|
+
return rows.map(hydrateNotification)
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/** Count unread notifications. */
|
|
107
|
+
async unreadCount(type: string, id: string | number): Promise<number> {
|
|
108
|
+
const sql = Database.raw
|
|
109
|
+
const rows = await sql`
|
|
110
|
+
SELECT COUNT(*)::int AS count FROM "_strav_notifications"
|
|
111
|
+
WHERE "notifiable_type" = ${type}
|
|
112
|
+
AND "notifiable_id" = ${String(id)}
|
|
113
|
+
AND "read_at" IS NULL
|
|
114
|
+
`
|
|
115
|
+
return (rows[0] as Record<string, unknown>).count as number
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/** Mark a single notification as read. */
|
|
119
|
+
async markAsRead(id: string): Promise<void> {
|
|
120
|
+
const sql = Database.raw
|
|
121
|
+
await sql`
|
|
122
|
+
UPDATE "_strav_notifications"
|
|
123
|
+
SET "read_at" = NOW()
|
|
124
|
+
WHERE "id" = ${id} AND "read_at" IS NULL
|
|
125
|
+
`
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
/** Mark all notifications as read for a notifiable. */
|
|
129
|
+
async markAllAsRead(type: string, id: string | number): Promise<void> {
|
|
130
|
+
const sql = Database.raw
|
|
131
|
+
await sql`
|
|
132
|
+
UPDATE "_strav_notifications"
|
|
133
|
+
SET "read_at" = NOW()
|
|
134
|
+
WHERE "notifiable_type" = ${type}
|
|
135
|
+
AND "notifiable_id" = ${String(id)}
|
|
136
|
+
AND "read_at" IS NULL
|
|
137
|
+
`
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
/** Delete a single notification. */
|
|
141
|
+
async delete(id: string): Promise<void> {
|
|
142
|
+
const sql = Database.raw
|
|
143
|
+
await sql`DELETE FROM "_strav_notifications" WHERE "id" = ${id}`
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
/** Delete all notifications for a notifiable. */
|
|
147
|
+
async deleteAll(type: string, id: string | number): Promise<void> {
|
|
148
|
+
const sql = Database.raw
|
|
149
|
+
await sql`
|
|
150
|
+
DELETE FROM "_strav_notifications"
|
|
151
|
+
WHERE "notifiable_type" = ${type} AND "notifiable_id" = ${String(id)}
|
|
152
|
+
`
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Register the built-in queue handler for async notification delivery.
|
|
157
|
+
* Call this in your app bootstrap after Queue and NotificationManager are configured.
|
|
158
|
+
*/
|
|
159
|
+
registerQueueHandler(): void {
|
|
160
|
+
Queue.handle('strav:send-notification', async (job: any) => {
|
|
161
|
+
const { notifiable: ref, routing, payload } = job
|
|
162
|
+
|
|
163
|
+
// Reconstruct a minimal Notifiable proxy with pre-resolved routing
|
|
164
|
+
const notifiable: Notifiable = {
|
|
165
|
+
notifiableId: () => ref.id,
|
|
166
|
+
notifiableType: () => ref.type,
|
|
167
|
+
routeNotificationForEmail: () => routing?.email ?? null,
|
|
168
|
+
routeNotificationForWebhook: () => routing?.webhook ?? null,
|
|
169
|
+
routeNotificationForDiscord: () => routing?.discord ?? null,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const channelName of payload.channels) {
|
|
173
|
+
const channel = NotificationManager.channel(channelName)
|
|
174
|
+
await channel.send(notifiable, payload)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Wire event-to-notification mappings to the Emitter.
|
|
181
|
+
* Call this once during bootstrap after registering all bindings via
|
|
182
|
+
* {@link NotificationManager.on}.
|
|
183
|
+
*/
|
|
184
|
+
wireEvents(): void {
|
|
185
|
+
for (const [event, bindings] of NotificationManager.eventBindings()) {
|
|
186
|
+
Emitter.on(event, async (eventPayload: any) => {
|
|
187
|
+
for (const binding of bindings) {
|
|
188
|
+
const notification = binding.create(eventPayload)
|
|
189
|
+
const recipients = await binding.recipients(eventPayload)
|
|
190
|
+
await notify(recipients, notification)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// Hydration
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
function hydrateNotification(row: Record<string, unknown>): NotificationRecord {
|
|
202
|
+
return {
|
|
203
|
+
id: row.id as string,
|
|
204
|
+
notifiableType: row.notifiable_type as string,
|
|
205
|
+
notifiableId: row.notifiable_id as string,
|
|
206
|
+
type: row.type as string,
|
|
207
|
+
data: (typeof row.data === 'string' ? JSON.parse(row.data) : row.data) as Record<
|
|
208
|
+
string,
|
|
209
|
+
unknown
|
|
210
|
+
>,
|
|
211
|
+
readAt: (row.read_at as Date) ?? null,
|
|
212
|
+
createdAt: row.created_at as Date,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { default, default as NotificationManager } from './notification_manager.ts'
|
|
2
|
+
export { BaseNotification } from './base_notification.ts'
|
|
3
|
+
export { notify, notifications } from './helpers.ts'
|
|
4
|
+
export { EmailChannel } from './channels/email_channel.ts'
|
|
5
|
+
export { DatabaseChannel } from './channels/database_channel.ts'
|
|
6
|
+
export { WebhookChannel } from './channels/webhook_channel.ts'
|
|
7
|
+
export { DiscordChannel } from './channels/discord_channel.ts'
|
|
8
|
+
export type {
|
|
9
|
+
NotificationChannel,
|
|
10
|
+
Notifiable,
|
|
11
|
+
NotificationPayload,
|
|
12
|
+
NotificationRecord,
|
|
13
|
+
NotificationConfig,
|
|
14
|
+
MailEnvelope,
|
|
15
|
+
DatabaseEnvelope,
|
|
16
|
+
WebhookEnvelope,
|
|
17
|
+
DiscordEnvelope,
|
|
18
|
+
DiscordEmbed,
|
|
19
|
+
EventNotificationBinding,
|
|
20
|
+
} from './types.ts'
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
2
|
+
import Configuration from '@stravigor/kernel/config/configuration'
|
|
3
|
+
import Database from '@stravigor/database/database/database'
|
|
4
|
+
import type { NotificationChannel, NotificationConfig, EventNotificationBinding } from './types.ts'
|
|
5
|
+
import { EmailChannel } from './channels/email_channel.ts'
|
|
6
|
+
import { DatabaseChannel } from './channels/database_channel.ts'
|
|
7
|
+
import { WebhookChannel } from './channels/webhook_channel.ts'
|
|
8
|
+
import { DiscordChannel } from './channels/discord_channel.ts'
|
|
9
|
+
import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Central notification configuration hub.
|
|
13
|
+
*
|
|
14
|
+
* Resolved once via the DI container — reads the notification config,
|
|
15
|
+
* registers built-in channels, and provides an event-to-notification registry.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* app.singleton(NotificationManager)
|
|
19
|
+
* app.resolve(NotificationManager)
|
|
20
|
+
* await NotificationManager.ensureTable()
|
|
21
|
+
*
|
|
22
|
+
* // Register a custom channel
|
|
23
|
+
* NotificationManager.useChannel(new SlackChannel())
|
|
24
|
+
*/
|
|
25
|
+
@inject
|
|
26
|
+
export default class NotificationManager {
|
|
27
|
+
private static _db: Database
|
|
28
|
+
private static _config: NotificationConfig
|
|
29
|
+
private static _channels = new Map<string, NotificationChannel>()
|
|
30
|
+
private static _eventMap = new Map<string, EventNotificationBinding[]>()
|
|
31
|
+
|
|
32
|
+
constructor(db: Database, config: Configuration) {
|
|
33
|
+
NotificationManager._db = db
|
|
34
|
+
NotificationManager._config = {
|
|
35
|
+
channels: config.get('notification.channels', ['database']) as string[],
|
|
36
|
+
queue: config.get('notification.queue', 'default') as string,
|
|
37
|
+
webhooks: config.get('notification.webhooks', {}) as Record<
|
|
38
|
+
string,
|
|
39
|
+
{ url: string; headers?: Record<string, string> }
|
|
40
|
+
>,
|
|
41
|
+
discord: config.get('notification.discord', {}) as Record<string, string>,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Register built-in channels
|
|
45
|
+
NotificationManager._channels.set('email', new EmailChannel())
|
|
46
|
+
NotificationManager._channels.set('database', new DatabaseChannel())
|
|
47
|
+
NotificationManager._channels.set('webhook', new WebhookChannel(NotificationManager._config))
|
|
48
|
+
NotificationManager._channels.set('discord', new DiscordChannel(NotificationManager._config))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static get db(): Database {
|
|
52
|
+
if (!NotificationManager._db) {
|
|
53
|
+
throw new Error('NotificationManager not configured. Resolve it through the container first.')
|
|
54
|
+
}
|
|
55
|
+
return NotificationManager._db
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static get config(): NotificationConfig {
|
|
59
|
+
return NotificationManager._config
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Get a registered channel by name. */
|
|
63
|
+
static channel(name: string): NotificationChannel {
|
|
64
|
+
const ch = NotificationManager._channels.get(name)
|
|
65
|
+
if (!ch) throw new ConfigurationError(`Unknown notification channel: ${name}`)
|
|
66
|
+
return ch
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Register a custom notification channel (or replace a built-in one). */
|
|
70
|
+
static useChannel(channel: NotificationChannel): void {
|
|
71
|
+
NotificationManager._channels.set(channel.name, channel)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Create the _strav_notifications table if it doesn't exist. */
|
|
75
|
+
static async ensureTable(): Promise<void> {
|
|
76
|
+
const sql = NotificationManager.db.sql
|
|
77
|
+
|
|
78
|
+
await sql`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS "_strav_notifications" (
|
|
80
|
+
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
81
|
+
"notifiable_type" VARCHAR(255) NOT NULL,
|
|
82
|
+
"notifiable_id" VARCHAR(255) NOT NULL,
|
|
83
|
+
"type" VARCHAR(255) NOT NULL,
|
|
84
|
+
"data" JSONB NOT NULL DEFAULT '{}',
|
|
85
|
+
"read_at" TIMESTAMPTZ,
|
|
86
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
87
|
+
)
|
|
88
|
+
`
|
|
89
|
+
|
|
90
|
+
await sql`
|
|
91
|
+
CREATE INDEX IF NOT EXISTS "idx_strav_notifications_notifiable"
|
|
92
|
+
ON "_strav_notifications" ("notifiable_type", "notifiable_id", "created_at" DESC)
|
|
93
|
+
`
|
|
94
|
+
|
|
95
|
+
await sql`
|
|
96
|
+
CREATE INDEX IF NOT EXISTS "idx_strav_notifications_unread"
|
|
97
|
+
ON "_strav_notifications" ("notifiable_type", "notifiable_id")
|
|
98
|
+
WHERE "read_at" IS NULL
|
|
99
|
+
`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Register an event-to-notification mapping.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* NotificationManager.on('task.assigned', {
|
|
107
|
+
* create: (payload) => new TaskAssignedNotification(payload.task, payload.assigner),
|
|
108
|
+
* recipients: (payload) => payload.assignee,
|
|
109
|
+
* })
|
|
110
|
+
*/
|
|
111
|
+
static on(event: string, binding: EventNotificationBinding): void {
|
|
112
|
+
const existing = NotificationManager._eventMap.get(event) ?? []
|
|
113
|
+
existing.push(binding)
|
|
114
|
+
NotificationManager._eventMap.set(event, existing)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Expose the event map (used by wireEvents). */
|
|
118
|
+
static eventBindings(): Map<string, EventNotificationBinding[]> {
|
|
119
|
+
return NotificationManager._eventMap
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Clear all state. For testing only. */
|
|
123
|
+
static reset(): void {
|
|
124
|
+
NotificationManager._channels.clear()
|
|
125
|
+
NotificationManager._eventMap.clear()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** A recipient that can receive notifications. */
|
|
2
|
+
export interface Notifiable {
|
|
3
|
+
/** Unique identifier for the notifiable entity. */
|
|
4
|
+
notifiableId(): string | number
|
|
5
|
+
/** Type discriminator (e.g., 'user', 'organization'). */
|
|
6
|
+
notifiableType(): string
|
|
7
|
+
/** Email address for the email channel. Returns null to skip email. */
|
|
8
|
+
routeNotificationForEmail?(): string | null
|
|
9
|
+
/** Webhook URL for the webhook channel. Returns null to skip. */
|
|
10
|
+
routeNotificationForWebhook?(): string | null
|
|
11
|
+
/** Discord webhook URL. Returns null to skip. */
|
|
12
|
+
routeNotificationForDiscord?(): string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// -- Channel envelopes --------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/** Envelope produced by a notification for the email channel. */
|
|
18
|
+
export interface MailEnvelope {
|
|
19
|
+
subject: string
|
|
20
|
+
template?: string
|
|
21
|
+
templateData?: Record<string, unknown>
|
|
22
|
+
html?: string
|
|
23
|
+
text?: string
|
|
24
|
+
from?: string
|
|
25
|
+
cc?: string | string[]
|
|
26
|
+
bcc?: string | string[]
|
|
27
|
+
replyTo?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Envelope produced by a notification for the database (in-app) channel. */
|
|
31
|
+
export interface DatabaseEnvelope {
|
|
32
|
+
/** Notification type / category (e.g., 'task.assigned', 'invoice.paid'). */
|
|
33
|
+
type: string
|
|
34
|
+
/** Structured data stored as JSONB. */
|
|
35
|
+
data: Record<string, unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Envelope produced by a notification for the webhook channel. */
|
|
39
|
+
export interface WebhookEnvelope {
|
|
40
|
+
/** JSON payload to POST. */
|
|
41
|
+
payload: Record<string, unknown>
|
|
42
|
+
/** Optional custom headers. */
|
|
43
|
+
headers?: Record<string, string>
|
|
44
|
+
/** Override the webhook URL from notifiable routing. */
|
|
45
|
+
url?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Envelope produced by a notification for the Discord channel. */
|
|
49
|
+
export interface DiscordEnvelope {
|
|
50
|
+
/** Plain text content. */
|
|
51
|
+
content?: string
|
|
52
|
+
/** Discord embed objects. */
|
|
53
|
+
embeds?: DiscordEmbed[]
|
|
54
|
+
/** Override the webhook URL from notifiable routing. */
|
|
55
|
+
url?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DiscordEmbed {
|
|
59
|
+
title?: string
|
|
60
|
+
description?: string
|
|
61
|
+
url?: string
|
|
62
|
+
color?: number
|
|
63
|
+
fields?: { name: string; value: string; inline?: boolean }[]
|
|
64
|
+
footer?: { text: string }
|
|
65
|
+
timestamp?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// -- Channel interface --------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** Serializable envelope bundle built by BaseNotification. */
|
|
71
|
+
export interface NotificationPayload {
|
|
72
|
+
notificationClass: string
|
|
73
|
+
channels: string[]
|
|
74
|
+
mail?: MailEnvelope
|
|
75
|
+
database?: DatabaseEnvelope
|
|
76
|
+
webhook?: WebhookEnvelope
|
|
77
|
+
discord?: DiscordEnvelope
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pluggable notification channel backend.
|
|
82
|
+
* Implement this interface for custom channels.
|
|
83
|
+
*/
|
|
84
|
+
export interface NotificationChannel {
|
|
85
|
+
readonly name: string
|
|
86
|
+
send(notifiable: Notifiable, payload: NotificationPayload): Promise<void>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// -- Database records ---------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/** A stored in-app notification row. */
|
|
92
|
+
export interface NotificationRecord {
|
|
93
|
+
id: string
|
|
94
|
+
notifiableType: string
|
|
95
|
+
notifiableId: string
|
|
96
|
+
type: string
|
|
97
|
+
data: Record<string, unknown>
|
|
98
|
+
readAt: Date | null
|
|
99
|
+
createdAt: Date
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// -- Configuration ------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export interface NotificationConfig {
|
|
105
|
+
/** Default channels when a notification does not specify via(). */
|
|
106
|
+
channels: string[]
|
|
107
|
+
/** Queue name for async notifications. */
|
|
108
|
+
queue: string
|
|
109
|
+
/** Named webhook endpoints. */
|
|
110
|
+
webhooks: Record<string, { url: string; headers?: Record<string, string> }>
|
|
111
|
+
/** Named Discord webhook URLs. */
|
|
112
|
+
discord: Record<string, string>
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// -- Event binding ------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export interface EventNotificationBinding {
|
|
118
|
+
/** Factory that creates the notification from the event payload. */
|
|
119
|
+
create: (payload: any) => import('./base_notification.ts').BaseNotification
|
|
120
|
+
/** Resolves which notifiable(s) should receive this notification. */
|
|
121
|
+
recipients: (payload: any) => Notifiable | Notifiable[] | Promise<Notifiable | Notifiable[]>
|
|
122
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import ServiceProvider from '@stravigor/kernel/core/service_provider'
|
|
2
|
+
import type Application from '@stravigor/kernel/core/application'
|
|
3
|
+
import BroadcastManager from '../broadcast/broadcast_manager.ts'
|
|
4
|
+
import type { BootOptions } from '../broadcast/broadcast_manager.ts'
|
|
5
|
+
import Router from '@stravigor/http/http/router'
|
|
6
|
+
|
|
7
|
+
export default class BroadcastProvider extends ServiceProvider {
|
|
8
|
+
readonly name = 'broadcast'
|
|
9
|
+
|
|
10
|
+
constructor(private options?: BootOptions) {
|
|
11
|
+
super()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override boot(app: Application): void {
|
|
15
|
+
const router = app.resolve(Router)
|
|
16
|
+
BroadcastManager.boot(router, this.options)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override shutdown(): void {
|
|
20
|
+
BroadcastManager.reset()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { default as MailProvider } from './mail_provider.ts'
|
|
2
|
+
export { default as NotificationProvider } from './notification_provider.ts'
|
|
3
|
+
export { default as BroadcastProvider } from './broadcast_provider.ts'
|
|
4
|
+
|
|
5
|
+
export type { NotificationProviderOptions } from './notification_provider.ts'
|