@zerotal/notifications 1.0.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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/package.json +58 -0
- package/src/BroadcastChannel.ts +80 -0
- package/src/BroadcastMessage.ts +32 -0
- package/src/BroadcastNotificationJob.ts +51 -0
- package/src/DatabaseChannel.ts +295 -0
- package/src/MailChannel.ts +93 -0
- package/src/Notifiable.ts +95 -0
- package/src/Notification.ts +114 -0
- package/src/NotificationFake.ts +282 -0
- package/src/NotificationManager.ts +269 -0
- package/src/NotificationRegistry.ts +37 -0
- package/src/OnDemandNotifiable.ts +46 -0
- package/src/SendNotificationJob.ts +67 -0
- package/src/SlackChannel.ts +76 -0
- package/src/SmsChannel.ts +151 -0
- package/src/admin.ts +219 -0
- package/src/commands/NotificationsPruneCommand.ts +54 -0
- package/src/commands/NotificationsTestCommand.ts +63 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +122 -0
- package/src/drivers/LogDriver.ts +41 -0
- package/src/drivers/MailDriver.ts +54 -0
- package/src/drivers/ResendDriver.ts +54 -0
- package/src/drivers/SmtpDriver.ts +510 -0
- package/src/errors.ts +163 -0
- package/src/events.ts +70 -0
- package/src/facades/Notify.ts +3 -0
- package/src/global.d.ts +31 -0
- package/src/index.ts +68 -0
- package/src/messages/MailMessage.ts +342 -0
- package/src/observability.ts +146 -0
- package/src/provider/NotificationProvider.ts +62 -0
- package/src/serialization.ts +188 -0
- package/src/stats.ts +93 -0
- package/src/types.ts +129 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { Application, currentApp } from "@zerotal/core";
|
|
2
|
+
import type { Notifiable } from "./types.ts";
|
|
3
|
+
import type { Notification } from "./Notification.ts";
|
|
4
|
+
import type { NotificationRecord } from "./DatabaseChannel.ts";
|
|
5
|
+
import type { OnDemandRoutes } from "./OnDemandNotifiable.ts";
|
|
6
|
+
import { OnDemandNotifiable } from "./OnDemandNotifiable.ts";
|
|
7
|
+
|
|
8
|
+
type Binding = unknown;
|
|
9
|
+
|
|
10
|
+
interface CapturedNotification {
|
|
11
|
+
notifiable: Notifiable;
|
|
12
|
+
notification: Notification;
|
|
13
|
+
/** The channels the notification declared for this recipient. */
|
|
14
|
+
channels: string[];
|
|
15
|
+
/** Whether it went through `queue()` rather than `send()`. */
|
|
16
|
+
queued: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Drop-in replacement for NotificationManager that captures sent notifications
|
|
21
|
+
* instead of delivering them. Install at the start of a test; restore after.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* const notify = NotificationFake.install();
|
|
25
|
+
*
|
|
26
|
+
* await Notify.send(user, new OrderShippedNotification(order));
|
|
27
|
+
*
|
|
28
|
+
* notify.assertSentTo(user, OrderShippedNotification);
|
|
29
|
+
* notify.assertSentOn(user, OrderShippedNotification, "mail");
|
|
30
|
+
* notify.assertNothingSent(); // or assertSentCount(1)
|
|
31
|
+
*
|
|
32
|
+
* notify.restore(); // call in afterEach
|
|
33
|
+
*/
|
|
34
|
+
export class NotificationFake {
|
|
35
|
+
private readonly _sent: CapturedNotification[] = [];
|
|
36
|
+
private readonly _app: Application;
|
|
37
|
+
private readonly _original: Binding;
|
|
38
|
+
|
|
39
|
+
private constructor(app: Application, original: Binding) {
|
|
40
|
+
this._app = app;
|
|
41
|
+
this._original = original;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Replace the 'notifications' container binding with this fake. */
|
|
45
|
+
static install(): NotificationFake {
|
|
46
|
+
const app = currentApp();
|
|
47
|
+
const original = app.container.registry.get("notifications");
|
|
48
|
+
const fake = new NotificationFake(app, original);
|
|
49
|
+
app.container.value("notifications", fake);
|
|
50
|
+
return fake;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Restore the original 'notifications' binding. Call in afterEach. */
|
|
54
|
+
restore(): void {
|
|
55
|
+
if (this._original !== undefined) {
|
|
56
|
+
this._app.container.registry.set("notifications", this._original as never);
|
|
57
|
+
} else {
|
|
58
|
+
this._app.container.registry.delete("notifications");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Notification manager interface ───────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
65
|
+
this._capture(notifiable, notification, false);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async queue(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
69
|
+
this._capture(notifiable, notification, true);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async sendMany(notifiables: Iterable<Notifiable>, notification: Notification): Promise<void> {
|
|
73
|
+
for (const notifiable of notifiables) this._capture(notifiable, notification, false);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async queueMany(notifiables: Iterable<Notifiable>, notification: Notification): Promise<void> {
|
|
77
|
+
for (const notifiable of notifiables) this._capture(notifiable, notification, true);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
route(routes: OnDemandRoutes): {
|
|
81
|
+
notify(notification: Notification): Promise<void>;
|
|
82
|
+
notifyLater(notification: Notification): Promise<void>;
|
|
83
|
+
} {
|
|
84
|
+
const notifiable = new OnDemandNotifiable(routes);
|
|
85
|
+
return {
|
|
86
|
+
notify: async (notification) => this._capture(notifiable, notification, false),
|
|
87
|
+
notifyLater: async (notification) => this._capture(notifiable, notification, true),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Registering a custom channel on the fake is a no-op — nothing is delivered. */
|
|
92
|
+
extend(): this {
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
channels(): string[] {
|
|
97
|
+
return ["mail", "database", "slack", "sms", "broadcast"];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* An inert stand-in for the real database channel.
|
|
102
|
+
*
|
|
103
|
+
* A faked send writes no rows, so the inbox is empty rather than absent — code
|
|
104
|
+
* under test that reads `unreadNotifications()` keeps working instead of
|
|
105
|
+
* throwing on a missing property. Assert on what was sent, not on this.
|
|
106
|
+
*/
|
|
107
|
+
get database(): {
|
|
108
|
+
all(): Promise<NotificationRecord[]>;
|
|
109
|
+
unread(): Promise<NotificationRecord[]>;
|
|
110
|
+
unreadCount(): Promise<number>;
|
|
111
|
+
markAllAsRead(): Promise<void>;
|
|
112
|
+
markAsRead(): Promise<void>;
|
|
113
|
+
markAsUnread(): Promise<void>;
|
|
114
|
+
delete(): Promise<void>;
|
|
115
|
+
clear(): Promise<void>;
|
|
116
|
+
} {
|
|
117
|
+
return {
|
|
118
|
+
all: async () => [],
|
|
119
|
+
unread: async () => [],
|
|
120
|
+
unreadCount: async () => 0,
|
|
121
|
+
markAllAsRead: async () => undefined,
|
|
122
|
+
markAsRead: async () => undefined,
|
|
123
|
+
markAsUnread: async () => undefined,
|
|
124
|
+
delete: async () => undefined,
|
|
125
|
+
clear: async () => undefined,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private _capture(notifiable: Notifiable, notification: Notification, queued: boolean): void {
|
|
130
|
+
let channels: string[] = [];
|
|
131
|
+
try {
|
|
132
|
+
channels = notification.channels(notifiable);
|
|
133
|
+
} catch {
|
|
134
|
+
/* a notification whose channels() throws is still worth recording */
|
|
135
|
+
}
|
|
136
|
+
this._sent.push({ notifiable, notification, channels, queued });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Assertions ───────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** All captured notifications. */
|
|
142
|
+
sent(): CapturedNotification[] {
|
|
143
|
+
return [...this._sent];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Captured notifications for one recipient. */
|
|
147
|
+
sentTo(notifiable: Notifiable): CapturedNotification[] {
|
|
148
|
+
return this._sent.filter(({ notifiable: n }) => String(n.id) === String(notifiable.id));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Assert that a notification was sent to the given notifiable.
|
|
153
|
+
* Pass an optional filter callback to narrow the assertion.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* notify.assertSentTo(user, OrderShippedNotification);
|
|
157
|
+
* notify.assertSentTo(user, OrderShippedNotification, (n) => n.orderId === 42);
|
|
158
|
+
*/
|
|
159
|
+
assertSentTo<T extends Notification>(
|
|
160
|
+
notifiable: Notifiable,
|
|
161
|
+
NotificationClass: new (...args: never[]) => T,
|
|
162
|
+
callback?: (notification: T) => boolean,
|
|
163
|
+
): void {
|
|
164
|
+
const matching = this._match(notifiable, NotificationClass, callback);
|
|
165
|
+
if (matching.length === 0) {
|
|
166
|
+
const hint = callback ? " matching the given filter" : "";
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Expected ${NotificationClass.name} to have been sent to notifiable #${notifiable.id}${hint}, but it was not.${this._summary()}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Assert that a notification class was NOT sent to the given notifiable. */
|
|
174
|
+
assertNotSentTo<T extends Notification>(
|
|
175
|
+
notifiable: Notifiable,
|
|
176
|
+
NotificationClass: new (...args: never[]) => T,
|
|
177
|
+
): void {
|
|
178
|
+
const matching = this._match(notifiable, NotificationClass);
|
|
179
|
+
if (matching.length > 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Expected ${NotificationClass.name} NOT to have been sent to notifiable #${notifiable.id}, but it was (${matching.length}×).`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Assert a notification was sent to a recipient on a specific channel.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* notify.assertSentOn(user, PasswordChanged, "mail");
|
|
191
|
+
*/
|
|
192
|
+
assertSentOn<T extends Notification>(
|
|
193
|
+
notifiable: Notifiable,
|
|
194
|
+
NotificationClass: new (...args: never[]) => T,
|
|
195
|
+
channel: string,
|
|
196
|
+
): void {
|
|
197
|
+
const matching = this._match(notifiable, NotificationClass);
|
|
198
|
+
if (matching.length === 0) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Expected ${NotificationClass.name} to have been sent to notifiable #${notifiable.id} on '${channel}', but it was not sent at all.${this._summary()}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (!matching.some((m) => m.channels.includes(channel))) {
|
|
204
|
+
const seen = [...new Set(matching.flatMap((m) => m.channels))];
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Expected ${NotificationClass.name} to have been sent to notifiable #${notifiable.id} on '${channel}', ` +
|
|
207
|
+
`but it declared: ${seen.join(", ") || "no channels"}.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Assert a notification was queued rather than sent immediately. */
|
|
213
|
+
assertQueued<T extends Notification>(
|
|
214
|
+
notifiable: Notifiable,
|
|
215
|
+
NotificationClass: new (...args: never[]) => T,
|
|
216
|
+
): void {
|
|
217
|
+
const matching = this._match(notifiable, NotificationClass);
|
|
218
|
+
if (!matching.some((m) => m.queued)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Expected ${NotificationClass.name} to have been queued for notifiable #${notifiable.id}, ` +
|
|
221
|
+
`but ${matching.length === 0 ? "it was not sent at all" : "it was sent immediately"}.${this._summary()}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Assert how many times a notification class was sent, to anyone. */
|
|
227
|
+
assertSentTimes<T extends Notification>(
|
|
228
|
+
NotificationClass: new (...args: never[]) => T,
|
|
229
|
+
count: number,
|
|
230
|
+
): void {
|
|
231
|
+
const actual = this._sent.filter(
|
|
232
|
+
({ notification }) => notification instanceof NotificationClass,
|
|
233
|
+
).length;
|
|
234
|
+
if (actual !== count) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Expected ${NotificationClass.name} to have been sent ${count}×, but it was sent ${actual}×.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Assert that zero notifications were sent. */
|
|
242
|
+
assertNothingSent(): void {
|
|
243
|
+
if (this._sent.length > 0) {
|
|
244
|
+
const names = this._sent.map(({ notification }) => notification.constructor.name).join(", ");
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Expected nothing to be sent, but ${this._sent.length} notification(s) were: ${names}.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Assert the exact total number of notifications sent. */
|
|
252
|
+
assertSentCount(count: number): void {
|
|
253
|
+
if (this._sent.length !== count) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Expected ${count} notification(s) to be sent, but ${this._sent.length} were.${this._summary()}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private _match<T extends Notification>(
|
|
261
|
+
notifiable: Notifiable,
|
|
262
|
+
NotificationClass: new (...args: never[]) => T,
|
|
263
|
+
callback?: (notification: T) => boolean,
|
|
264
|
+
): CapturedNotification[] {
|
|
265
|
+
return this._sent.filter(
|
|
266
|
+
({ notifiable: n, notification }) =>
|
|
267
|
+
String(n.id) === String(notifiable.id) &&
|
|
268
|
+
notification instanceof NotificationClass &&
|
|
269
|
+
(callback === undefined || callback(notification as T)),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** What was actually captured — the first thing you want when an assertion fails. */
|
|
274
|
+
private _summary(): string {
|
|
275
|
+
if (this._sent.length === 0) return " Nothing was sent.";
|
|
276
|
+
const lines = this._sent.map(
|
|
277
|
+
({ notification, notifiable, channels, queued }) =>
|
|
278
|
+
`${notification.constructor.name} → #${notifiable.id} [${channels.join(", ") || "no channels"}]${queued ? " (queued)" : ""}`,
|
|
279
|
+
);
|
|
280
|
+
return ` Sent: ${lines.join("; ")}.`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { Notifiable, NotificationChannel, NotificationConfigShape } from "./types.ts";
|
|
2
|
+
import {
|
|
3
|
+
NotificationChannelNotConfiguredError,
|
|
4
|
+
NotificationDispatchError,
|
|
5
|
+
UnknownNotificationChannelError,
|
|
6
|
+
} from "./errors.ts";
|
|
7
|
+
import type { Notification } from "./Notification.ts";
|
|
8
|
+
import { MailChannel } from "./MailChannel.ts";
|
|
9
|
+
import { DatabaseChannel } from "./DatabaseChannel.ts";
|
|
10
|
+
import { SlackChannel } from "./SlackChannel.ts";
|
|
11
|
+
import { SmsChannel } from "./SmsChannel.ts";
|
|
12
|
+
import { BroadcastChannel } from "./BroadcastChannel.ts";
|
|
13
|
+
import { OnDemandNotifiable, type OnDemandRoutes } from "./OnDemandNotifiable.ts";
|
|
14
|
+
import { _getConnection } from "@zerotal/orm";
|
|
15
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
16
|
+
import { NotificationSent, MessageQueued } from "./events.ts";
|
|
17
|
+
|
|
18
|
+
/** A channel factory, resolved once on first use. */
|
|
19
|
+
type ChannelResolver = () => NotificationChannel;
|
|
20
|
+
|
|
21
|
+
export class NotificationManager {
|
|
22
|
+
/** Channel name → resolver. Built-ins are seeded here; `extend()` adds to it. */
|
|
23
|
+
private readonly _resolvers = new Map<string, ChannelResolver>();
|
|
24
|
+
/** Resolved channel instances, memoized per name. */
|
|
25
|
+
private readonly _channels = new Map<string, NotificationChannel>();
|
|
26
|
+
private readonly _db: DatabaseChannel;
|
|
27
|
+
|
|
28
|
+
constructor(private readonly config: NotificationConfigShape) {
|
|
29
|
+
this._db = new DatabaseChannel(config.database.table, _getConnection());
|
|
30
|
+
|
|
31
|
+
this._resolvers.set("mail", () => new MailChannel(config.mail));
|
|
32
|
+
this._resolvers.set("database", () => this._db);
|
|
33
|
+
this._resolvers.set("broadcast", () => new BroadcastChannel());
|
|
34
|
+
|
|
35
|
+
this._resolvers.set("slack", () => {
|
|
36
|
+
if (config.slack === undefined) {
|
|
37
|
+
throw new NotificationChannelNotConfiguredError(
|
|
38
|
+
"slack",
|
|
39
|
+
'Add slack: { webhook: "..." } to config/notifications.ts.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return new SlackChannel(config.slack);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this._resolvers.set("sms", () => {
|
|
46
|
+
if (config.sms === undefined) {
|
|
47
|
+
throw new NotificationChannelNotConfiguredError(
|
|
48
|
+
"sms",
|
|
49
|
+
'Add sms: { driver: "twilio", twilio: { ... } } to config/notifications.ts.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return new SmsChannel(config.sms);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register a custom delivery channel, or replace a built-in one.
|
|
58
|
+
*
|
|
59
|
+
* The factory runs once, the first time the channel is used, so a channel
|
|
60
|
+
* nobody sends on costs nothing.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* // in a service provider's onBooted()
|
|
64
|
+
* const notify = app.container.makeSync("notifications");
|
|
65
|
+
* notify.extend("discord", () => new DiscordChannel(config));
|
|
66
|
+
*
|
|
67
|
+
* // then, in a notification
|
|
68
|
+
* channels() { return ["discord"]; }
|
|
69
|
+
* toDiscord() { return { content: "Deploy finished" }; }
|
|
70
|
+
*/
|
|
71
|
+
extend(channel: string, factory: ChannelResolver): this {
|
|
72
|
+
this._resolvers.set(channel, factory);
|
|
73
|
+
this._channels.delete(channel);
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Every registered channel name, built-in and custom. */
|
|
78
|
+
channels(): string[] {
|
|
79
|
+
return [...this._resolvers.keys()];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Send a notification via all declared channels immediately.
|
|
84
|
+
*
|
|
85
|
+
* Every channel is attempted even if an earlier one fails — a Slack webhook
|
|
86
|
+
* returning 500 must not cost the recipient the email and the stored row that
|
|
87
|
+
* were declared alongside it. If any channel failed, the failures are reported
|
|
88
|
+
* together as a {@link NotificationDispatchError} once the rest are delivered.
|
|
89
|
+
*
|
|
90
|
+
* @throws {NotificationDispatchError} when one or more channels failed.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* await Notify.send(user, new OrderShippedNotification(order));
|
|
94
|
+
*/
|
|
95
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
96
|
+
const className = notification.constructor?.name ?? "Notification";
|
|
97
|
+
const recipient = NotificationManager._notifiableLabel(notifiable);
|
|
98
|
+
|
|
99
|
+
const failures: Array<{ channel: string; error: Error }> = [];
|
|
100
|
+
const delivered: string[] = [];
|
|
101
|
+
|
|
102
|
+
for (const channel of notification.channels(notifiable)) {
|
|
103
|
+
// Per-channel delivery telemetry — one NotificationSent event per channel,
|
|
104
|
+
// feeding the monitor's Notifications feed (distinct from the Mail log). This
|
|
105
|
+
// single point also covers queued sends, which route back through send().
|
|
106
|
+
const startedAt = performance.now();
|
|
107
|
+
try {
|
|
108
|
+
await this._dispatch(channel, notifiable, notification);
|
|
109
|
+
delivered.push(channel);
|
|
110
|
+
FrameworkEvents.emit(
|
|
111
|
+
new NotificationSent(className, channel, recipient, true, performance.now() - startedAt),
|
|
112
|
+
);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
failures.push({
|
|
115
|
+
channel,
|
|
116
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
117
|
+
});
|
|
118
|
+
FrameworkEvents.emit(
|
|
119
|
+
new NotificationSent(
|
|
120
|
+
className,
|
|
121
|
+
channel,
|
|
122
|
+
recipient,
|
|
123
|
+
false,
|
|
124
|
+
performance.now() - startedAt,
|
|
125
|
+
error instanceof Error ? error.message : String(error),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (failures.length > 0) {
|
|
132
|
+
// A single failing channel keeps its own error type, so `catch (e) { if (e
|
|
133
|
+
// instanceof NotificationDeliveryError) }` still works for the common case.
|
|
134
|
+
if (failures.length === 1 && delivered.length === 0) throw failures[0]!.error;
|
|
135
|
+
throw new NotificationDispatchError(className, failures, delivered);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Send one notification to many recipients.
|
|
141
|
+
*
|
|
142
|
+
* Recipients are independent: one failing does not stop the rest, and the
|
|
143
|
+
* errors are reported together at the end.
|
|
144
|
+
*
|
|
145
|
+
* @throws {NotificationDispatchError} when one or more recipients failed.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* await Notify.sendMany(admins, new LowStockNotification(product));
|
|
149
|
+
*/
|
|
150
|
+
async sendMany(notifiables: Iterable<Notifiable>, notification: Notification): Promise<void> {
|
|
151
|
+
const failures: Array<{ channel: string; error: Error }> = [];
|
|
152
|
+
let delivered = 0;
|
|
153
|
+
|
|
154
|
+
for (const notifiable of notifiables) {
|
|
155
|
+
try {
|
|
156
|
+
await this.send(notifiable, notification);
|
|
157
|
+
delivered++;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
failures.push({
|
|
160
|
+
channel: `recipient ${NotificationManager._notifiableLabel(notifiable)}`,
|
|
161
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (failures.length > 0) {
|
|
167
|
+
throw new NotificationDispatchError(
|
|
168
|
+
notification.constructor?.name ?? "Notification",
|
|
169
|
+
failures,
|
|
170
|
+
Array.from({ length: delivered }, (_, i) => `recipient ${i + 1}`),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Queue one notification for many recipients. */
|
|
176
|
+
async queueMany(notifiables: Iterable<Notifiable>, notification: Notification): Promise<void> {
|
|
177
|
+
for (const notifiable of notifiables) {
|
|
178
|
+
await this.queue(notifiable, notification);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Notify a destination that has no model behind it — a bare email address,
|
|
184
|
+
* phone number, or Slack webhook.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* await Notify.route({ mail: "ops@acme.test" }).notify(new DeployFinished(build));
|
|
188
|
+
* await Notify.route({ sms: "+15551234567", mail: "on-call@acme.test" })
|
|
189
|
+
* .notifyLater(new PagerAlert(incident));
|
|
190
|
+
*/
|
|
191
|
+
route(routes: OnDemandRoutes): {
|
|
192
|
+
notify(notification: Notification): Promise<void>;
|
|
193
|
+
notifyLater(notification: Notification): Promise<void>;
|
|
194
|
+
} {
|
|
195
|
+
const notifiable = new OnDemandNotifiable(routes);
|
|
196
|
+
return {
|
|
197
|
+
notify: (notification) => this.send(notifiable, notification),
|
|
198
|
+
notifyLater: (notification) => this.queue(notifiable, notification),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A stable recipient label: email when present, else the id. */
|
|
203
|
+
private static _notifiableLabel(n: Notifiable): string {
|
|
204
|
+
return n.email ?? String(n.id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Queue the notification for background delivery via @zerotal/queue.
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* await Notify.queue(user, new OrderShippedNotification(order));
|
|
212
|
+
*/
|
|
213
|
+
async queue(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
214
|
+
// If this notification mails, log it as "queued" now — the Mail tab shows the
|
|
215
|
+
// deferred message immediately, before SendNotificationJob delivers it (at which
|
|
216
|
+
// point MessageSent flips it to "sent"). Telemetry must never block the queue.
|
|
217
|
+
if (notification.channels(notifiable).includes("mail")) {
|
|
218
|
+
try {
|
|
219
|
+
const message = await notification.toMail(notifiable);
|
|
220
|
+
const to = notifiable.routeNotificationFor?.("mail") ?? notifiable.email;
|
|
221
|
+
const fallbackTo = to
|
|
222
|
+
? [{ address: to, ...(notifiable.name ? { name: notifiable.name } : {}) }]
|
|
223
|
+
: [];
|
|
224
|
+
const payload = message.toPayload(this.config.mail.from, fallbackTo);
|
|
225
|
+
FrameworkEvents.emit(
|
|
226
|
+
new MessageQueued(
|
|
227
|
+
notification.constructor?.name ?? "Notification",
|
|
228
|
+
payload.to.map((a) => a.address),
|
|
229
|
+
payload.subject,
|
|
230
|
+
"notifications",
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
} catch {
|
|
234
|
+
/* never block queuing on telemetry */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const { SendNotificationJob } = await import("./SendNotificationJob.ts");
|
|
238
|
+
const { Queue } = await import("@zerotal/queue");
|
|
239
|
+
await Queue.dispatch(new SendNotificationJob(notifiable, notification));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Expose the database channel for direct queries (unread, markAsRead). */
|
|
243
|
+
get database(): DatabaseChannel {
|
|
244
|
+
return this._db;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async _dispatch(
|
|
248
|
+
channel: string,
|
|
249
|
+
notifiable: Notifiable,
|
|
250
|
+
notification: Notification,
|
|
251
|
+
): Promise<void> {
|
|
252
|
+
await this._resolveChannel(channel).send(notifiable, notification);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Resolve a channel by name, constructing it on first use. */
|
|
256
|
+
private _resolveChannel(channel: string): NotificationChannel {
|
|
257
|
+
const existing = this._channels.get(channel);
|
|
258
|
+
if (existing) return existing;
|
|
259
|
+
|
|
260
|
+
const resolver = this._resolvers.get(channel);
|
|
261
|
+
if (!resolver) {
|
|
262
|
+
throw new UnknownNotificationChannelError(channel, [...this._resolvers.keys()]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const instance = resolver();
|
|
266
|
+
this._channels.set(channel, instance);
|
|
267
|
+
return instance;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Notification } from "./Notification.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A notification class, optionally with a custom `fromPayload` reviver. The
|
|
5
|
+
* reviver may be async — rebuilding often means loading a record back.
|
|
6
|
+
*/
|
|
7
|
+
export type NotificationClass = (new (...args: never[]) => Notification) & {
|
|
8
|
+
fromPayload?(data: Record<string, unknown>): Notification | Promise<Notification>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Global registry mapping notification class names to their constructors.
|
|
13
|
+
*
|
|
14
|
+
* A queued notification is stored as `{ type, data }` — the class name plus the
|
|
15
|
+
* result of `payload()`. When a worker picks the job up it has only those two
|
|
16
|
+
* strings, so the class must be reachable by name to be rebuilt. That is what
|
|
17
|
+
* this registry provides.
|
|
18
|
+
*
|
|
19
|
+
* Registration is automatic in the common case: {@link discoverNotifications}
|
|
20
|
+
* imports `app/notifications/*.ts` and registers every exported notification
|
|
21
|
+
* class. Call `register()` yourself only for notifications that live elsewhere.
|
|
22
|
+
*/
|
|
23
|
+
export const NotificationRegistry = {
|
|
24
|
+
_map: new Map<string, NotificationClass>(),
|
|
25
|
+
|
|
26
|
+
register(NotificationClass: NotificationClass): void {
|
|
27
|
+
NotificationRegistry._map.set(NotificationClass.name, NotificationClass);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
resolve(className: string): NotificationClass | undefined {
|
|
31
|
+
return NotificationRegistry._map.get(className);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
all(): ReadonlyMap<string, NotificationClass> {
|
|
35
|
+
return NotificationRegistry._map;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Notifiable } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Per-channel destinations for a recipient with no model behind it. */
|
|
4
|
+
export interface OnDemandRoutes {
|
|
5
|
+
/** Email address for the `mail` channel. */
|
|
6
|
+
mail?: string;
|
|
7
|
+
/** E.164 phone number for the `sms` channel. */
|
|
8
|
+
sms?: string;
|
|
9
|
+
/** Incoming webhook URL for the `slack` channel. */
|
|
10
|
+
slack?: string;
|
|
11
|
+
/** Broadcast channel name for the `broadcast` channel. */
|
|
12
|
+
broadcast?: string;
|
|
13
|
+
/** Destination for a channel registered with `extend()`. */
|
|
14
|
+
[channel: string]: string | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A recipient addressed directly rather than looked up — `Notify.route({ mail:
|
|
19
|
+
* "ops@acme.test" })`.
|
|
20
|
+
*
|
|
21
|
+
* It satisfies `Notifiable` without being a model, so every channel works
|
|
22
|
+
* unchanged. The `database` channel is the exception worth knowing about: rows
|
|
23
|
+
* it writes are keyed to a random id nothing can query back, which is why an
|
|
24
|
+
* on-demand notification normally declares only transport channels.
|
|
25
|
+
*/
|
|
26
|
+
export class OnDemandNotifiable implements Notifiable {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly email?: string;
|
|
29
|
+
readonly phone?: string;
|
|
30
|
+
|
|
31
|
+
constructor(private readonly _routes: OnDemandRoutes) {
|
|
32
|
+
this.id = `on-demand:${crypto.randomUUID()}`;
|
|
33
|
+
// Assigned only when present: `exactOptionalPropertyTypes` distinguishes an
|
|
34
|
+
// absent optional field from one explicitly set to undefined.
|
|
35
|
+
if (_routes.mail !== undefined) this.email = _routes.mail;
|
|
36
|
+
if (_routes.sms !== undefined) this.phone = _routes.sms;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
routeNotificationFor(channel: string): string | undefined {
|
|
40
|
+
return this._routes[channel];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
receivesBroadcastNotificationsOn(): string {
|
|
44
|
+
return this._routes.broadcast ?? `notifications.${this.id}`;
|
|
45
|
+
}
|
|
46
|
+
}
|