@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,295 @@
|
|
|
1
|
+
import type { SQLInstance } from "@zerotal/orm";
|
|
2
|
+
import type { Notifiable } from "./types.ts";
|
|
3
|
+
import { NotificationError } from "./errors.ts";
|
|
4
|
+
import { notifiableType } from "./serialization.ts";
|
|
5
|
+
import type { Notification } from "./Notification.ts";
|
|
6
|
+
|
|
7
|
+
/** Build a TemplateStringsArray from a plain string array (mirrors ORM helper). */
|
|
8
|
+
function tpl(strings: string[]): TemplateStringsArray {
|
|
9
|
+
return Object.freeze(
|
|
10
|
+
Object.assign([...strings], { raw: [...strings] }),
|
|
11
|
+
) as unknown as TemplateStringsArray;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Execute sql with a dynamically-constructed template (table name in static parts). */
|
|
15
|
+
async function run<T = Record<string, unknown>>(
|
|
16
|
+
conn: SQLInstance,
|
|
17
|
+
parts: string[],
|
|
18
|
+
...values: unknown[]
|
|
19
|
+
): Promise<T[]> {
|
|
20
|
+
return conn<T>(tpl(parts), ...values);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Allow only simple identifier characters — guards against injection in table names. */
|
|
24
|
+
function safeIdentifier(name: string): string {
|
|
25
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
|
26
|
+
throw new NotificationError(
|
|
27
|
+
`Invalid table name: '${name}'`,
|
|
28
|
+
"E_NOTIFICATION_INVALID_TABLE",
|
|
29
|
+
500,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return name;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** How many rows `all()` and `unread()` return when no limit is given. */
|
|
36
|
+
const DEFAULT_LIMIT = 100;
|
|
37
|
+
|
|
38
|
+
/** Paging and ordering options for an inbox query. */
|
|
39
|
+
export interface InboxQuery {
|
|
40
|
+
/** Maximum rows to return. Default: 100. Pass 0 for no limit. */
|
|
41
|
+
limit?: number;
|
|
42
|
+
/** Rows to skip, for paging. Default: 0. */
|
|
43
|
+
offset?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Stores notifications in the database.
|
|
48
|
+
* The table and its indexes are created automatically on first use.
|
|
49
|
+
*
|
|
50
|
+
* Schema:
|
|
51
|
+
* id TEXT PRIMARY KEY — UUID
|
|
52
|
+
* notifiable_type TEXT NOT NULL — recipient's model class name
|
|
53
|
+
* notifiable_id TEXT NOT NULL — stringified notifiable id
|
|
54
|
+
* type TEXT NOT NULL — notification class name
|
|
55
|
+
* data TEXT NOT NULL — JSON-encoded toDatabase() result
|
|
56
|
+
* read_at TEXT — ISO timestamp when read; NULL = unread
|
|
57
|
+
* created_at TEXT NOT NULL — ISO timestamp
|
|
58
|
+
*
|
|
59
|
+
* Every read is scoped by `(notifiable_type, notifiable_id)`, not by id alone:
|
|
60
|
+
* ids are only unique within a model, so a `User#1` and a `Team#1` would
|
|
61
|
+
* otherwise share one inbox.
|
|
62
|
+
*/
|
|
63
|
+
export class DatabaseChannel {
|
|
64
|
+
private readonly _t: string;
|
|
65
|
+
private _ready: Promise<void>;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
table: string,
|
|
69
|
+
private readonly _sql: SQLInstance,
|
|
70
|
+
) {
|
|
71
|
+
this._t = safeIdentifier(table);
|
|
72
|
+
this._ready = this._ensureTable();
|
|
73
|
+
// The constructor cannot await, so a failure here would otherwise surface as
|
|
74
|
+
// an unhandled rejection before the first query re-awaits it.
|
|
75
|
+
this._ready.catch(() => undefined);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async _ensureTable(): Promise<void> {
|
|
79
|
+
await run(this._sql, [
|
|
80
|
+
`CREATE TABLE IF NOT EXISTS ${this._t} (
|
|
81
|
+
id TEXT PRIMARY KEY,
|
|
82
|
+
notifiable_type TEXT NOT NULL,
|
|
83
|
+
notifiable_id TEXT NOT NULL,
|
|
84
|
+
type TEXT NOT NULL,
|
|
85
|
+
data TEXT NOT NULL,
|
|
86
|
+
read_at TEXT,
|
|
87
|
+
created_at TEXT NOT NULL
|
|
88
|
+
)`,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// The inbox is read far more often than it is written, and always by
|
|
92
|
+
// recipient — usually filtered to unread and ordered by recency.
|
|
93
|
+
await run(this._sql, [
|
|
94
|
+
`CREATE INDEX IF NOT EXISTS ${this._t}_notifiable_idx
|
|
95
|
+
ON ${this._t} (notifiable_type, notifiable_id, created_at)`,
|
|
96
|
+
]);
|
|
97
|
+
await run(this._sql, [
|
|
98
|
+
`CREATE INDEX IF NOT EXISTS ${this._t}_unread_idx
|
|
99
|
+
ON ${this._t} (notifiable_type, notifiable_id, read_at)`,
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
104
|
+
await this._ready;
|
|
105
|
+
const id = crypto.randomUUID();
|
|
106
|
+
const type = notification.constructor.name;
|
|
107
|
+
const data = JSON.stringify(await notification.toDatabase(notifiable));
|
|
108
|
+
const createdAt = new Date().toISOString();
|
|
109
|
+
|
|
110
|
+
// N values → N+1 string parts (tagged-template invariant).
|
|
111
|
+
await run(
|
|
112
|
+
this._sql,
|
|
113
|
+
[
|
|
114
|
+
`INSERT INTO ${this._t} (id, notifiable_type, notifiable_id, type, data, read_at, created_at) VALUES (`,
|
|
115
|
+
", ", // between id and notifiable_type
|
|
116
|
+
", ", // between notifiable_type and notifiable_id
|
|
117
|
+
", ", // between notifiable_id and type
|
|
118
|
+
", ", // between type and data
|
|
119
|
+
", ", // between data and read_at
|
|
120
|
+
", ", // between read_at and created_at
|
|
121
|
+
")", // after created_at
|
|
122
|
+
],
|
|
123
|
+
id,
|
|
124
|
+
notifiableType(notifiable),
|
|
125
|
+
String(notifiable.id),
|
|
126
|
+
type,
|
|
127
|
+
data,
|
|
128
|
+
null,
|
|
129
|
+
createdAt,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Mark a notification as read by its id. */
|
|
134
|
+
async markAsRead(id: string): Promise<void> {
|
|
135
|
+
await this._ready;
|
|
136
|
+
const readAt = new Date().toISOString();
|
|
137
|
+
await run(this._sql, [`UPDATE ${this._t} SET read_at = `, ` WHERE id = `, ""], readAt, id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Mark a notification as unread by its id. */
|
|
141
|
+
async markAsUnread(id: string): Promise<void> {
|
|
142
|
+
await this._ready;
|
|
143
|
+
await run(this._sql, [`UPDATE ${this._t} SET read_at = NULL WHERE id = `, ""], id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Return unread notifications for a notifiable, newest first. */
|
|
147
|
+
async unread(notifiable: Notifiable, query: InboxQuery = {}): Promise<NotificationRecord[]> {
|
|
148
|
+
await this._ready;
|
|
149
|
+
return run<NotificationRecord>(
|
|
150
|
+
this._sql,
|
|
151
|
+
[
|
|
152
|
+
`SELECT * FROM ${this._t} WHERE notifiable_type = `,
|
|
153
|
+
` AND notifiable_id = `,
|
|
154
|
+
` AND read_at IS NULL ORDER BY created_at DESC${this._paging(query)}`,
|
|
155
|
+
],
|
|
156
|
+
notifiableType(notifiable),
|
|
157
|
+
String(notifiable.id),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Return notifications for a notifiable, newest first. */
|
|
162
|
+
async all(notifiable: Notifiable, query: InboxQuery = {}): Promise<NotificationRecord[]> {
|
|
163
|
+
await this._ready;
|
|
164
|
+
return run<NotificationRecord>(
|
|
165
|
+
this._sql,
|
|
166
|
+
[
|
|
167
|
+
`SELECT * FROM ${this._t} WHERE notifiable_type = `,
|
|
168
|
+
` AND notifiable_id = `,
|
|
169
|
+
` ORDER BY created_at DESC${this._paging(query)}`,
|
|
170
|
+
],
|
|
171
|
+
notifiableType(notifiable),
|
|
172
|
+
String(notifiable.id),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** How many unread notifications a notifiable has — for a badge, without loading rows. */
|
|
177
|
+
async unreadCount(notifiable: Notifiable): Promise<number> {
|
|
178
|
+
await this._ready;
|
|
179
|
+
const rows = await run<{ count: number }>(
|
|
180
|
+
this._sql,
|
|
181
|
+
[
|
|
182
|
+
`SELECT COUNT(*) AS count FROM ${this._t} WHERE notifiable_type = `,
|
|
183
|
+
` AND notifiable_id = `,
|
|
184
|
+
` AND read_at IS NULL`,
|
|
185
|
+
],
|
|
186
|
+
notifiableType(notifiable),
|
|
187
|
+
String(notifiable.id),
|
|
188
|
+
);
|
|
189
|
+
return Number(rows[0]?.count ?? 0);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Mark every unread notification for a notifiable as read. */
|
|
193
|
+
async markAllAsRead(notifiable: Notifiable): Promise<void> {
|
|
194
|
+
await this._ready;
|
|
195
|
+
const readAt = new Date().toISOString();
|
|
196
|
+
await run(
|
|
197
|
+
this._sql,
|
|
198
|
+
[
|
|
199
|
+
`UPDATE ${this._t} SET read_at = `,
|
|
200
|
+
` WHERE notifiable_type = `,
|
|
201
|
+
` AND notifiable_id = `,
|
|
202
|
+
` AND read_at IS NULL`,
|
|
203
|
+
],
|
|
204
|
+
readAt,
|
|
205
|
+
notifiableType(notifiable),
|
|
206
|
+
String(notifiable.id),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The most recent stored notifications across every notifiable, newest first.
|
|
212
|
+
* Backs the admin console; use `all()` for one recipient's inbox.
|
|
213
|
+
*/
|
|
214
|
+
async recent(limit = 100): Promise<NotificationRecord[]> {
|
|
215
|
+
await this._ready;
|
|
216
|
+
const capped = Math.max(1, Math.floor(limit));
|
|
217
|
+
return run<NotificationRecord>(this._sql, [
|
|
218
|
+
`SELECT * FROM ${this._t} ORDER BY created_at DESC LIMIT ${capped}`,
|
|
219
|
+
]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Delete one stored notification by id. */
|
|
223
|
+
async delete(id: string): Promise<void> {
|
|
224
|
+
await this._ready;
|
|
225
|
+
await run(this._sql, [`DELETE FROM ${this._t} WHERE id = `, ""], id);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Delete every stored notification for a notifiable. */
|
|
229
|
+
async clear(notifiable: Notifiable): Promise<void> {
|
|
230
|
+
await this._ready;
|
|
231
|
+
await run(
|
|
232
|
+
this._sql,
|
|
233
|
+
[`DELETE FROM ${this._t} WHERE notifiable_type = `, ` AND notifiable_id = `, ""],
|
|
234
|
+
notifiableType(notifiable),
|
|
235
|
+
String(notifiable.id),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Delete read notifications older than `days`, across every notifiable.
|
|
241
|
+
* Backs `notifications:prune` — an inbox table grows without bound otherwise.
|
|
242
|
+
*
|
|
243
|
+
* @param days - Age threshold in days.
|
|
244
|
+
* @param includeUnread - Also prune notifications never read. Default: false.
|
|
245
|
+
* @returns How many rows were deleted.
|
|
246
|
+
*/
|
|
247
|
+
async prune(days: number, includeUnread = false): Promise<number> {
|
|
248
|
+
await this._ready;
|
|
249
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
250
|
+
|
|
251
|
+
const before = await this._count();
|
|
252
|
+
if (includeUnread) {
|
|
253
|
+
await run(this._sql, [`DELETE FROM ${this._t} WHERE created_at < `, ""], cutoff);
|
|
254
|
+
} else {
|
|
255
|
+
await run(
|
|
256
|
+
this._sql,
|
|
257
|
+
[`DELETE FROM ${this._t} WHERE created_at < `, ` AND read_at IS NOT NULL`],
|
|
258
|
+
cutoff,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return before - (await this._count());
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Total stored notifications, across every notifiable. */
|
|
265
|
+
async _count(): Promise<number> {
|
|
266
|
+
const rows = await run<{ count: number }>(this._sql, [
|
|
267
|
+
`SELECT COUNT(*) AS count FROM ${this._t}`,
|
|
268
|
+
]);
|
|
269
|
+
return Number(rows[0]?.count ?? 0);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Render LIMIT/OFFSET into the static part of the template.
|
|
274
|
+
*
|
|
275
|
+
* Both are coerced to non-negative integers before interpolation — they are
|
|
276
|
+
* numbers by type, but this is a string concatenated into SQL, so the
|
|
277
|
+
* narrowing is enforced rather than assumed.
|
|
278
|
+
*/
|
|
279
|
+
private _paging(query: InboxQuery): string {
|
|
280
|
+
const limit = query.limit === undefined ? DEFAULT_LIMIT : Math.max(0, Math.floor(query.limit));
|
|
281
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
282
|
+
if (limit === 0) return offset > 0 ? ` LIMIT -1 OFFSET ${offset}` : "";
|
|
283
|
+
return ` LIMIT ${limit}${offset > 0 ? ` OFFSET ${offset}` : ""}`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface NotificationRecord {
|
|
288
|
+
id: string;
|
|
289
|
+
notifiable_type: string;
|
|
290
|
+
notifiable_id: string;
|
|
291
|
+
type: string;
|
|
292
|
+
data: string;
|
|
293
|
+
read_at: string | null;
|
|
294
|
+
created_at: string;
|
|
295
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
2
|
+
import { MessageSent, MessageFailed } from "./events.ts";
|
|
3
|
+
import type { Notifiable, MailConfigShape } from "./types.ts";
|
|
4
|
+
import type { Notification } from "./Notification.ts";
|
|
5
|
+
import type { MailAddress, MailDriver } from "./drivers/MailDriver.ts";
|
|
6
|
+
import { LogDriver } from "./drivers/LogDriver.ts";
|
|
7
|
+
import { SmtpDriver } from "./drivers/SmtpDriver.ts";
|
|
8
|
+
import { ResendDriver } from "./drivers/ResendDriver.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The `mail` notification channel — renders a notification's `toMail()` MailMessage and
|
|
12
|
+
* delivers it through the configured driver (log / SMTP / Resend). Built-in to
|
|
13
|
+
* @zerotal/notifications, so the mail channel works without any extra package.
|
|
14
|
+
*/
|
|
15
|
+
export class MailChannel {
|
|
16
|
+
private readonly _driver: MailDriver;
|
|
17
|
+
private readonly _from: MailAddress;
|
|
18
|
+
|
|
19
|
+
constructor(config: MailConfigShape) {
|
|
20
|
+
this._from = config.from;
|
|
21
|
+
this._driver = MailChannel._buildDriver(config);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private static _buildDriver(config: MailConfigShape): MailDriver {
|
|
25
|
+
switch (config.driver) {
|
|
26
|
+
case "smtp":
|
|
27
|
+
return new SmtpDriver(
|
|
28
|
+
config.smtp.host,
|
|
29
|
+
config.smtp.port,
|
|
30
|
+
config.smtp.username,
|
|
31
|
+
config.smtp.password,
|
|
32
|
+
config.smtp.secure,
|
|
33
|
+
{
|
|
34
|
+
...(config.smtp.allowInsecureAuth !== undefined
|
|
35
|
+
? { allowInsecureAuth: config.smtp.allowInsecureAuth }
|
|
36
|
+
: {}),
|
|
37
|
+
...(config.smtp.rejectUnauthorized !== undefined
|
|
38
|
+
? { rejectUnauthorized: config.smtp.rejectUnauthorized }
|
|
39
|
+
: {}),
|
|
40
|
+
...(config.smtp.timeoutMs !== undefined ? { timeoutMs: config.smtp.timeoutMs } : {}),
|
|
41
|
+
...(config.smtp.clientName !== undefined ? { clientName: config.smtp.clientName } : {}),
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
case "resend":
|
|
45
|
+
return new ResendDriver(config.resend.apiKey);
|
|
46
|
+
case "log":
|
|
47
|
+
default:
|
|
48
|
+
return new LogDriver(config.log.channel);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async send(notifiable: Notifiable, notification: Notification): Promise<void> {
|
|
53
|
+
const message = await notification.toMail(notifiable);
|
|
54
|
+
// The recipient defaults to the notifiable's email unless the MailMessage set
|
|
55
|
+
// one, or the notifiable routes 'mail' somewhere else (e.g. a billing address).
|
|
56
|
+
const address = notifiable.routeNotificationFor?.("mail") ?? notifiable.email;
|
|
57
|
+
const fallbackTo: MailAddress[] = address
|
|
58
|
+
? [{ address, ...(notifiable.name ? { name: notifiable.name } : {}) }]
|
|
59
|
+
: [];
|
|
60
|
+
const payload = message.toPayload(this._from, fallbackTo);
|
|
61
|
+
|
|
62
|
+
// Mail telemetry — these FrameworkEvents are what feeds the monitor's Mail tab.
|
|
63
|
+
// The notification class is the "mailer" identity; recipients/subject/body come
|
|
64
|
+
// from the resolved payload so the panel mirrors exactly what was put on the wire.
|
|
65
|
+
const className = notification.constructor?.name ?? "Notification";
|
|
66
|
+
const to = payload.to.map((a) => a.address);
|
|
67
|
+
const startedAt = performance.now();
|
|
68
|
+
try {
|
|
69
|
+
await this._driver.send(payload);
|
|
70
|
+
FrameworkEvents.emit(
|
|
71
|
+
new MessageSent(
|
|
72
|
+
className,
|
|
73
|
+
to,
|
|
74
|
+
payload.subject,
|
|
75
|
+
payload.html ?? payload.text ?? "",
|
|
76
|
+
performance.now() - startedAt,
|
|
77
|
+
false,
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
FrameworkEvents.emit(
|
|
82
|
+
new MessageFailed(
|
|
83
|
+
className,
|
|
84
|
+
to,
|
|
85
|
+
payload.subject,
|
|
86
|
+
performance.now() - startedAt,
|
|
87
|
+
error instanceof Error ? error.message : String(error),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ── Notifiable ────────────────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Notifiable model mixin — compose it
|
|
4
|
+
// onto any model that should receive notifications (typically User):
|
|
5
|
+
//
|
|
6
|
+
// import { BaseModelWith } from "@zerotal/orm";
|
|
7
|
+
// import { Notifiable } from "@zerotal/notifications";
|
|
8
|
+
//
|
|
9
|
+
// export class User extends BaseModelWith(Notifiable) {
|
|
10
|
+
// @column() email!: string;
|
|
11
|
+
// }
|
|
12
|
+
//
|
|
13
|
+
// await user.notify(new OrderShipped(order)); // send now, across all channels
|
|
14
|
+
// await user.notifyLater(new OrderShipped(order)); // queue for background delivery
|
|
15
|
+
// const unread = await user.unreadNotifications(); // database-channel inbox
|
|
16
|
+
// await user.markNotificationsAsRead();
|
|
17
|
+
//
|
|
18
|
+
// The model only needs an `id` (and `email`/`phone` for the mail/sms channels) to
|
|
19
|
+
// satisfy the Notifiable contract. These methods delegate to the same
|
|
20
|
+
// NotificationManager the `Notify` facade uses, so behaviour is identical — this is
|
|
21
|
+
// purely an ergonomic, object-oriented entry point.
|
|
22
|
+
//
|
|
23
|
+
// `Notifiable` is both the mixin (value) and the contract (type) — a function+interface
|
|
24
|
+
// merge — so `import { Notifiable }` composes and `import type { Notifiable }` annotates.
|
|
25
|
+
|
|
26
|
+
import { currentApp } from "@zerotal/core";
|
|
27
|
+
import type { Notifiable as NotifiableContract } from "./types.ts";
|
|
28
|
+
import type { Notification } from "./Notification.ts";
|
|
29
|
+
import type { NotificationManager } from "./NotificationManager.ts";
|
|
30
|
+
import type { InboxQuery, NotificationRecord } from "./DatabaseChannel.ts";
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic mixin base bound
|
|
33
|
+
type Constructor<T = object> = new (...args: any[]) => T;
|
|
34
|
+
|
|
35
|
+
/** Resolve the live NotificationManager from the container (bound by NotificationProvider). */
|
|
36
|
+
function _manager(): NotificationManager {
|
|
37
|
+
return currentApp().container.makeSync("notifications") as NotificationManager;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The one cast this module makes: a mixin's `this` is typed by its generic base,
|
|
42
|
+
* which cannot promise the `id`/`email` fields the Notifiable contract reads —
|
|
43
|
+
* those come from the composing model's columns. The manager reads them at
|
|
44
|
+
* delivery time, so a model composed without an `id` fails there, not here.
|
|
45
|
+
*/
|
|
46
|
+
function asNotifiable(model: object): Notifiable {
|
|
47
|
+
return model as unknown as Notifiable;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Notifiable<TBase extends Constructor>(Base: TBase) {
|
|
51
|
+
return class extends Base {
|
|
52
|
+
/** Send a notification to this model now, across the notification's channels. */
|
|
53
|
+
async notify(notification: Notification): Promise<void> {
|
|
54
|
+
await _manager().send(asNotifiable(this), notification);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Queue a notification for background delivery via @zerotal/queue. */
|
|
58
|
+
async notifyLater(notification: Notification): Promise<void> {
|
|
59
|
+
await _manager().queue(asNotifiable(this), notification);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** All stored (database-channel) notifications for this model, newest first. */
|
|
63
|
+
async notifications(query?: InboxQuery): Promise<NotificationRecord[]> {
|
|
64
|
+
return _manager().database.all(asNotifiable(this), query);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Unread stored notifications for this model, newest first. */
|
|
68
|
+
async unreadNotifications(query?: InboxQuery): Promise<NotificationRecord[]> {
|
|
69
|
+
return _manager().database.unread(asNotifiable(this), query);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** How many unread notifications this model has — for a badge, without loading rows. */
|
|
73
|
+
async unreadNotificationCount(): Promise<number> {
|
|
74
|
+
return _manager().database.unreadCount(asNotifiable(this));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Mark all of this model's unread notifications as read. */
|
|
78
|
+
async markNotificationsAsRead(): Promise<void> {
|
|
79
|
+
await _manager().database.markAllAsRead(asNotifiable(this));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Delete every stored notification for this model. */
|
|
83
|
+
async clearNotifications(): Promise<void> {
|
|
84
|
+
await _manager().database.clear(asNotifiable(this));
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A model composed with the {@link Notifiable} mixin. Declared here so `Notifiable` is both a
|
|
91
|
+
* value (the mixin) and a type (the contract) — it merges with the function above. Extends the
|
|
92
|
+
* base notifiable contract, so instances pass anywhere a notifiable is expected.
|
|
93
|
+
*/
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- the empty body is the point: it merges the name with the mixin function above
|
|
95
|
+
export interface Notifiable extends NotifiableContract {}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { NotificationContractError } from "./errors.ts";
|
|
2
|
+
import type { Notifiable } from "./types.ts";
|
|
3
|
+
import type { MailMessage } from "./messages/MailMessage.ts";
|
|
4
|
+
import type { SlackMessage } from "./SlackChannel.ts";
|
|
5
|
+
import type { SmsMessage } from "./SmsChannel.ts";
|
|
6
|
+
import type { BroadcastMessage } from "./BroadcastMessage.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base class for all notifications.
|
|
10
|
+
*
|
|
11
|
+
* Extend this class, declare which channels() to use, then implement
|
|
12
|
+
* the corresponding to*() method for each declared channel.
|
|
13
|
+
*
|
|
14
|
+
* Supported channels:
|
|
15
|
+
* 'mail' — implement toMail() → returns a MailMessage
|
|
16
|
+
* 'database' — implement toDatabase() → returns a plain object
|
|
17
|
+
* 'slack' — implement toSlack() → returns a SlackMessage
|
|
18
|
+
* 'sms' — implement toSms() → returns an SmsMessage
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* export class OrderShippedNotification extends Notification {
|
|
22
|
+
* constructor(private order: Order) { super(); }
|
|
23
|
+
*
|
|
24
|
+
* channels() { return ['mail', 'slack', 'database']; }
|
|
25
|
+
*
|
|
26
|
+
* toMail(notifiable: Notifiable): MailMessage {
|
|
27
|
+
* return new MailMessage()
|
|
28
|
+
* .subject(`Order #${this.order.id} shipped`)
|
|
29
|
+
* .line('Your order is on its way.')
|
|
30
|
+
* .action('Track', `https://app.test/orders/${this.order.id}`);
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* toSlack(_notifiable: Notifiable): SlackMessage | Promise<SlackMessage> {
|
|
34
|
+
* return {
|
|
35
|
+
* webhookUrl: 'https://hooks.slack.com/services/...',
|
|
36
|
+
* text: `Order #${this.order.id} was shipped!`,
|
|
37
|
+
* };
|
|
38
|
+
* }
|
|
39
|
+
*
|
|
40
|
+
* toDatabase() {
|
|
41
|
+
* return { orderId: this.order.id, status: 'shipped' };
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
*/
|
|
45
|
+
export abstract class Notification {
|
|
46
|
+
/**
|
|
47
|
+
* Declare which channels to deliver on: `'mail'`, `'database'`, `'slack'`,
|
|
48
|
+
* `'sms'`, `'broadcast'`, or any channel registered with `extend()`.
|
|
49
|
+
*
|
|
50
|
+
* The recipient is passed in, so routing can follow their preferences.
|
|
51
|
+
* Ignore the parameter when every recipient gets the same channels.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* channels(user: Notifiable) {
|
|
55
|
+
* return user.wantsSms ? ["database", "sms"] : ["database", "mail"];
|
|
56
|
+
* }
|
|
57
|
+
*/
|
|
58
|
+
abstract channels(notifiable?: Notifiable): string[];
|
|
59
|
+
|
|
60
|
+
toMail(_notifiable: Notifiable): MailMessage | Promise<MailMessage> {
|
|
61
|
+
throw new NotificationContractError(this.constructor.name, "toMail", "mail");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
toDatabase(_notifiable: Notifiable): Record<string, unknown> | Promise<Record<string, unknown>> {
|
|
65
|
+
throw new NotificationContractError(this.constructor.name, "toDatabase", "database");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
toSlack(_notifiable: Notifiable): SlackMessage | Promise<SlackMessage> {
|
|
69
|
+
throw new NotificationContractError(this.constructor.name, "toSlack", "slack");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
toSms(_notifiable: Notifiable): SmsMessage | Promise<SmsMessage> {
|
|
73
|
+
throw new NotificationContractError(this.constructor.name, "toSms", "sms");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The real-time representation for the 'broadcast' channel. Return a `BroadcastMessage`
|
|
78
|
+
* (or a plain data object). Required when `channels()` includes `'broadcast'`.
|
|
79
|
+
*/
|
|
80
|
+
toBroadcast(
|
|
81
|
+
_notifiable: Notifiable,
|
|
82
|
+
):
|
|
83
|
+
| BroadcastMessage
|
|
84
|
+
| Record<string, unknown>
|
|
85
|
+
| Promise<BroadcastMessage | Record<string, unknown>> {
|
|
86
|
+
throw new NotificationContractError(this.constructor.name, "toBroadcast", "broadcast");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The wire `type` of a broadcast notification (lets clients distinguish kinds). Default: class name. */
|
|
90
|
+
broadcastType(): string {
|
|
91
|
+
return this.constructor.name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Serialize this notification's state so it can be queued.
|
|
96
|
+
*
|
|
97
|
+
* The default copies own enumerable fields, which covers the usual case of a
|
|
98
|
+
* constructor assigning plain values. Override it when the notification holds
|
|
99
|
+
* something JSON cannot carry — a model instance, a Date-keyed Map, a closure
|
|
100
|
+
* — and pair the override with a matching `static fromPayload()`.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* override payload() {
|
|
104
|
+
* return { orderId: this.order.id };
|
|
105
|
+
* }
|
|
106
|
+
*
|
|
107
|
+
* static override async fromPayload(data: Record<string, unknown>) {
|
|
108
|
+
* return new OrderShipped(await Order.find(data.orderId as number));
|
|
109
|
+
* }
|
|
110
|
+
*/
|
|
111
|
+
payload(): Record<string, unknown> {
|
|
112
|
+
return { ...this } as Record<string, unknown>;
|
|
113
|
+
}
|
|
114
|
+
}
|