Skip to content

Building blocks

Notifications

In-app notifications: a bell in the dashboard's top bar, a drawer with the newest 20, and notify() on the server to write one. The kit notifies buyers about purchases, refunds and subscription changes. You add a type when your own feature needs to tell someone something.

How it works

  1. notify(event, input) in server/notifications/index.ts(Licensees only) inserts a row in notifications, unless the recipient switched off the type's category.
  2. The drawer fetches GET /api/notifications, which returns the newest 20 and the unread count. It fetches again on every dashboard navigation.
  3. Each row is worded from dashboard.notifications.types.<type> in the locale files. Opening a row marks it read and follows its link.
  4. The daily notifications:prune task deletes notifications that were read more than retentionDays ago. Unread ones stay.

Add a notification type

Types live in shared/utils/notifications.ts(Licensees only). Add the name and its category:

export const NOTIFICATION_TYPES = [
  'billing.purchased',
  'billing.refunded',
  'billing.subscriptionStarted',
  'billing.subscriptionCancelled',
  'billing.subscriptionRefunded',

  'product.announcement',

  'reports.ready',
] as const

export const NOTIFICATION_CATEGORY = {
  'billing.purchased': 'transactional',
  'billing.refunded': 'transactional',
  'billing.subscriptionStarted': 'transactional',
  'billing.subscriptionCancelled': 'transactional',
  'billing.subscriptionRefunded': 'transactional',
  'product.announcement': 'product',
  'reports.ready': 'transactional',
} as const satisfies Record<NotificationType, MessageCategory>

Then word it in every locale under dashboard.notifications.types. The dot in the type is a level of nesting, so reports.ready is:

"types": {
  "reports": {
    "ready": "Your report is ready to download."
  }
}

A flat "reports.ready" key renders the raw key path in the drawer. test/unit/notification-copy.test.ts(Licensees only) fails when a locale misses a type or words one that doesn't exist, and when a billing.* or security.* type is not transactional.

The type is stored verbatim in notifications.type, so renaming one later means updating the stored rows. shared/utils/notifications.ts belongs to the template, so expect a small conflict there on updates.

Send a notification

import { notify } from '#server/notifications'

await notify(event, {
  userId,
  type: 'reports.ready',
  metadata: { reportId },
  link: `/reports/${reportId}`,
})

notify never throws. A failed write is logged and dropped, so it's safe inside a webhook, a job or a sign-in path. It returns without writing when notifications are off or the recipient refused the category. The event only needs its bindings, so a job's or a listener's event works.

  • link must be a same-origin path. Anything else, //host included, is logged and stored as null. Write it without a locale prefix: the drawer adds the reader's when it navigates.
  • metadata is stored as JSON and returned by the API as metadata. The shipped panel renders the copy without it; to interpolate values, pass row.metadata to t() in NotificationsPanel.vue(Licensees only).
  • userId must be an existing account.

Categories and opt-outs

Notifications use the same three categories as Email, from shared/messaging/categories.ts(Licensees only):

  • transactional is always written. Use it for anything about the user's own account, money or security.
  • product and marketing can be switched off. Use them for things the user loses nothing by missing.

The switches are separate from the email ones: the in-app refusal lives in users.notify_product_opt_out_at and users.notify_marketing_opt_out_at. Users flip them on /dashboard/settings while notifications.enabled is on. The route behind them is PATCH /api/account/email-preferences with inAppProduct and inAppMarketing booleans (true is subscribed).

product.announcement is declared with the product category and nothing sends it. Use it for product news, or delete it.

Shipped notifications

server/plugins/notify-payments.ts(Licensees only) listens to payments:settled (Events) and writes one notification per applied provider event:

Payment eventNotification
payment.succeededbilling.purchased
payment.refundedbilling.refunded
subscription.createdbilling.subscriptionStarted
subscription.cancelledbilling.subscriptionCancelled
subscription.refundedbilling.subscriptionRefunded

Each links to the billing page of the organization the grant landed on, /o/<publicId>/billing, or /dashboard/billing with organizations off. When that organization is gone, the notification has no link. A purchase with no known user writes nothing. Delete the plugin if you don't want these.

The bell and the drawer

FileWhat it does
NotificationBell.vue(Licensees only)The trigger with the unread count, in DashboardTopBar.vue
NotificationsDrawer.vue(Licensees only)Mounted once in app/layouts/dashboard.vue
NotificationsPanel.vue(Licensees only)The list, shared by both drawer surfaces
useNotifications.ts(Licensees only)The fetch, markAllRead, select, and useNotificationsOpen()

From 1024px up the drawer is a 20rem region on the right that the dashboard shrinks to uncover. Below that it is a slideover. Both read one open flag, so you can open the drawer from anywhere:

const open = useNotificationsOpen()

open.value = true

The drawer closes and refetches when the route path changes; a query-only change does neither. Opening a row marks it read without waiting for the request, so a failed mark leaves it unread and still navigates. Mark all as read shows a toast when it fails.

Configuration

KeyWhereDefaultWhat it does
notifications.enabledstarter.config.tstrueOff, notify() writes nothing and the bell, drawer and in-app switches don't render.
notifications.retentionDaysstarter.config.ts30Days a read notification is kept. false keeps everything.

The build refuses notifications with auth.enabled: false, since every notification belongs to an account. The prune runs on the daily cron with the other scheduled tasks (Background jobs).

Good to know

  • Add a type in the same change as the first notify() that sends it. Someone will build a badge or a preference around a declared type, and if nothing sends it, the bug is silence.
  • Notifications and the activity log are separate. Write a notification only for something worth interrupting the user over; the activity log keeps the audit trail.
  • The API returns 20 rows and no pages. If your app needs a full history, add your own route over notifications.
  • GET /api/notifications and the read routes need a verified session, and marking a row read is scoped to the caller. Another user's id changes nothing and gets the same answer.

A link with a lock opens the kit’s private repository, which every licensee can read.