Notifications

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

plugins/forum/src/api/lib/notifications.ts
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():

plugins/forum/src/config.api.ts
export const forumApiPlugin = () =>
  buildApiPlugin({
    pluginId: CONFIG_PLUGIN.pluginId,
    messages: apiMessages,
    notificationTypes: [topicReplyNotification],
    modules: [topicsModule],
  })

Or to any module, nested ones included, with buildModule():

plugins/forum/src/api/modules/topics/topics.module.ts
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:

plugins/forum/src/locales/api/en.json
{
  "@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>.

Core cleans whatever present returns before anyone sees it:

  • No HTML. title and body are plain text. Markup is never rendered, whitespace is collapsed and each string is capped at 500 characters.
  • Same-site targets only. target must 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 present throws 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.

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.