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
notify(event, input)inserver/notifications/index.ts(Licensees only) inserts a row innotifications, unless the recipient switched off the type's category.- The drawer fetches
GET /api/notifications, which returns the newest 20 and the unread count. It fetches again on every dashboard navigation. - Each row is worded from
dashboard.notifications.types.<type>in the locale files. Opening a row marks it read and follows its link. - The daily
notifications:prunetask deletes notifications that were read more thanretentionDaysago. 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.
linkmust be a same-origin path. Anything else,//hostincluded, is logged and stored asnull. Write it without a locale prefix: the drawer adds the reader's when it navigates.metadatais stored as JSON and returned by the API asmetadata. The shipped panel renders the copy without it; to interpolate values, passrow.metadatatot()inNotificationsPanel.vue(Licensees only).userIdmust be an existing account.
Categories and opt-outs
Notifications use the same three categories as Email, from
shared/messaging/categories.ts(Licensees only):
transactionalis always written. Use it for anything about the user's own account, money or security.productandmarketingcan 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 event | Notification |
|---|---|
payment.succeeded | billing.purchased |
payment.refunded | billing.refunded |
subscription.created | billing.subscriptionStarted |
subscription.cancelled | billing.subscriptionCancelled |
subscription.refunded | billing.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
| File | What 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
| Key | Where | Default | What it does |
|---|---|---|---|
notifications.enabled | starter.config.ts | true | Off, notify() writes nothing and the bell, drawer and in-app switches don't render. |
notifications.retentionDays | starter.config.ts | 30 | Days 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/notificationsand 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.