@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.
@@ -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'