# 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.



---

<!-- source: en/plugins/notifications.md -->
## 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

```ts
// 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](#push-apns-fcm)), 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](#store)). `digestWindowMs` enables [digest/batching](#digest-batching); the scheduled flush (interval `flushIntervalMs`, default 30s) drains digest windows and quiet-hours deferrals.

## Sending — `NotificationService`

```ts
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`:

```ts
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:

```ts
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**:

```ts
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`:

```ts
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:

```tsx
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.

## Inbox states — read is not archived

`readAt` and `archivedAt` are two states, not one, and the distinction is the
one users care about: archiving is what EMPTIES the inbox.

```ts
notifications.markRead      // one item read
notifications.markUnread    // …and back again
notifications.markAllRead   // → { count } — how many CHANGED, not how many exist
notifications.archive       // out of the inbox
notifications.unarchive     // back into it
```

Archiving does **not** mark an item read. An archived-but-unread item still
counts toward `unreadCount`, so a UI can show what the user actually did rather
than a state the framework inferred for them.

All five are scoped to the calling subject: an inbox action never reaches
another subject's row because an id happens to be guessable.


## Store

**Durable by default — no config.** The plugin contributes six tables via its `extendSchema` and migrates them automatically: `_voltro_notification_inbox`, `_voltro_notification_preferences`, `_voltro_notification_deliveries`, `_voltro_notification_topic_subscriptions` (broadcast fan-out set), `_voltro_notification_quiet_hours` (per-subject DND window), and `_voltro_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: `_voltro_notification_deliveries` (time-TTL, 90d default), `_voltro_notification_inbox` (read-aware — unread items survive, 180d default), and `_voltro_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:

```ts
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`).

## Whose inbox is it — `resolveSubjectId`

```ts
notificationsPlugin({
  // Your addressing unit, not ours. An employee, a member, a contact —
  // something that need not have an auth user. It almost always lives in a
  // TABLE, so the resolver may be async.
  resolveSubjectId: (ctx) => resolveCallerEmployeeId(ctx),
})
```

By default an inbox belongs to `subject.id`. That is the framework's answer and
it is not always yours: a shift change, an absence request or a task reminder is
addressed to a PERSON, and your app may key that person by its own id.

Return your own id here and the whole surface follows — `inbox`, `unreadCount`,
`markRead`, `archive`, preferences, quiet hours.

### Read this before you reach for it

**The subject is whatever signs in. If your addressing unit is not that, you are
addressing something nobody can read.**

That sentence was written by the team that adopted this option and then reversed
it, and it is the correction to an argument this page used to make. The earlier
version justified `resolveSubjectId` with a measured number: rows belonging to
people who had no auth user, which employee-keying would "reach" and `subject.id`
would not. The number was correct and the conclusion was backwards. An inbox
belongs to whoever can OPEN it, and only an account can open one. Keying by
employee did not deliver those rows — it made them look addressed, and charged a
translation on every read path and every push for the privilege.

So the question to ask first is not "what is our addressing unit" but **"can the
thing I am addressing sign in?"** If it cannot, this option gives you rows nobody
will ever see. Translate at the SENDING seam instead — once, where the producer
knows both ids — and leave the inbox keyed by the account.

`resolveSubjectId` remains right for the case it was built for: an app whose
sign-in identity genuinely IS its own id (a member, a contact, a tenant user)
rather than the framework's `subject.id`. That is a different situation from
having a second identity that some accounts happen to map to.

**The resolver may be async, and usually has to be.** An app that has its own
addressing unit keeps it in a table — an employee, a member and a contact are
all rows. If the mapping were in the token, this seam would not be needed at
all: `subject.id` would already be the right id. Return a `string`, a
`Promise<string>`, or `undefined`/`Promise<undefined>`; a sync resolver still
works unchanged.

It is deliberately **not cached** for you. A per-connection cache is the obvious
next step and it is yours to make: the first call would decide the answer for
the life of the connection, so a member created a second after connect resolves
to the fallback until reconnect. You know your invalidation; the framework does
not. Memoise inside your resolver if that trade is right for your app.

Absent keeps `subject.id`, so nothing changes for an app whose units line up.
Returning `undefined` — or throwing, or a rejected promise, if your resolver
reaches a database — falls back the same way rather than failing the read.


## Permissions

`store:write` (in-app inbox + delivery records) + `inspect:read` (dashboard panel).
