Notifications: inbox, push and email (notifications)
One call reaches people wherever they want to be reached. ctx.notify() writes an entry in the user's in-app inbox, sends a push notification to every device they registered and an email, following each user's preferences and quiet hours. Notification types are small files you read in one glance, and your app's settings screen can be built from what the API returns.
npx calm add notifications| Adds | |
|---|---|
| Files | app/blocks/notifications/index.js; the notification resource in app/resources/notification/ (resource and routes); an example type in app/notifications/welcome.js |
| Environment | none |
| Collections | notifications (the inbox, entries expire after 180 days), calm_notification_prefs |
| Routes | /api/notifications (the inbox), GET and PATCH /api/notifications/preferences |
Push goes through app.push and email through app.mailer, so in development everything lands in devtools: inbox entries in Data, pushes in Push (on a simulated phone if you add one), emails in Mail.
Send one#
// app/resources/order/order.routes.js
r.post('/:id/ship', { policy: 'admin' }, async (ctx) => {
const order = await ctx.service.update(ctx.params.id, { status: 'shipped' }, ctx);
await ctx.notify(order.customer, 'order-shipped', { number: order.number });
return order;
});ctx.notify(to, type, data) takes a user id, a user record, or an array of them. It is on every route context, and services get it with the ctx you pass them. Outside a request (jobs, event listeners, scripts), use app.ext.notify(...).
For a one-off message without a type file, pass the message itself. Its type is message:
await ctx.notify(userId, { title: 'Your export is ready', url: '/exports/42', channels: ['inbox'] });It resolves to what happened (an array when to is an array):
{ "user": "6650c0ffee0000000000abcd", "type": "order-shipped", "channels": { "inbox": true, "push": "sent", "email": "sent" }, "quietHours": false, "id": "6650c0ffee0000000000beef" }id is the inbox entry. channels.push is the status of the push (captured in development without a transport, no-devices when the user has no phone registered). A channel that preferences turned off is missing. Users that do not exist or are disabled are skipped: { "user": "...", "skipped": "no such user" }.
Types#
A type is a file in app/notifications/, named after the type:
// app/notifications/order-shipped.js
export default {
title: ({ number }) => `Order #${number} shipped`,
body: 'It arrives Thursday.',
url: ({ number }) => `/orders/${number}`,
category: 'orders',
channels: ['inbox', 'push', 'email'],
collapseId: ({ number }) => `order-${number}`,
};Every field can be a value or a function of the data you pass to ctx.notify().
| Field | |
|---|---|
title, body | What people read. One of them is required. |
url | Where a tap goes. Also sent as the link in the email (relative URLs resolve against APP_URL). |
data | Extra data for the app. The push also carries type and notificationId (the inbox entry). |
category | Lets people turn a whole group of types on or off |
channels | Where it goes by default: inbox, push, email. Default: the defaultChannels setting (['inbox', 'push']). |
required | true for security notices: preferences and quiet hours cannot turn it off |
email | { subject, text, html } when the email should say more than the push. Default subject: the title. Default text: the body and the link. |
collapseId | A newer notification with the same id replaces the older one on the phone |
androidChannel | Android notification channel id |
description | Shown in the preferences list, for your settings screen |
calm doctor warns about types without a title or body, and about unknown channels.
The inbox#
Inbox entries are a regular resource, so they follow the same rules as everything else: each user lists, reads, marks and deletes only their own, and nobody creates them through the API. They expire after 180 days (change the TTL index in notification.resource.js).
| Endpoint | |
|---|---|
GET /api/notifications | Newest first. ?readAt[exists]=false for unread only, ?type=order-shipped to filter |
GET /api/notifications/unread-count | { count } for the bell icon |
POST /api/notifications/:id/read | Mark one read |
POST /api/notifications/read-all | { marked } |
DELETE /api/notifications/:id | Remove one |
curl 'http://localhost:5001/api/notifications?readAt[exists]=false' -H "Authorization: Bearer $TOKEN"With the realtime-sse block, new entries reach open browser tabs right away. With the badge setting on, the iPhone badge shows the unread count.
When someone taps a push, your app gets data.notificationId. Report the tap with POST /api/auth/devices/opened { pushId, data } (or call POST /api/notifications/:id/read), so the unread count stays right on every device. Either way the block only marks entries of the signed-in user. In devtools, tapping a notification on a simulated phone emits the same push.opened event.
Preferences#
curl http://localhost:5001/api/notifications/preferences -H "Authorization: Bearer $TOKEN"{
"data": {
"channels": {},
"quietHours": null,
"types": [
{ "type": "order-shipped", "category": "orders", "description": null, "channels": ["inbox", "push", "email"], "required": false },
{ "type": "welcome", "category": "account", "description": null, "channels": ["inbox", "push"], "required": false }
]
}
}types lists everything a user can receive, so your settings screen needs no list of its own. Turn channels off per type or per category, and set quiet hours in the user's own time zone. During quiet hours there is no push; the inbox and the email still get it.
curl -X PATCH http://localhost:5001/api/notifications/preferences \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "channels": { "orders": { "email": false } }, "quietHours": { "start": "22:00", "end": "07:00", "timeZone": "Europe/Berlin" } }'- Keys in
channelsare type names, categories ormessage(one-off messages). A setting for a type wins over its category. quietHourstakesHH:MMtimes and an IANA time zone (defaultUTC)."quietHours": nullturns them off.- Unknown types, channels or time zones answer
422with the field that is wrong. - Types marked
required: trueignore preferences and quiet hours.
Settings#
// calm.config.js
export default defineConfig({
blocks: { notifications: { defaultChannels: ['inbox'], badge: true } },
});| Option | Default | |
|---|---|---|
defaultChannels | ['inbox', 'push'] | Channels for one-off messages and types that do not list theirs |
badge | true | Send the unread count as the iPhone badge |
Events and devtools#
notification.sent is emitted for every user notified, with the result shown above:
calm.on('notification.sent', ({ user, type, channels }) => metrics.increment('notification', { type }));The devtools Notifications panel lists every type with its channels and the 20 latest inbox entries. calm doctor reports an error when the notification resource is missing.
Testing#
Notifications are captured in tests like everything else: pushes in t.pushes, emails in t.mails.
test('the welcome notification reaches the inbox and the phone', async () => {
const ada = await t.signup({ name: 'Ada' });
await t.post('/auth/devices', { token: 'test-token-1', platform: 'ios' }, ada);
const result = await t.app.ext.notify(ada.id, 'welcome', { name: 'Ada' });
assert.equal(result.channels.inbox, true);
assert.equal(t.pushes.at(-1).message.title, 'Welcome, Ada!');
const unread = await t.get('/notifications/unread-count', ada);
assert.equal(unread.body.data.count, 1);
});