Notification types
Reference for buildNotificationType - ids, schema versions, presentation, access checks, grouping, email and mandatory types.
A notification type describes one kind of notification: what data it stores, how it reads in the inbox, who may see it and how users receive it by default.
Declare a type
import { buildNotificationType } from '@vitnode/core/api/lib/notifications/registry'
import { z } from 'zod'
export const topicReplyNotification = buildNotificationType({
id: 'forum.topic_reply',
version: 1,
schema: z.object({
topicId: z.number().int().positive(),
topicTitle: z.string().max(255),
}),
category: 'social',
label: '@acme/forum.notifications.topic_reply.label',
description: '@acme/forum.notifications.topic_reply.description',
subjectType: 'forum.topic',
defaults: { inApp: true, email: 'daily' },
email: true,
grouping: { windowMinutes: 60 },
access: async ({ c, data, userIds }) =>
await canReadTopic(c, data.topicId, userIds),
present: ({ actors, actorCount, data, t }) => ({
title: t('@acme/forum.notifications.topic_reply.title', {
name: actors[0]?.name ?? '',
others: actorCount - 1,
title: data.topicTitle,
}),
target: `/forum/topics/${data.topicId}`,
}),
})The returned object is what you register and what you pass to publish(),
so data is type-checked against schema at every call site.
Register types
Pass them to buildApiPlugin():
export const forumApiPlugin = () =>
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
messages: apiMessages,
notificationTypes: [topicReplyNotification],
modules: [topicsModule],
})Or to any module, nested ones included, with buildModule():
export const topicsModule = buildModule({
pluginId: CONFIG_PLUGIN.pluginId,
name: 'topics',
routes: [replyRoute],
notificationTypes: [topicReplyNotification],
})Core collects the types from every installed plugin when the API starts. The same type id registered twice - by one plugin or by two - stops startup with an error naming both owners.
buildNotificationType options
Prop
Type
buildNotificationType() throws at import time when an option is invalid: a
bad id or category, a version below 1, a grouping window outside 1-10,080
minutes (one week), or an email default without email.
Render the inbox item with present
present runs on the server every time an item is listed or emailed, in the
recipient's language. It receives:
Prop
Type
Put the strings in your plugin's API messages, next to the type:
{
"@acme/forum": {
"notifications": {
"topic_reply": {
"label": "Replies to your topics",
"description": "When someone replies to a topic you started.",
"title": "{others, plural, =0 {{name} replied} one {{name} and # other replied} other {{name} and # others replied}} to {title}"
}
}
}
}A missing label key shows the type id instead, so a forgotten translation is
visible, not broken. Category names are looked up as
core.notifications.categories.<category> and then
<pluginId>.notifications.categories.<category>.
Output is plain text with safe links
Core cleans whatever present returns before anyone sees it:
- No HTML.
titleandbodyare plain text. Markup is never rendered, whitespace is collapsed and each string is capped at 500 characters. - Same-site targets only.
targetmust be a path such as/forum/topics/42. Absolute URLs,//host,javascript:and backslash tricks are dropped, and the item renders without a link. - Failures are contained. If
presentthrows or returns an empty title, that item becomes an "unavailable" placeholder and the error is logged.
Check access with access
access answers "which of these users may see this?". Core calls it:
- during fan-out, once per batch of recipients (up to
fanoutBatchSize); - before an email or digest goes out, for that one user;
- whenever a user lists their inbox, for each item.
Write it to answer for many users with one query. See
Batch access checks
for an example. Without access, every candidate may see the notification.
Group related events with grouping
grouping: {
windowMinutes: 60,
key: ({ data }) => `topic:${data.topicId}`,
},Events with the same type and key, for the same user, in the same
windowMinutes window, join one inbox item: "Alex and 4 others replied to
Shipping day". The key defaults to the event's subject. An event without a
subject, or a key that returns null, is never grouped. See
Grouping for how grouped
items behave.
Change the data shape with version and migrate
Events are stored for weeks. When schema changes, bump version and teach
migrate to upgrade the old shape. Here version 1 stored title, and version 2
renamed it to topicTitle:
export const topicReplyNotification = buildNotificationType({
id: 'forum.topic_reply',
version: 2,
schema: z.object({ topicId: z.number(), topicTitle: z.string() }),
migrate: (data, fromVersion) => {
if (fromVersion !== 1) return data
const old = data as { title: string; topicId: number }
return { topicId: old.topicId, topicTitle: old.title }
},
// ...
})Data that cannot be upgraded - no migrate, a newer stored version, a thrown
error or a failed parse - is never shown. Undelivered events fail, and inbox
items render as placeholders.
Mandatory types
mandatory: true,Use mandatory for account and security notices users must not miss in the
inbox: always delivered in-app and fixed on the preferences page.
Its email follows the installation default. Password resets and sign-in codes
are not notifications - keep sending them with c.get("email").send(). See
Mandatory types and account email.
Related
Notifications
Add persistent notifications to a VitNode plugin - an inbox, a live unread count, push and email choices, daily or weekly digests, all from one publish call.
Publish notifications
Publish a notification from a VitNode plugin with c.get("notifications").publish() - inside your transaction, with an idempotency key, to the users your plugin names.