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 rollup

Three 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 own notifications.* 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).