@zerotal/arch 1.7.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 +31 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/api-surface.md +283 -0
- package/docs/about.md +676 -0
- package/docs/admin/actions.md +183 -0
- package/docs/admin/auth.md +89 -0
- package/docs/admin/dashboard.md +155 -0
- package/docs/admin/extending-ui.md +229 -0
- package/docs/admin/extending.md +205 -0
- package/docs/admin/forms.md +155 -0
- package/docs/admin/index.md +144 -0
- package/docs/admin/operations.md +224 -0
- package/docs/admin/references.md +174 -0
- package/docs/admin/resources.md +132 -0
- package/docs/admin/structure.md +158 -0
- package/docs/admin/tables.md +185 -0
- package/docs/admin/testing.md +138 -0
- package/docs/ai.md +373 -0
- package/docs/application.md +435 -0
- package/docs/arch.md +263 -0
- package/docs/assets.md +755 -0
- package/docs/audit.md +367 -0
- package/docs/authentication.md +1000 -0
- package/docs/authorization.md +529 -0
- package/docs/broadcasting/channels.md +170 -0
- package/docs/broadcasting/client.md +108 -0
- package/docs/broadcasting/events.md +190 -0
- package/docs/broadcasting/index.md +163 -0
- package/docs/broadcasting/references.md +84 -0
- package/docs/broadcasting/testing.md +132 -0
- package/docs/cache.md +387 -0
- package/docs/carbon.md +830 -0
- package/docs/changelog.md +465 -0
- package/docs/client/auth.md +113 -0
- package/docs/client/errors.md +139 -0
- package/docs/client/files.md +118 -0
- package/docs/client/index.md +128 -0
- package/docs/client/references.md +58 -0
- package/docs/client/requests.md +131 -0
- package/docs/client/resilience.md +141 -0
- package/docs/client/testing.md +146 -0
- package/docs/commands.md +450 -0
- package/docs/components.md +2427 -0
- package/docs/config-system.md +258 -0
- package/docs/container.md +596 -0
- package/docs/context.md +579 -0
- package/docs/contributing.md +164 -0
- package/docs/controllers.md +354 -0
- package/docs/conventions.md +466 -0
- package/docs/cookies.md +101 -0
- package/docs/csrf.md +206 -0
- package/docs/database.md +476 -0
- package/docs/deployment.md +434 -0
- package/docs/devtools.md +1013 -0
- package/docs/email-verification.md +320 -0
- package/docs/encryption.md +380 -0
- package/docs/errors.md +317 -0
- package/docs/events.md +420 -0
- package/docs/flow/components.md +846 -0
- package/docs/flow/decorators.md +458 -0
- package/docs/flow/events.md +420 -0
- package/docs/flow/forms.md +551 -0
- package/docs/flow/index.md +568 -0
- package/docs/flow/layouts.md +793 -0
- package/docs/flow/lifecycle.md +336 -0
- package/docs/flow/pagination.md +402 -0
- package/docs/flow/performance.md +262 -0
- package/docs/flow/references.md +317 -0
- package/docs/flow/routing.md +550 -0
- package/docs/flow/testing.md +447 -0
- package/docs/getting-started.md +286 -0
- package/docs/health.md +269 -0
- package/docs/helpers.md +423 -0
- package/docs/i18n.md +414 -0
- package/docs/index.md +65 -0
- package/docs/inertia/build.md +95 -0
- package/docs/inertia/devtools.md +197 -0
- package/docs/inertia/index.md +267 -0
- package/docs/inertia/middleware.md +81 -0
- package/docs/inertia/props.md +507 -0
- package/docs/inertia/references.md +144 -0
- package/docs/inertia/rendering.md +189 -0
- package/docs/inertia/ssr.md +102 -0
- package/docs/inspirations.md +257 -0
- package/docs/lifecycle.md +331 -0
- package/docs/lock.md +454 -0
- package/docs/logger.md +517 -0
- package/docs/media.md +582 -0
- package/docs/middleware.md +509 -0
- package/docs/migrations.md +529 -0
- package/docs/monitor.md +377 -0
- package/docs/notifications.md +848 -0
- package/docs/orm/casts.md +365 -0
- package/docs/orm/factories.md +231 -0
- package/docs/orm/index.md +663 -0
- package/docs/orm/lifecycle.md +421 -0
- package/docs/orm/queries.md +692 -0
- package/docs/orm/relationships.md +440 -0
- package/docs/orm/serialization.md +251 -0
- package/docs/package-development.md +536 -0
- package/docs/pagination.md +340 -0
- package/docs/password-reset.md +335 -0
- package/docs/providers.md +447 -0
- package/docs/query-builder.md +465 -0
- package/docs/queue.md +549 -0
- package/docs/rate-limiting.md +253 -0
- package/docs/responses.md +336 -0
- package/docs/roles-and-2fa.md +418 -0
- package/docs/routing.md +1008 -0
- package/docs/scaffolding.md +335 -0
- package/docs/scheduler.md +533 -0
- package/docs/seeding.md +229 -0
- package/docs/session.md +451 -0
- package/docs/social.md +524 -0
- package/docs/storage.md +622 -0
- package/docs/structure.md +230 -0
- package/docs/support-policy.md +114 -0
- package/docs/telemetry.md +505 -0
- package/docs/tenancy.md +608 -0
- package/docs/testing/browser.md +153 -0
- package/docs/testing/console.md +130 -0
- package/docs/testing/database.md +238 -0
- package/docs/testing/flow-browser.md +216 -0
- package/docs/testing/http.md +441 -0
- package/docs/testing/index.md +240 -0
- package/docs/testing/mocking.md +410 -0
- package/docs/upgrade.md +192 -0
- package/docs/validator.md +549 -0
- package/docs/view.md +453 -0
- package/package.json +60 -0
- package/src/bin/mcp.ts +73 -0
- package/src/config.ts +63 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +56 -0
- package/src/install/ArchInstallCommand.ts +189 -0
- package/src/install/detect.ts +98 -0
- package/src/install/guidelines.ts +290 -0
- package/src/install/markers.ts +77 -0
- package/src/install/mcpConfig.ts +81 -0
- package/src/mcp/index.ts +27 -0
- package/src/mcp/jsonrpc.ts +133 -0
- package/src/mcp/server.ts +307 -0
- package/src/mcp/stdio.ts +111 -0
- package/src/mcp/types.ts +146 -0
- package/src/probe/ArchProbeCommand.ts +48 -0
- package/src/probe/sentinel.ts +13 -0
- package/src/probe/topics.ts +291 -0
- package/src/provider/ArchProvider.ts +101 -0
- package/src/tools/_probe.ts +164 -0
- package/src/tools/apiSurface.ts +241 -0
- package/src/tools/appInfo.ts +88 -0
- package/src/tools/baselines.ts +210 -0
- package/src/tools/context.ts +37 -0
- package/src/tools/doctor.ts +93 -0
- package/src/tools/index.ts +50 -0
- package/src/tools/logs.ts +265 -0
- package/src/tools/routes.ts +133 -0
- package/src/tools/schema.ts +119 -0
- package/src/tools/searchDocs.ts +345 -0
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Notifications
|
|
3
|
+
description: Send one notification across mail, database, Slack, SMS, and real-time broadcast channels from a single class.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Notifications
|
|
7
|
+
|
|
8
|
+
`@zerotal/notifications` lets you describe a notification once and deliver it
|
|
9
|
+
over many channels — mail, database, Slack, SMS, or a real-time broadcast. A
|
|
10
|
+
notification class says _what_ to send and on which channels; the manager handles
|
|
11
|
+
routing each channel to its driver.
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# in your project root
|
|
17
|
+
bun add @zerotal/notifications
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The mail, database, Slack, SMS, and broadcast channels are all built into this
|
|
21
|
+
package — no extra channel packages to install.
|
|
22
|
+
|
|
23
|
+
## Register the provider
|
|
24
|
+
|
|
25
|
+
Add `NotificationProvider` to the providers array in `bootstrap/providers.ts`.
|
|
26
|
+
The database channel reads from your ORM connection, so register it after
|
|
27
|
+
`DatabaseProvider`:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// bootstrap/providers.ts
|
|
31
|
+
import { DatabaseProvider } from "@zerotal/orm";
|
|
32
|
+
import { NotificationProvider } from "@zerotal/notifications";
|
|
33
|
+
|
|
34
|
+
const providers = [
|
|
35
|
+
// …your other providers
|
|
36
|
+
DatabaseProvider,
|
|
37
|
+
NotificationProvider,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
export default providers;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Registering the provider switches on the following:
|
|
44
|
+
|
|
45
|
+
- `onRegister` — binds `NotificationManager` as a lazy singleton under the
|
|
46
|
+
`"notifications"` container key, built from `config/notifications.ts`.
|
|
47
|
+
- `onBooted` — eagerly resolves that binding so the manager (and its database
|
|
48
|
+
table) is ready before the first request.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
Create `config/notifications.ts` with the `NotificationConfig()` helper — it
|
|
53
|
+
merges your overrides over sensible defaults, so you only set the keys you need:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// config/notifications.ts
|
|
57
|
+
import { NotificationConfig } from "@zerotal/notifications";
|
|
58
|
+
import { env } from "zerotal";
|
|
59
|
+
|
|
60
|
+
export default NotificationConfig({
|
|
61
|
+
database: {
|
|
62
|
+
table: "notifications", // table for stored notifications
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
// mail channel works out of the box on the 'log' driver; switch to smtp/resend here
|
|
66
|
+
mail: {
|
|
67
|
+
driver: "log",
|
|
68
|
+
from: { address: "hello@example.com", name: "Zerotal App" },
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Optional — global Slack webhook fallback (per-notification toSlack() can override):
|
|
72
|
+
slack: {
|
|
73
|
+
webhook: env("SLACK_WEBHOOK_URL", ""),
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// Optional — required only for the 'sms' channel:
|
|
77
|
+
sms: {
|
|
78
|
+
driver: "twilio", // 'twilio' | 'vonage'
|
|
79
|
+
twilio: {
|
|
80
|
+
accountSid: env("TWILIO_ACCOUNT_SID", ""),
|
|
81
|
+
authToken: env("TWILIO_AUTH_TOKEN", ""),
|
|
82
|
+
from: env("TWILIO_FROM", ""),
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| Field | Required | Default | Description |
|
|
89
|
+
| ---------------- | -------- | --------------------------------- | --------------------------------------------------------------------------- |
|
|
90
|
+
| `database.table` | no | `"notifications"` | Table where database-channel notifications are stored (auto-created). |
|
|
91
|
+
| `mail.driver` | no | `"log"` | Mail transport: `"log"` (prints to console), `"smtp"`, or `"resend"`. |
|
|
92
|
+
| `mail.from` | no | `hello@example.com` / Zerotal App | Default sender, used unless a `MailMessage` overrides it with `from()`. |
|
|
93
|
+
| `mail.smtp` | no | localhost:1025 | SMTP host/port/credentials, used when `driver` is `"smtp"`. |
|
|
94
|
+
| `mail.resend` | no | `{ apiKey: "" }` | Resend API key, used when `driver` is `"resend"`. |
|
|
95
|
+
| `slack` | no | unset | Global Slack webhook fallback. Include only if you use the `slack` channel. |
|
|
96
|
+
| `sms` | no | unset | SMS driver (`"twilio"` or `"vonage"`) and credentials. Required for `sms`. |
|
|
97
|
+
|
|
98
|
+
> **Note** — The `slack` and `sms` keys are optional. If a notification declares
|
|
99
|
+
> a channel whose config is missing, the manager throws a
|
|
100
|
+
> `NotificationChannelNotConfiguredError` at send time.
|
|
101
|
+
|
|
102
|
+
`NotificationConfig()` checks the result before returning it, so combinations
|
|
103
|
+
that could only fail at send time fail at boot instead: a `resend` driver with no
|
|
104
|
+
API key, an `smtp` driver with no host, a username without a password, a `from`
|
|
105
|
+
that is not an address, or an SMS driver missing its credential block. Each
|
|
106
|
+
raises a `NotificationConfigError` naming the key to fix.
|
|
107
|
+
|
|
108
|
+
### SMTP transport security
|
|
109
|
+
|
|
110
|
+
The `secure` flag chooses how the connection is protected, and the driver refuses
|
|
111
|
+
combinations that would leak credentials:
|
|
112
|
+
|
|
113
|
+
- `secure: true` — TLS from the first byte, the usual choice for port 465.
|
|
114
|
+
- `secure: false` — connects in the clear and upgrades via STARTTLS when the
|
|
115
|
+
server offers it, the usual choice for port 587.
|
|
116
|
+
- `secure: false` against a server with no STARTTLS — stays plaintext. If
|
|
117
|
+
credentials are configured, the send is refused rather than sent in the open,
|
|
118
|
+
because SMTP authentication is base64-encoded, not encrypted.
|
|
119
|
+
|
|
120
|
+
That last case is the one worth knowing about: a local relay like Mailpit needs
|
|
121
|
+
no credentials, so it just works. If you genuinely want to authenticate against a
|
|
122
|
+
trusted relay over plaintext, set `mail.smtp.allowInsecureAuth: true` to say so
|
|
123
|
+
deliberately.
|
|
124
|
+
|
|
125
|
+
| Field | Default | Description |
|
|
126
|
+
| ------------------------- | --------- | ------------------------------------------------------ |
|
|
127
|
+
| `smtp.allowInsecureAuth` | `false` | Permit authentication over an unencrypted connection. |
|
|
128
|
+
| `smtp.rejectUnauthorized` | `true` | Reject servers presenting an untrusted certificate. |
|
|
129
|
+
| `smtp.timeoutMs` | `30000` | How long to wait for any single reply from the server. |
|
|
130
|
+
| `smtp.clientName` | `zerotal` | The name sent in the EHLO greeting. |
|
|
131
|
+
|
|
132
|
+
To check a transport end to end, `bun zt notifications:test you@example.com`
|
|
133
|
+
sends one real message and prints whatever the server said.
|
|
134
|
+
|
|
135
|
+
## Writing a notification
|
|
136
|
+
|
|
137
|
+
Extend `Notification`, declare `channels()`, then implement a `to*()` method for
|
|
138
|
+
each declared channel:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// app/notifications/OrderShippedNotification.ts
|
|
142
|
+
import { Notification, MailMessage } from "@zerotal/notifications";
|
|
143
|
+
import type { Notifiable } from "@zerotal/notifications";
|
|
144
|
+
|
|
145
|
+
export class OrderShippedNotification extends Notification {
|
|
146
|
+
constructor(private order: Order) {
|
|
147
|
+
super();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Which channels to deliver on
|
|
151
|
+
channels() {
|
|
152
|
+
return ["mail", "database", "slack"];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// mail channel — return a MailMessage
|
|
156
|
+
toMail(notifiable: Notifiable) {
|
|
157
|
+
return new MailMessage()
|
|
158
|
+
.subject(`Order #${this.order.id} shipped`)
|
|
159
|
+
.line("Your order is on its way.")
|
|
160
|
+
.action("Track package", `https://app.test/orders/${this.order.id}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// database channel — stored in the notifications table
|
|
164
|
+
toDatabase(_notifiable: Notifiable) {
|
|
165
|
+
return {
|
|
166
|
+
orderId: this.order.id,
|
|
167
|
+
status: "shipped",
|
|
168
|
+
message: `Order #${this.order.id} has been shipped.`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// slack channel
|
|
173
|
+
toSlack(_notifiable: Notifiable) {
|
|
174
|
+
return {
|
|
175
|
+
webhookUrl: "https://hooks.slack.com/services/...", // optional if a global webhook is configured
|
|
176
|
+
text: `Order #${this.order.id} shipped to ${this.order.customerName}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
> **Warning** — A `to*()` method you don't implement throws a
|
|
183
|
+
> `NotificationContractError` if its channel is declared in `channels()`. Keep
|
|
184
|
+
> the two in sync.
|
|
185
|
+
|
|
186
|
+
### Routing per recipient
|
|
187
|
+
|
|
188
|
+
`channels()` receives the recipient, so a single notification can respect each
|
|
189
|
+
person's preferences instead of forcing every recipient down the same path:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
// app/notifications/OrderShippedNotification.ts
|
|
193
|
+
channels(user: Notifiable) {
|
|
194
|
+
// Everyone gets the inbox copy; how they're alerted is their choice.
|
|
195
|
+
return user.prefersSms ? ["database", "sms"] : ["database", "mail"];
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Ignore the parameter when every recipient gets the same channels — that is the
|
|
200
|
+
common case, and `channels()` with no arguments stays valid.
|
|
201
|
+
|
|
202
|
+
A recipient can also redirect an individual channel without any notification
|
|
203
|
+
knowing about it, by implementing `routeNotificationFor`:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
// app/models/User.ts
|
|
207
|
+
routeNotificationFor(channel: string) {
|
|
208
|
+
// Invoices go to the billing contact; everything else to the usual address.
|
|
209
|
+
return channel === "mail" ? this.billingEmail : undefined;
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Return `undefined` to fall back to the default for that channel — `email` for
|
|
214
|
+
mail, `phone` for SMS, the configured webhook for Slack.
|
|
215
|
+
|
|
216
|
+
### When a channel fails
|
|
217
|
+
|
|
218
|
+
Channels are independent, so one failing does not cancel the others: every
|
|
219
|
+
declared channel is attempted, and the failures are collected and thrown together
|
|
220
|
+
afterwards as a `NotificationDispatchError`. A Slack webhook returning a 500 does
|
|
221
|
+
not cost the recipient the email and the stored row that were declared alongside
|
|
222
|
+
it.
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
// in a controller or service
|
|
226
|
+
try {
|
|
227
|
+
await Notify.send(user, new OrderShippedNotification(order));
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof NotificationDispatchError) {
|
|
230
|
+
error.delivered; // ["mail", "database"] — these did arrive
|
|
231
|
+
error.failures; // [{ channel: "slack", error }] — this did not
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
When a notification declares exactly one channel and it fails, that channel's own
|
|
237
|
+
error is thrown unwrapped, so a `catch` narrowing on `NotificationDeliveryError`
|
|
238
|
+
still reads naturally.
|
|
239
|
+
|
|
240
|
+
## The Notifiable interface
|
|
241
|
+
|
|
242
|
+
The entity receiving the notification must satisfy `Notifiable`:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// from @zerotal/notifications
|
|
246
|
+
interface Notifiable {
|
|
247
|
+
id: number | string;
|
|
248
|
+
email?: string; // default recipient for the 'mail' channel
|
|
249
|
+
name?: string;
|
|
250
|
+
phone?: string; // default recipient for the 'sms' channel (E.164 format)
|
|
251
|
+
receivesBroadcastNotificationsOn?(): string; // override the broadcast channel
|
|
252
|
+
routeNotificationFor?(channel: string): string | undefined; // per-channel override
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Your `User` model already satisfies this if it has `id` and `email` fields. To
|
|
257
|
+
get the object-oriented API (`user.notify(...)`, inbox helpers), compose the
|
|
258
|
+
[`Notifiable` mixin](#via-the-notifiable-mixin).
|
|
259
|
+
|
|
260
|
+
## Sending notifications
|
|
261
|
+
|
|
262
|
+
There are three entry points — all delegate to the same `NotificationManager`,
|
|
263
|
+
so behaviour is identical.
|
|
264
|
+
|
|
265
|
+
### Which should I use?
|
|
266
|
+
|
|
267
|
+
- **`Notifiable` mixin** — the ergonomic default when the recipient is a
|
|
268
|
+
model. Gives you `user.notify(...)` plus the database-inbox helpers.
|
|
269
|
+
- **`Notify` facade** — when you have a notifiable that isn't a mixin-composed
|
|
270
|
+
model, or you prefer a static call site.
|
|
271
|
+
- **`NotificationManager` directly** — when you've resolved the manager from the
|
|
272
|
+
container yourself (e.g. in a service with the container in hand).
|
|
273
|
+
|
|
274
|
+
### Via the Notifiable mixin
|
|
275
|
+
|
|
276
|
+
Compose `Notifiable(Base)` onto your notifiable model — the
|
|
277
|
+
`Notifiable` mixin. It adds `notify` / `notifyLater` plus
|
|
278
|
+
database-inbox helpers:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
// app/models/User.ts
|
|
282
|
+
import { AuthUser } from "@zerotal/auth";
|
|
283
|
+
import { column, table } from "@zerotal/orm";
|
|
284
|
+
import { Notifiable } from "@zerotal/notifications";
|
|
285
|
+
|
|
286
|
+
@table("users")
|
|
287
|
+
export class User extends AuthUser.using(Notifiable) {
|
|
288
|
+
@column() email!: string;
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
// in a controller or service
|
|
294
|
+
await user.notify(new OrderShippedNotification(order)); // send now
|
|
295
|
+
await user.notifyLater(new OrderShippedNotification(order)); // queue for background
|
|
296
|
+
|
|
297
|
+
const unread = await user.unreadNotifications(); // database-channel inbox
|
|
298
|
+
const all = await user.notifications();
|
|
299
|
+
await user.markNotificationsAsRead();
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Via the Notify facade
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
// in a controller or service
|
|
306
|
+
import { Notify } from "@zerotal/notifications";
|
|
307
|
+
|
|
308
|
+
await Notify.send(user, new OrderShippedNotification(order)); // send now
|
|
309
|
+
await Notify.queue(user, new OrderShippedNotification(order)); // queue for background
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### To many recipients at once
|
|
313
|
+
|
|
314
|
+
`sendMany` and `queueMany` take any iterable of notifiables. Recipients are
|
|
315
|
+
independent: one failing does not stop the rest, and the errors are reported
|
|
316
|
+
together at the end.
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
// in a controller or service
|
|
320
|
+
const admins = await User.where("role", "admin").get();
|
|
321
|
+
await Notify.sendMany(admins, new LowStockNotification(product));
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### To an address with no model behind it
|
|
325
|
+
|
|
326
|
+
Some notifications go to a destination rather than a user — an on-call address, a
|
|
327
|
+
webhook, a number typed into a form. `route()` takes the destinations directly:
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
// in a controller or service
|
|
331
|
+
await Notify.route({ mail: "ops@acme.test" }).notify(new DeployFinished(build));
|
|
332
|
+
|
|
333
|
+
await Notify.route({
|
|
334
|
+
sms: "+15551234567",
|
|
335
|
+
slack: "https://hooks.slack.com/services/…",
|
|
336
|
+
}).notifyLater(new PagerAlert(incident));
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Each key routes one channel, and `notifyLater` queues exactly as it does for a
|
|
340
|
+
model. The `database` channel is the one to avoid here: rows it writes are keyed
|
|
341
|
+
to a generated id that nothing can query back, so an on-demand notification
|
|
342
|
+
normally declares only transport channels.
|
|
343
|
+
|
|
344
|
+
### Via the NotificationManager directly
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
// in a service
|
|
348
|
+
import { NotificationManager } from "@zerotal/notifications";
|
|
349
|
+
import { Application } from "zerotal";
|
|
350
|
+
|
|
351
|
+
const manager = await currentApp().container.make(NotificationManager);
|
|
352
|
+
await manager.send(user, new OrderShippedNotification(order));
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
## Channels
|
|
356
|
+
|
|
357
|
+
### mail
|
|
358
|
+
|
|
359
|
+
Implement `toMail(notifiable)` returning a `MailMessage`. The recipient defaults
|
|
360
|
+
to the notifiable's `email`, so you rarely set `to()`. The mail channel is
|
|
361
|
+
built-in and works on the `log` driver out of the box; switch to `smtp` or
|
|
362
|
+
`resend` in `config/notifications.ts`.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
// in a Notification
|
|
366
|
+
import { MailMessage } from "@zerotal/notifications";
|
|
367
|
+
import type { Notifiable } from "@zerotal/notifications";
|
|
368
|
+
|
|
369
|
+
toMail(n: Notifiable): MailMessage {
|
|
370
|
+
return new MailMessage()
|
|
371
|
+
.subject("Your order shipped")
|
|
372
|
+
.greeting(`Hi ${n.name ?? "there"},`, { bold: true })
|
|
373
|
+
.line("Your order is on its way.")
|
|
374
|
+
.action("Track package", "https://app.test/orders/123");
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Attach files with `attach()` when you already have the bytes, or `attachFile()`
|
|
379
|
+
to read one from disk. `embed()` places an image in the body rather than listing
|
|
380
|
+
it as a download — reference it from your HTML as `cid:the-id`:
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
// in a Notification
|
|
384
|
+
async toMail(_n: Notifiable): Promise<MailMessage> {
|
|
385
|
+
return (await new MailMessage()
|
|
386
|
+
.subject("Your invoice")
|
|
387
|
+
.line("This month's invoice is attached.")
|
|
388
|
+
.attachFile("./storage/invoices/2026-07.pdf"))
|
|
389
|
+
.embed("logo", { filename: "logo.png", content: logoBytes, contentType: "image/png" });
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### database
|
|
394
|
+
|
|
395
|
+
Implement `toDatabase(notifiable)` returning a plain object. The notification is
|
|
396
|
+
stored in the configured table (`notifications` by default), which is
|
|
397
|
+
auto-created on first use:
|
|
398
|
+
|
|
399
|
+
| Column | Value |
|
|
400
|
+
| ----------------- | --------------------------------------- |
|
|
401
|
+
| `id` | UUID |
|
|
402
|
+
| `notifiable_type` | recipient model name (currently `User`) |
|
|
403
|
+
| `notifiable_id` | stringified recipient id |
|
|
404
|
+
| `type` | notification class name |
|
|
405
|
+
| `data` | JSON payload from `toDatabase()` |
|
|
406
|
+
| `read_at` | `null` until marked read |
|
|
407
|
+
| `created_at` | ISO timestamp |
|
|
408
|
+
|
|
409
|
+
`notifiable_type` is the recipient's own class name, and every read is scoped by
|
|
410
|
+
the type and the id together — ids are only unique within a model, so a `User#1`
|
|
411
|
+
and a `Team#1` keep separate inboxes.
|
|
412
|
+
|
|
413
|
+
Read and mark stored notifications through the [mixin helpers](#via-the-notifiable-mixin)
|
|
414
|
+
(`notifications()`, `unreadNotifications()`, `markNotificationsAsRead()`).
|
|
415
|
+
|
|
416
|
+
Inbox reads return the 100 most recent rows unless told otherwise. Pass a limit
|
|
417
|
+
and offset to page, or `limit: 0` for everything:
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
// in a controller
|
|
421
|
+
const page = await user.notifications({ limit: 20, offset: 40 });
|
|
422
|
+
const badge = await user.unreadNotificationCount(); // counts without loading rows
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
Nothing is deleted automatically, so a long-lived app accumulates rows
|
|
426
|
+
indefinitely. `bun zt notifications:prune --days 30` deletes read notifications
|
|
427
|
+
past an age threshold; add `--all` to include unread ones. Schedule it.
|
|
428
|
+
|
|
429
|
+
### slack
|
|
430
|
+
|
|
431
|
+
Implement `toSlack(notifiable)` returning a `SlackMessage`:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
// in a Notification
|
|
435
|
+
import type { SlackMessage } from "@zerotal/notifications";
|
|
436
|
+
|
|
437
|
+
toSlack(_notifiable: Notifiable): SlackMessage {
|
|
438
|
+
return {
|
|
439
|
+
text: `New signup: ${user.email}`,
|
|
440
|
+
// blocks: [...] // optional Block Kit blocks for rich formatting
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
The webhook URL is resolved from the most specific source available: the
|
|
446
|
+
message's own `webhookUrl`, then the recipient's `routeNotificationFor("slack")`,
|
|
447
|
+
then `slack.webhook` in `config/notifications.ts`. Set the global one and most
|
|
448
|
+
notifications need only supply text. When none of the three yields a URL, the
|
|
449
|
+
channel raises a `NotificationChannelNotConfiguredError` naming the notification.
|
|
450
|
+
|
|
451
|
+
### sms
|
|
452
|
+
|
|
453
|
+
Implement `toSms(notifiable)` returning an `SmsMessage`. Supported drivers:
|
|
454
|
+
`twilio` and `vonage`.
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
// in a Notification
|
|
458
|
+
import type { SmsMessage } from "@zerotal/notifications";
|
|
459
|
+
|
|
460
|
+
toSms(_notifiable: Notifiable): SmsMessage {
|
|
461
|
+
return { body: `Your verification code is ${this.code}.` };
|
|
462
|
+
}
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
The recipient defaults to the notifiable's `phone`, exactly as mail defaults to
|
|
466
|
+
`email`, so `to` is only needed when sending somewhere else. A notifiable with no
|
|
467
|
+
phone and no `to` raises an error naming the notification rather than silently
|
|
468
|
+
sending nothing.
|
|
469
|
+
|
|
470
|
+
### broadcast
|
|
471
|
+
|
|
472
|
+
Push a notification to a connected client in real time via
|
|
473
|
+
[`@zerotal/broadcasting`](/docs/broadcasting). Implement `toBroadcast(notifiable)`
|
|
474
|
+
returning a `BroadcastMessage` (or a plain data object), and optionally
|
|
475
|
+
`broadcastType()` to set the wire `type`:
|
|
476
|
+
|
|
477
|
+
```ts
|
|
478
|
+
// app/notifications/InvoicePaid.ts
|
|
479
|
+
import { Notification, BroadcastMessage } from "@zerotal/notifications";
|
|
480
|
+
import type { Notifiable } from "@zerotal/notifications";
|
|
481
|
+
|
|
482
|
+
export class InvoicePaid extends Notification {
|
|
483
|
+
constructor(private invoice: Invoice) {
|
|
484
|
+
super();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
channels() {
|
|
488
|
+
return ["database", "broadcast"];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
toBroadcast(_notifiable: Notifiable): BroadcastMessage {
|
|
492
|
+
return new BroadcastMessage({ invoiceId: this.invoice.id, amount: this.invoice.amount });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
broadcastType() {
|
|
496
|
+
return "invoice.paid"; // default: the class name
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
The broadcast channel works like this:
|
|
502
|
+
|
|
503
|
+
- **Channel.** Broadcasts on the notifiable's private channel —
|
|
504
|
+
`private-notifications.{id}` by default. Override per notifiable with
|
|
505
|
+
`receivesBroadcastNotificationsOn(): string`:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
// app/models/User.ts
|
|
509
|
+
class User extends Model {
|
|
510
|
+
receivesBroadcastNotificationsOn() {
|
|
511
|
+
return `users.${this.id}`;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
- **Event + payload.** The wire event name is `"notification"` — exported as
|
|
517
|
+
`BROADCAST_NOTIFICATION_EVENT` if you would rather import it than repeat the
|
|
518
|
+
string. Every broadcast notification uses that one event name; the payload's
|
|
519
|
+
`type` is what distinguishes them. The payload is your data merged with
|
|
520
|
+
`{ id, type, readAt: null, createdAt }`, so the client can render it like a
|
|
521
|
+
stored notification.
|
|
522
|
+
- **Authorize** the per-user channel in `routes/channels.ts`:
|
|
523
|
+
|
|
524
|
+
```ts
|
|
525
|
+
// routes/channels.ts
|
|
526
|
+
Broadcast.channel("notifications.[id]", (user, id) => String(user.id) === id);
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- **Client** (any Pusher-compatible client):
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
// in your frontend
|
|
533
|
+
Echo.private(`notifications.${userId}`).listen("notification", (n) => {
|
|
534
|
+
console.log(n.type, n);
|
|
535
|
+
});
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
> **Note** — The broadcast channel requires `BroadcastProvider` to be registered.
|
|
539
|
+
|
|
540
|
+
A broadcast goes out inline, which is the point of the channel. When one fans out
|
|
541
|
+
widely enough that the request should not wait for it, `.onQueue(name)` hands it
|
|
542
|
+
to a worker instead — at the cost of arriving whenever that worker picks it up.
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
// in a Notification
|
|
546
|
+
toBroadcast(_n: Notifiable) {
|
|
547
|
+
return new BroadcastMessage({ id: this.report.id }).onQueue("broadcasts");
|
|
548
|
+
}
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
## Custom channels
|
|
552
|
+
|
|
553
|
+
The five built-in channels are registered the same way yours are, so adding a
|
|
554
|
+
channel is not a special case. Register a factory under a name, and any
|
|
555
|
+
notification can declare it:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
// app/providers/DiscordChannelProvider.ts
|
|
559
|
+
import type { NotificationChannel, Notifiable } from "@zerotal/notifications";
|
|
560
|
+
|
|
561
|
+
class DiscordChannel implements NotificationChannel {
|
|
562
|
+
async send(notifiable: Notifiable, notification: Notification) {
|
|
563
|
+
const message = await (
|
|
564
|
+
notification as { toDiscord(n: Notifiable): { content: string } }
|
|
565
|
+
).toDiscord(notifiable);
|
|
566
|
+
await fetch(notifiable.routeNotificationFor?.("discord") ?? this.webhook, {
|
|
567
|
+
method: "POST",
|
|
568
|
+
headers: { "Content-Type": "application/json" },
|
|
569
|
+
body: JSON.stringify(message),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// in the provider's onBooted()
|
|
575
|
+
const notifications = this.app.container.makeSync("notifications");
|
|
576
|
+
notifications.extend("discord", () => new DiscordChannel());
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
// app/notifications/DeployFinished.ts
|
|
581
|
+
channels() {
|
|
582
|
+
return ["database", "discord"];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
toDiscord(_n: Notifiable) {
|
|
586
|
+
return { content: `Deploy ${this.build.sha} finished` };
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
The factory runs once, the first time something sends on that channel, so a
|
|
591
|
+
channel nobody uses costs nothing. Registering a name that already exists
|
|
592
|
+
replaces it, which is how you swap a built-in channel for your own
|
|
593
|
+
implementation. Sending on a name that was never registered raises an
|
|
594
|
+
`UnknownNotificationChannelError` listing the names that were.
|
|
595
|
+
|
|
596
|
+
## Queued notifications
|
|
597
|
+
|
|
598
|
+
`notifyLater()` and `Notify.queue()` hand delivery to
|
|
599
|
+
[the queue](/docs/queue). With the sync driver the notification is passed
|
|
600
|
+
through in memory; with a persistent driver (SQLite or Redis) it is serialized,
|
|
601
|
+
so both the notification and its recipient have to survive a round trip through
|
|
602
|
+
JSON.
|
|
603
|
+
|
|
604
|
+
Two consequences are worth knowing before you queue anything.
|
|
605
|
+
|
|
606
|
+
**The recipient crosses as a snapshot.** Channels read the `Notifiable` contract
|
|
607
|
+
plus whatever else the model exposes through `toJSON()`, and the rebuilt
|
|
608
|
+
recipient is a plain object. Read fields on a notifiable, not methods, in any
|
|
609
|
+
notification you queue.
|
|
610
|
+
|
|
611
|
+
**The notification is rebuilt by class name.** Classes under
|
|
612
|
+
`app/notifications/` are found automatically. One that lives elsewhere must
|
|
613
|
+
register itself:
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
// app/domain/billing/InvoiceOverdue.ts
|
|
617
|
+
import { NotificationRegistry } from "@zerotal/notifications";
|
|
618
|
+
|
|
619
|
+
export class InvoiceOverdue extends Notification {
|
|
620
|
+
/* … */
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
NotificationRegistry.register(InvoiceOverdue);
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
By default a notification's own enumerable fields are serialized, which covers a
|
|
627
|
+
constructor that assigns plain values. When it holds something JSON cannot carry
|
|
628
|
+
— a model instance, a `Map`, a closure — say how to shrink and rebuild it:
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
// app/notifications/InvoicePaid.ts
|
|
632
|
+
export class InvoicePaid extends Notification {
|
|
633
|
+
constructor(private invoice: Invoice) {
|
|
634
|
+
super();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
override payload() {
|
|
638
|
+
return { invoiceId: this.invoice.id };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
static override async fromPayload(data: Record<string, unknown>) {
|
|
642
|
+
return new InvoicePaid(await Invoice.findOrFail(data["invoiceId"] as number));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
## Testing
|
|
648
|
+
|
|
649
|
+
`NotificationFake` swaps the `"notifications"` container binding for an
|
|
650
|
+
in-memory recorder, so assertions run without hitting any real channel:
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
// tests/orders.test.ts
|
|
654
|
+
import { NotificationFake } from "@zerotal/notifications";
|
|
655
|
+
import { describe, it, beforeEach, afterEach } from "bun:test";
|
|
656
|
+
|
|
657
|
+
let notify: NotificationFake;
|
|
658
|
+
|
|
659
|
+
beforeEach(() => {
|
|
660
|
+
notify = NotificationFake.install();
|
|
661
|
+
});
|
|
662
|
+
afterEach(() => notify.restore());
|
|
663
|
+
|
|
664
|
+
it("notifies the user when order ships", async () => {
|
|
665
|
+
const user = await UserFactory.create();
|
|
666
|
+
const order = await OrderFactory.create({ userId: user.id });
|
|
667
|
+
|
|
668
|
+
await triggerShipment(order);
|
|
669
|
+
|
|
670
|
+
// Assert the right user got the right notification
|
|
671
|
+
notify.assertSentTo(user, OrderShippedNotification);
|
|
672
|
+
|
|
673
|
+
// With an optional filter callback
|
|
674
|
+
notify.assertSentTo(user, OrderShippedNotification, (n) => n instanceof OrderShippedNotification);
|
|
675
|
+
|
|
676
|
+
// Assert a notification was NOT sent to a specific user
|
|
677
|
+
notify.assertNotSentTo(adminUser, OrderShippedNotification);
|
|
678
|
+
|
|
679
|
+
// Assert the channels it was declared to go out on
|
|
680
|
+
notify.assertSentOn(user, OrderShippedNotification, "mail");
|
|
681
|
+
|
|
682
|
+
// Assert it was queued rather than sent immediately
|
|
683
|
+
notify.assertQueued(user, OrderShippedNotification);
|
|
684
|
+
|
|
685
|
+
// Assert how many times one class was sent, across all recipients
|
|
686
|
+
notify.assertSentTimes(OrderShippedNotification, 1);
|
|
687
|
+
|
|
688
|
+
// Assert the exact total count
|
|
689
|
+
notify.assertSentCount(1);
|
|
690
|
+
|
|
691
|
+
// Assert nothing at all was sent
|
|
692
|
+
// notify.assertNothingSent();
|
|
693
|
+
});
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
A failing assertion prints what was actually captured — the notification, the
|
|
697
|
+
recipient, its channels, and whether it was queued — which is normally the fact
|
|
698
|
+
you need next.
|
|
699
|
+
|
|
700
|
+
> **Warning** — `NotificationFake.install()` captures `send` _and_ `queue`, but
|
|
701
|
+
> it has no channel behaviour — `toDatabase()` rows are never written. Its
|
|
702
|
+
> `database` accessor answers as an empty inbox so code under test that reads
|
|
703
|
+
> `unreadNotifications()` keeps working; assert on what was sent, not on the
|
|
704
|
+
> inbox, inside a faked test.
|
|
705
|
+
|
|
706
|
+
## Watching deliveries
|
|
707
|
+
|
|
708
|
+
With [`@zerotal/admin`](/docs/admin) installed, the notifications console
|
|
709
|
+
appears under Operations, gated on the `notifications.view` ability. It shows
|
|
710
|
+
recent delivery attempts with the channel, recipient, duration, and the
|
|
711
|
+
provider's own error text; per-channel totals since boot, which is where a
|
|
712
|
+
failing channel stands out; and the stored inbox, with actions to delete a row or
|
|
713
|
+
prune read notifications.
|
|
714
|
+
|
|
715
|
+
The recent-delivery and per-channel figures are in-process counters, not history
|
|
716
|
+
— they reset when the process does. The durable record of a notification is the
|
|
717
|
+
database channel.
|
|
718
|
+
|
|
719
|
+
## References
|
|
720
|
+
|
|
721
|
+
### NotificationManager
|
|
722
|
+
|
|
723
|
+
| Method | Signature | Description |
|
|
724
|
+
| ----------- | ---------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
725
|
+
| `send` | `(notifiable: Notifiable, notification: Notification) => Promise<void>` | Deliver over every declared channel now. |
|
|
726
|
+
| `sendMany` | `(notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>` | Deliver to many recipients now. |
|
|
727
|
+
| `queue` | `(notifiable: Notifiable, notification: Notification) => Promise<void>` | Queue for background delivery via the queue. |
|
|
728
|
+
| `queueMany` | `(notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>` | Queue for many recipients. |
|
|
729
|
+
| `route` | `(routes: OnDemandRoutes) => { notify, notifyLater }` | Address a destination with no model behind it. |
|
|
730
|
+
| `extend` | `(channel: string, factory: () => NotificationChannel) => this` | Register a custom channel, or replace a built-in. |
|
|
731
|
+
| `channels` | `() => string[]` | Every registered channel name. |
|
|
732
|
+
| `database` | `DatabaseChannel` (getter) | Direct access to the database channel for queries. |
|
|
733
|
+
|
|
734
|
+
The `Notify` facade proxies these — `Notify.send(...)`, `Notify.sendMany(...)`,
|
|
735
|
+
`Notify.queue(...)`, `Notify.route(...)`.
|
|
736
|
+
|
|
737
|
+
### Notification (extend this)
|
|
738
|
+
|
|
739
|
+
Every `to*()` method may return its message directly or a promise of it, so
|
|
740
|
+
building one can do I/O — reading an attachment, loading a record.
|
|
741
|
+
|
|
742
|
+
| Member | Signature | Description |
|
|
743
|
+
| --------------- | ------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
744
|
+
| `channels` | `(notifiable?: Notifiable) => string[]` | Declare the channels to deliver on (abstract). |
|
|
745
|
+
| `toMail` | `(notifiable: Notifiable) => MailMessage` | Build the email for the `mail` channel. |
|
|
746
|
+
| `toDatabase` | `(notifiable: Notifiable) => Record<string, unknown>` | Build the stored payload for the `database` channel. |
|
|
747
|
+
| `toSlack` | `(notifiable: Notifiable) => SlackMessage` | Build the Slack message for the `slack` channel. |
|
|
748
|
+
| `toSms` | `(notifiable: Notifiable) => SmsMessage` | Build the SMS for the `sms` channel. |
|
|
749
|
+
| `toBroadcast` | `(notifiable: Notifiable) => BroadcastMessage \| Record<string, unknown>` | Build the broadcast payload. |
|
|
750
|
+
| `broadcastType` | `() => string` | Wire `type` of a broadcast (default: class name). |
|
|
751
|
+
| `payload` | `() => Record<string, unknown>` | State to store when queued (default: own fields). |
|
|
752
|
+
| `fromPayload` | `static (data) => Notification \| Promise<Notification>` | Rebuild from stored state (optional). |
|
|
753
|
+
|
|
754
|
+
### Notifiable mixin
|
|
755
|
+
|
|
756
|
+
| Method | Signature | Description |
|
|
757
|
+
| ------------------------- | ------------------------------------------------------- | -------------------------------------------- |
|
|
758
|
+
| `notify` | `(n: Notification) => Promise<void>` | Send now across the notification's channels. |
|
|
759
|
+
| `notifyLater` | `(n: Notification) => Promise<void>` | Queue for background delivery. |
|
|
760
|
+
| `notifications` | `(query?: InboxQuery) => Promise<NotificationRecord[]>` | All stored notifications, newest first. |
|
|
761
|
+
| `unreadNotifications` | `(query?: InboxQuery) => Promise<NotificationRecord[]>` | Unread stored notifications, newest first. |
|
|
762
|
+
| `unreadNotificationCount` | `() => Promise<number>` | Unread count, without loading rows. |
|
|
763
|
+
| `markNotificationsAsRead` | `() => Promise<void>` | Mark all unread notifications as read. |
|
|
764
|
+
| `clearNotifications` | `() => Promise<void>` | Delete every stored notification. |
|
|
765
|
+
|
|
766
|
+
### NotificationFake
|
|
767
|
+
|
|
768
|
+
| Method | Description |
|
|
769
|
+
| ------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
|
770
|
+
| `NotificationFake.install()` | Replace the `"notifications"` container binding. Returns the `NotificationFake` instance. |
|
|
771
|
+
| `restore()` | Restore the original binding. Call in `afterEach`. |
|
|
772
|
+
| `sent()` | Return all captured `{ notifiable, notification }` pairs. |
|
|
773
|
+
| `sentTo(notifiable)` | Return the captured notifications for one recipient. |
|
|
774
|
+
| `assertSentTo(notifiable, Class, filter?)` | Throw if `Class` was not sent to `notifiable`. |
|
|
775
|
+
| `assertNotSentTo(notifiable, Class)` | Throw if `Class` was sent to `notifiable`. |
|
|
776
|
+
| `assertSentOn(notifiable, Class, channel)` | Throw unless `Class` declared `channel` for `notifiable`. |
|
|
777
|
+
| `assertQueued(notifiable, Class)` | Throw unless `Class` was queued rather than sent immediately. |
|
|
778
|
+
| `assertSentTimes(Class, n)` | Throw if `Class` was not sent exactly `n` times, across all recipients. |
|
|
779
|
+
| `assertNothingSent()` | Throw if any notification was sent. |
|
|
780
|
+
| `assertSentCount(n)` | Throw if total sent count is not `n`. |
|
|
781
|
+
|
|
782
|
+
### Channel classes
|
|
783
|
+
|
|
784
|
+
You name channels as strings from `channels()`; these are the classes behind them,
|
|
785
|
+
exported so a custom channel can wrap one rather than reimplement it, and so
|
|
786
|
+
`extend()` can replace a built-in with a subclass.
|
|
787
|
+
|
|
788
|
+
| Channel | Class | Builds from |
|
|
789
|
+
| ----------- | ------------------ | --------------- |
|
|
790
|
+
| `mail` | `MailChannel` | `toMail()` |
|
|
791
|
+
| `database` | `DatabaseChannel` | `toDatabase()` |
|
|
792
|
+
| `slack` | `SlackChannel` | `toSlack()` |
|
|
793
|
+
| `sms` | `SmsChannel` | `toSms()` |
|
|
794
|
+
| `broadcast` | `BroadcastChannel` | `toBroadcast()` |
|
|
795
|
+
|
|
796
|
+
### Mail drivers
|
|
797
|
+
|
|
798
|
+
Selected through `config/notifications.ts` rather than constructed directly.
|
|
799
|
+
|
|
800
|
+
| Driver | Class | Notes |
|
|
801
|
+
| -------- | -------------- | ------------------------------------------------------------- |
|
|
802
|
+
| `smtp` | `SmtpDriver` | Speaks SMTP directly — no npm dependency. |
|
|
803
|
+
| `resend` | `ResendDriver` | Posts to the Resend HTTP API; needs an API key. |
|
|
804
|
+
| `log` | `LogDriver` | Writes the rendered message to the log instead of sending it. |
|
|
805
|
+
|
|
806
|
+
### Events
|
|
807
|
+
|
|
808
|
+
Emitted on the [event bus](/docs/events), so an app can observe delivery without
|
|
809
|
+
wrapping the manager.
|
|
810
|
+
|
|
811
|
+
| Event | Fired when |
|
|
812
|
+
| ------------------------------- | ---------------------------------------------------------- |
|
|
813
|
+
| `NotificationSent` | A notification finished delivering across its channels. |
|
|
814
|
+
| `MessageSent` / `MessageFailed` | One channel's delivery succeeded / failed, with the error. |
|
|
815
|
+
| `MessageQueued` | A notification was queued rather than sent inline. |
|
|
816
|
+
|
|
817
|
+
### Delivery counters
|
|
818
|
+
|
|
819
|
+
`recentDeliveries()` returns the most recent attempts, newest first, and
|
|
820
|
+
`channelStats()` per-channel totals, busiest first — the two figures the admin
|
|
821
|
+
console renders. Both are in-process counters that reset with the process; the
|
|
822
|
+
durable record is the database channel.
|
|
823
|
+
|
|
824
|
+
### Errors
|
|
825
|
+
|
|
826
|
+
| Error | Thrown when |
|
|
827
|
+
| ------------------------------------- | ------------------------------------------------------------- |
|
|
828
|
+
| `NotificationError` | Base class — catch this to handle any notification failure. |
|
|
829
|
+
| `NotificationDeliveryError` | A channel's transport rejected the message. |
|
|
830
|
+
| `NotificationChannelUnavailableError` | A declared channel is not registered or is missing config. |
|
|
831
|
+
| `UnknownNotificationTypeError` | A queued notification's stored type cannot be resolved back. |
|
|
832
|
+
| `UnknownSmsDriverError` | The configured SMS driver name is not recognised. |
|
|
833
|
+
| `SmtpConnectionError` | The SMTP server could not be reached or the handshake failed. |
|
|
834
|
+
| `SmtpResponseError` | The SMTP server rejected a command, carrying its reply code. |
|
|
835
|
+
|
|
836
|
+
### Other exports
|
|
837
|
+
|
|
838
|
+
| Export | Purpose |
|
|
839
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
840
|
+
| `OnDemandNotifiable` | The recipient `Notify.route()` builds — a destination with no model behind it. Its database rows are keyed to a random id nothing can query back, so on-demand notifications normally declare transport channels only. |
|
|
841
|
+
| `RichLine` | The chainable line returned inside `MailMessage` for mixed formatting (`.text()`, `.color()`). |
|
|
842
|
+
|
|
843
|
+
## Next steps
|
|
844
|
+
|
|
845
|
+
- [Broadcasting](/docs/broadcasting) — the real-time broadcast channel and channel auth.
|
|
846
|
+
- [Queue](/docs/queue) — background delivery with `notifyLater()` / `Notify.queue()`.
|
|
847
|
+
- [Database](/docs/database) — where stored notifications live.
|
|
848
|
+
- [Admin](/docs/admin) — the panel hosting the notifications console.
|