Notifications
Unified notifications — one send API across email / Slack / SMS / push / in-app, with per-user channel preferences, an in-app inbox, and delivery records.
@voltro/plugin-notifications is the one messaging answer instead of twenty brand wrappers: a single send across channels, per-user preferences, and an in-app inbox with unread counts — not a per-vendor SDK in every handler.
Wiring
// app.config.ts
import { notificationsPlugin, consoleChannel, webhookChannel, emailChannel } from '@voltro/plugin-notifications'
export default {
type: 'api' as const,
name: 'api',
plugins: [
notificationsPlugin({
channels: [
consoleChannel(), // dev: writes to stdout
emailChannel((m) => myMailer(m)), // bridge to @voltro/plugin-mail
webhookChannel({ url: process.env.SLACK_WEBHOOK! }),// slack/teams/discord incoming webhook
],
}),
],
}The built-in in-app channel persists to the notification store and is appended automatically; consoleChannel(), webhookChannel({ url, id?, format?, headers? }), emailChannel(send), smsChannel(send), pushChannel({ tokensFor, transport }) (see Push), and customChannel(id, deliver) cover the rest. A channel is just { id, deliver: (msg) => Promise<void> } — bring your own.
notificationsPlugin({ channels?, store?, digestWindowMs?, flushIntervalMs?, name? }) — the inbox, per-subject channel preferences, and delivery log auto-persist to the framework DataStore by default, durably, on every supported dialect. You only pass an explicit store for a custom backend (see Store). digestWindowMs enables digest/batching; the scheduled flush (interval flushIntervalMs, default 30s) drains digest windows and quiet-hours deferrals.
Sending — NotificationService
import { NotificationService } from '@voltro/plugin-notifications'
export default (input: { userId: string }, _ctx) => Effect.gen(function* () {
const notify = yield* NotificationService
yield* Effect.promise(() => notify.send({
to: input.userId,
category: 'order.shipped',
title: 'Your order shipped',
body: 'Track it in your account.',
// channels?: ['email', 'inApp'] — narrow this send to specific channels
}))
return { ok: true }
})resolveChannels picks the effective channel set: an explicit channels: on the send narrows to those (intersected with the configured channels); else every configured channel — then any channel the user has turned off for that category (a stored ChannelPreference with enabled: false) is dropped. A missing preference means on. Each delivery is recorded as a DeliveryRecord (channel, status, error?).
Push (APNs / FCM)
pushChannel is a first-class mobile-push channel — the built-in alternative to hand-rolling customChannel:
import { pushChannel, PushTokenRejected } from '@voltro/plugin-notifications'
pushChannel({
tokensFor: async (subjectId) => myDeviceTokens(subjectId), // your device-token table
transport: async (payload) => {
// payload: { token, title, body, badge?, data } — the APNs / FCM shape.
const res = await sendToApns(payload) // your provider + AUTH secret live HERE
if (res.status === 410) throw new PushTokenRejected({ token: payload.token, reason: 'Unregistered' })
},
})deliver formats one PushPayload per device token and sends each. A PushTokenRejected is captured into the fan-out (recorded failed, naming the rejected token — never the auth secret) so your app can prune the dead token. The push AUTH secret lives in your transport closure — it never enters the package and is never logged.
Digest / batching
Set digestWindowMs > 0 and multiple sends to the same subject within the window coalesce into one digest delivery, flushed on the window boundary:
notificationsPlugin({ digestWindowMs: 5 * 60_000 }) // 5-minute rollupThree sends to u1 inside the window produce ONE digest-category notification whose body lists all three and whose data.items carries them. A send that forces its own channels: bypasses the digest (explicit intent → deliver now). The scheduled flush (interval flushIntervalMs) delivers each window on its boundary.
Quiet hours (per-subject DND)
Each subject can set a Do-Not-Disturb window; a send during the window is held and delivered after (default) or dropped:
import { NotificationService } from '@voltro/plugin-notifications'
export default (_input, _ctx) => Effect.gen(function* () {
const notify = yield* NotificationService
// 22:00 → 08:00 in the subject's zone; minutes past local midnight.
yield* Effect.promise(() => notify.setQuietHours({
subjectId: 'u1', startMinute: 22 * 60, endMinute: 8 * 60, tz: 'Europe/Berlin', policy: 'hold',
}))
return { ok: true }
})Windows may wrap midnight. policy: 'hold' defers the send to the window close (delivered by the scheduled flush); policy: 'drop' discards it. Subjects self-manage from the browser with useQuietHours().
Broadcast / topic fan-out
send is single-recipient. To reach N subscribers of a topic in one call, subscribe subjects to a topic and broadcast:
import { NotificationService } from '@voltro/plugin-notifications'
export default (_input, _ctx) => Effect.gen(function* () {
const notify = yield* NotificationService
yield* Effect.promise(() => notify.subscribe('release-notes', 'u1'))
const result = yield* Effect.promise(() => notify.broadcast('release-notes', {
category: 'news', title: 'v2 shipped', body: 'Read the changelog.',
}))
return { recipients: result.recipients } // one call → every subscriber
})Each fan-out send still honours that subject's preferences + quiet hours. Subjects self-subscribe from the browser with useTopicSubscription().
Inbox + preferences (client)
The plugin ships routes — notifications.inbox, unreadCount, markRead, preferences, setPreference, plus the self-service subscribe / unsubscribe (topics) and setQuietHours / clearQuietHours (DND) — and matching hooks:
import {
useInbox, useUnreadCount, useMarkRead, useSetNotificationPreference,
useTopicSubscription, useQuietHours,
} from '@voltro/plugin-notifications/web'
const inbox = useInbox() // InboxItem[]
const unread = useUnreadCount() // number — drives the badge
const markRead = useMarkRead()
const setPref = useSetNotificationPreference()
const topics = useTopicSubscription() // { subscribe, unsubscribe }
const quiet = useQuietHours() // { set, clear }The plugin OWNS the
notifications.*route tags (notifications.inbox,notifications.unreadCount,notifications.markRead,notifications.preferences,notifications.setPreference,notifications.subscribe,notifications.unsubscribe,notifications.setQuietHours,notifications.clearQuietHours). An app must NOT also author its ownnotifications.*procedures — boot fails with a tag-collision error. Use the plugin OR hand-roll your own, never both.
Store
Durable by default — no config. The plugin contributes six tables via its extendSchema and migrates them automatically: notification_inbox, notification_preferences, notification_deliveries, notification_topic_subscriptions (broadcast fan-out set), notification_quiet_hours (per-subject DND window), and notification_held (the digest/quiet-hours held queue). Once the app's store exists, the plugin auto-binds a dataStoreNotificationStore over those tables (via the plugin bindDataStore hook) and declares the store:write permission for them — so the inbox, per-subject channel preferences, and delivery log all persist to the framework DataStore on every supported dialect.
The append-only tables are bounded by the framework retention sweep: notification_deliveries (time-TTL, 90d default), notification_inbox (read-aware — unread items survive, 180d default), and notification_held (safety-net on flushAt, 7d default; the scheduled flush normally drains a held row the moment it is due).
The in-memory store is just the dev/test fallback before the DataStore is bound. You only pass an explicit store for a custom backend:
import { dataStoreNotificationStore, memoryNotificationStore } from '@voltro/plugin-notifications'
// dataStoreNotificationStore(store) — the durable default the plugin auto-binds.
// memoryNotificationStore() — the in-process dev/test fallback.The store is the swap point; the service + channels are storage-agnostic.
Dashboard panel
Both dashboards ship a Notifications panel (api apps): the delivery log (channel → recipient, category, sent/failed status) with per-channel + sent/failed counts, plus a per-subject inbox lookup. Read-only. Backed by /_voltro/inspect/plugins/notifications/{deliveries,inbox} (permission inspect:read).
Permissions
store:write (in-app inbox + delivery records) + inspect:read (dashboard panel).