first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
# @suaveplan/api-plugin-notifications - Notifications plugin for @suaveplan/api. Ships SurrealDB-backed NotificationRepository + DeviceTokenRepository, /notifications and /push Hono OpenAPI route groups, an APN service with injectable transport (no hardcoded network library), an event-bus integration via ctx.eventBus, 18 NOTIFICATIONS_PLUGIN_* error codes, and 6 OTel counters. Server-tier — runs in Bun and Node.
SurrealDB-backed notifications + push plugin for `@suaveplan/api`.
Ships a `NotificationRepository` + `DeviceTokenRepository` pair, an
`ApnService` with a pluggable transport contract (no hardcoded
`@parse/node-apn` dependency), eleven `/notifications/*` Hono OpenAPI
routes, five `/push/*` routes, an opt-in multi-channel
`NotificationDispatcher` (`apn` + `email`) wired on top of
`@suaveplan/notification-channel`, `@suaveplan/emailer`, and
`@suaveplan/delivery-tracking`, twenty-two `NOTIFICATIONS_PLUGIN_*`
error codes, and eight OTel counters.
Server-tier — runs in Bun and Node.
<!-- begin:auto-docs -->
## Documentation tree
- [apn-channel](./src/channels/apn-channel/apn-channel.md) — Adapts the in-package `ApnService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `Notificatio…
- [apn-channel](./src/channels/apn-channel/apn-channel.md) — Adapts the in-package `ApnService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `Notificatio…
- [email-channel](./src/channels/email-channel/email-channel.md) — Adapts `@suaveplan/emailer`'s `EmailService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `N…
- [email-channel](./src/channels/email-channel/email-channel.md) — Adapts `@suaveplan/emailer`'s `EmailService` to the `@suaveplan/notification-channel` `DeliveryChannel` interface so `N…
- [idempotency-adapter](./src/dispatcher/idempotency-adapter/idempotency-adapter.md) — Defines the narrow contract that lets `NotificationDispatcher` delegate per-call deduplication to a caller-supplied imp…
- [idempotency-adapter](./src/dispatcher/idempotency-adapter/idempotency-adapter.md) — Defines the narrow contract that lets `NotificationDispatcher` delegate per-call deduplication to a caller-supplied imp…
- [notification-dispatcher](./src/dispatcher/notification-dispatcher/notification-dispatcher.md) — Top-level orchestrator that lets callers send one notification through whichever delivery channel they pick (APN, email…
- [notification-dispatcher](./src/dispatcher/notification-dispatcher/notification-dispatcher.md) — Top-level orchestrator that lets callers send one notification through whichever delivery channel they pick (APN, email…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-plugin-notifications`. The module exports a frozen `NOTIFICATIONS_PLUGIN…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-plugin-notifications`. The module exports a frozen `NOTIFICATIONS_PLUGIN…
- [otel](./src/otel/instruments.md) — OpenTelemetry instrument bundle for `@suaveplan/api-plugin-notifications`. The module exposes a lazy, memoised factory…
- [instruments](./src/otel/instruments.md) — OpenTelemetry instrument bundle for `@suaveplan/api-plugin-notifications`. The module exposes a lazy, memoised factory…
- [plugin](./src/plugin/plugin.md) — Plugin descriptor factory for `@suaveplan/api-plugin-notifications`. Composes everything in the package — Zod-validated…
- [plugin](./src/plugin/plugin.md) — Plugin descriptor factory for `@suaveplan/api-plugin-notifications`. Composes everything in the package — Zod-validated…
- [device-token-repository](./src/repositories/device-token-repository/device-token-repository.md) — SurrealDB-backed repository for the `device_token` table. Owns every SurrealQL statement the plugin issues against devi…
- [device-token-repository](./src/repositories/device-token-repository/device-token-repository.md) — SurrealDB-backed repository for the `device_token` table. Owns every SurrealQL statement the plugin issues against devi…
- [notification-repository](./src/repositories/notification-repository/notification-repository.md) — SurrealDB-backed repository for the `notification` table. Owns every SurrealQL statement the plugin issues against noti…
- [notification-repository](./src/repositories/notification-repository/notification-repository.md) — SurrealDB-backed repository for the `notification` table. Owns every SurrealQL statement the plugin issues against noti…
- [notifications-routes](./src/routes/notifications-routes/notifications-routes.md) — Hono OpenAPI route module exposing the `/notifications/*` surface. Eleven endpoints cover list / unread-count / create…
- [notifications-routes](./src/routes/notifications-routes/notifications-routes.md) — Hono OpenAPI route module exposing the `/notifications/*` surface. Eleven endpoints cover list / unread-count / create…
- [push-routes](./src/routes/push-routes/push-routes.md) — Hono OpenAPI route module exposing the `/push/*` surface for device- token registration and APN delivery. Five endpoint…
- [push-routes](./src/routes/push-routes/push-routes.md) — Hono OpenAPI route module exposing the `/push/*` surface for device- token registration and APN delivery. Five endpoint…
- [dispatch-schemas](./src/schemas/dispatch-schemas.md) — Defines the Zod boundary for the `NotificationDispatcher` surface. The dispatcher fans a single call out to whichever d…
- [notification-schemas](./src/schemas/notification-schemas.md) — Zod boundary schemas for the notification entity and every wire surface the plugin exposes — list / get / create / upda…
- [options](./src/schemas/options.md) — Zod schema for plugin options accepted by `createNotificationsPlugin()` plus the nested APN configuration shape (`ApnCo…
- [push-schemas](./src/schemas/push-schemas.md) — Zod boundary schemas for the `/push/*` route group and the device-token entity stored in SurrealDB. Schemas describe bo…
- [services](./src/services/registry-augmentation.md) — Module-augmentation file that declares three new service slots on `@suaveplan/api-plugin/service-registry`'s `ServiceRe…
- [apn-service](./src/services/apn-service/apn-service.md) — Apple Push Notification delivery service. Validates an `ApnConfig` at construction-time, builds a pluggable `ApnTranspo…
- [apn-service](./src/services/apn-service/apn-service.md) — Apple Push Notification delivery service. Validates an `ApnConfig` at construction-time, builds a pluggable `ApnTranspo…
- [registry-augmentation](./src/services/registry-augmentation.md) — Module-augmentation file that declares three new service slots on `@suaveplan/api-plugin/service-registry`'s `ServiceRe…
- [apn-transport](./src/transport/apn-transport/apn-transport.md) — Pluggable transport contract for Apple Push Notification delivery. The plugin never depends on a concrete APN client li…
- [apn-transport](./src/transport/apn-transport/apn-transport.md) — Pluggable transport contract for Apple Push Notification delivery. The plugin never depends on a concrete APN client li…
- [types](./src/types/types.md) — Public type aliases for cross-module wiring: the typed event-bus payload map (`NotificationsPluginEvents`), the discrim…
- [types](./src/types/types.md) — Public type aliases for cross-module wiring: the typed event-bus payload map (`NotificationsPluginEvents`), the discrim…
<!-- end:auto-docs -->
## Modules
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md)
- [`src/otel/instruments.md`](src/otel/instruments.md)
- [`src/plugin/plugin.md`](src/plugin/plugin.md)
- [`src/repositories/notification-repository/notification-repository.md`](src/repositories/notification-repository/notification-repository.md)
- [`src/repositories/device-token-repository/device-token-repository.md`](src/repositories/device-token-repository/device-token-repository.md)
- [`src/routes/notifications-routes/notifications-routes.md`](src/routes/notifications-routes/notifications-routes.md)
- [`src/routes/push-routes/push-routes.md`](src/routes/push-routes/push-routes.md)
- [`src/schemas/options.md`](src/schemas/options.md)
- [`src/schemas/notification-schemas.md`](src/schemas/notification-schemas.md)
- [`src/schemas/push-schemas.md`](src/schemas/push-schemas.md)
- [`src/services/apn-service/apn-service.md`](src/services/apn-service/apn-service.md)
- [`src/services/registry-augmentation.md`](src/services/registry-augmentation.md)
- [`src/transport/apn-transport/apn-transport.md`](src/transport/apn-transport/apn-transport.md)
- [`src/types/types.md`](src/types/types.md)
- [`src/channels/apn-channel/apn-channel.md`](src/channels/apn-channel/apn-channel.md)
- [`src/channels/email-channel/email-channel.md`](src/channels/email-channel/email-channel.md)
- [`src/dispatcher/notification-dispatcher/notification-dispatcher.md`](src/dispatcher/notification-dispatcher/notification-dispatcher.md)
- [`src/dispatcher/idempotency-adapter/idempotency-adapter.md`](src/dispatcher/idempotency-adapter/idempotency-adapter.md)
- [`src/schemas/dispatch-schemas.md`](src/schemas/dispatch-schemas.md)
## Usage
```ts
import { createNotificationsPlugin } from "@suaveplan/api-plugin-notifications";
await api.loadPlugin(
createNotificationsPlugin({
basePath: "/v1/notifications",
pushBasePath: "/v1/push",
enablePush: true,
apn: {
token: { key: "/keys/AuthKey.p8", keyId: "K", teamId: "T" },
defaultTopic: "com.example.app",
production: true,
},
})
);
```
## Pluggable APN transport
Production hosts inject a `node-apn`-backed `ApnTransport`; tests use
the deterministic `MemoryApnTransport`:
```ts
import { ApnService, type ApnTransport } from "@suaveplan/api-plugin-notifications";
const transport: ApnTransport = makeNodeApnTransport(provider);
const service = new ApnService({ config, transport });
```
See `src/transport/apn-transport/apn-transport.md` for the full
contract.

View File

@@ -0,0 +1,123 @@
# apn-channel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Adapts the in-package `ApnService` to the
`@suaveplan/notification-channel` `DeliveryChannel` interface so
`NotificationDispatcher` can fan out to APN alongside email and
webhook channels. The channel is registered under the name `apn` and
canSend-gates on `notification.channel === "push"` so push routing
rules in the upstream router can target it explicitly. This module is
the single place where the dispatcher's generic `Notification` shape
is translated into the APN-specific `PushNotificationPayload` —
keeping that translation out of the router and out of `ApnService`
itself.
## Features
- Channel name `apn`; canSend gates on `notification.channel ===
"push"`.
- Reads the device token from `Notification.metadata.deviceToken`
(string) for single-device sends and
`Notification.metadata.deviceTokens` (string array) for fan-out.
- Maps `Notification.subject` → APN alert title, `Notification.body`
→ APN alert body.
- Honours per-notification `metadata.topic`, `metadata.badge`,
`metadata.sound`, `metadata.category` overrides; falls back to the
channel-level `defaultTopic` when no per-notification topic is set.
- Folds per-device failures into a single `DeliveryResult` whose
`success` reflects whether at least one delivery succeeded. The
first failure's `error` is surfaced as `DeliveryResult.error`.
- Returns `{ success: false, error: "deviceToken metadata required" }`
when neither `deviceToken` nor `deviceTokens` is present — no
throws.
## Usage: Basic
```ts
import {
ApnDeliveryChannel,
ApnService,
ApnConfigSchema,
} from "@suaveplan/api-plugin-notifications";
const apn = new ApnService({
config: ApnConfigSchema.parse({
token: { key: "k", keyId: "i", teamId: "t" },
defaultTopic: "com.example.app",
}),
});
await apn.initialize();
const channel = new ApnDeliveryChannel({ apnService: apn });
const result = await channel.send({
id: "notif_1",
channel: "push",
recipient: "user-42",
body: "You have a new message",
priority: "high",
status: "sent",
metadata: { deviceToken: "abc123" },
createdAt: new Date().toISOString(),
});
// result.success === true (when the in-memory transport accepts)
```
## Usage: Advanced
Fan a single notification out to a bag of device tokens with custom
sound and badge override, then plug into a router:
```ts
import { NotificationRouter } from "@suaveplan/notification-channel";
const router = new NotificationRouter({
channels: [{ channel: "push", enabled: true }],
defaultChannel: "push",
deliveryChannels: [new ApnDeliveryChannel({
apnService: apn,
defaultTopic: "com.example.app",
})],
});
await router.send({
recipient: "user-42",
channel: "push",
body: "Order shipped",
metadata: {
deviceTokens: ["tokenA", "tokenB", "tokenC"],
badge: 3,
sound: "ping.caf",
},
});
```
## API Reference
- `class ApnDeliveryChannel implements DeliveryChannel`
- `name: "apn"`
- `canSend(n): boolean` — true iff `n.channel === "push"`
- `send(n): Promise<DeliveryResult>`
- `interface ApnDeliveryChannelOptions`
- `apnService: ApnService` (required)
- `defaultTopic?: string`
- `clock?: Clock`
## Implementation Notes
- The channel never throws on per-device failures — it folds them
into the `DeliveryResult.error` field so the upstream
`NotificationRouter` can decide whether to retry / dead-letter.
- Bulk send path uses `ApnService.sendBulkNotifications` which fans
out at the transport layer; the channel layer does not slice the
array further.
- Single-token path uses `ApnService.sendNotification` to preserve
per-token result granularity in the existing OTel counters.
- Metadata is intentionally read defensively (`typeof === "string"` /
`Array.isArray` guards) because the router's `Notification.metadata`
type is `Record<string, unknown>`.

View File

@@ -0,0 +1,111 @@
# email-channel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Adapts `@suaveplan/emailer`'s `EmailService` to the
`@suaveplan/notification-channel` `DeliveryChannel` interface so
`NotificationDispatcher` can route email notifications through any of
the emailer's three transports (Dummy / SMTP / Gmail REST). The
channel is registered under the name `email` and canSend-gates on
`notification.channel === "email"`. This module is the single place
where the dispatcher's generic `Notification` shape is translated
into an `EmailMessage` — keeping that translation out of the router
and out of `EmailService`.
## Features
- Channel name `email`; canSend gates on `notification.channel ===
"email"`.
- Maps `Notification.recipient` → `EmailMessage.to` (the emailer's
`AddressBag` accepts a single string), `Notification.subject` →
`EmailMessage.subject` (with `defaultSubject` fallback when the
notification carries no subject), `Notification.body` →
`EmailMessage.text` (or `html` when `metadata.html === true`).
- Folds `EmailService.send`'s `{ success: false, error }` outcomes
into `DeliveryResult.error`. No throws for transport failures.
- Refuses to construct with `emailService: null` — raises
`NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)` so the
dispatcher fails fast at boot when an operator forgets to inject
the service.
- Surfaces the emailer's `messageId` when the transport returns one;
falls back to the upstream notification id otherwise.
## Usage: Basic
```ts
import { EmailService, createDummyTransport } from "@suaveplan/emailer";
import { EmailDeliveryChannel } from "@suaveplan/api-plugin-notifications";
const email = new EmailService({
transport: createDummyTransport({ logSends: true }),
});
await email.initialize();
const channel = new EmailDeliveryChannel({ emailService: email });
const result = await channel.send({
id: "notif_1",
channel: "email",
recipient: "user@example.com",
subject: "Welcome",
body: "Hello!",
priority: "normal",
status: "sent",
createdAt: new Date().toISOString(),
});
```
## Usage: Advanced
Render an HTML body, configure a default subject, and wire into the
router:
```ts
import { NotificationRouter } from "@suaveplan/notification-channel";
const channel = new EmailDeliveryChannel({
emailService: email,
defaultSubject: "[YourApp] Notification",
});
const router = new NotificationRouter({
channels: [{ channel: "email", enabled: true }],
defaultChannel: "email",
deliveryChannels: [channel],
});
await router.send({
recipient: "user@example.com",
channel: "email",
body: "<h1>Welcome</h1><p>Glad you're here.</p>",
metadata: { html: true },
});
```
## API Reference
- `class EmailDeliveryChannel implements DeliveryChannel`
- `name: "email"`
- `canSend(n): boolean` — true iff `n.channel === "email"`
- `send(n): Promise<DeliveryResult>`
- `interface EmailDeliveryChannelOptions`
- `emailService: EmailService | null` — null raises
`EMAIL_SERVICE_UNAVAILABLE` at construction
- `defaultSubject?: string` — default `"Notification"`
- `clock?: Clock`
## Implementation Notes
- The emailer's `EmailMessage` requires `subject` to be a non-empty
string; the channel always supplies one (via `defaultSubject` if
the notification omits it).
- `metadata.html === true` flips body delivery from `text` to `html`.
Both formats are mutually exclusive — choose one per notification.
- The channel exposes the emailer's transport-reported `messageId`
on the `DeliveryResult` so callers can correlate delivery-tracking
events with the underlying email message.
- `EmailService.send` never throws (it folds errors into the result),
so the channel doesn't need a try/catch around it.

View File

@@ -0,0 +1,109 @@
# idempotency-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the narrow contract that lets `NotificationDispatcher`
delegate per-call deduplication to a caller-supplied implementation
without `@suaveplan/api-plugin-notifications` taking a runtime
dependency on `@suaveplan/idempotency` (or any other concrete guard).
This keeps the dispatcher package light while still letting hosts plug
a production-grade idempotency guard with cross-process locking when
they need exactly-once semantics.
## Features
- `DispatcherIdempotencyAdapter` interface — a single method
`execute<T>(key, fn): Promise<T>` that runs `fn` at most once per
`key`.
- `NoopIdempotencyAdapter` — the default adapter that just runs `fn()`
every time. Used when the dispatcher is constructed without an
explicit adapter, so the dispatch codepath is identical regardless
of whether dedup is wired up.
- Structural compatibility with
`@suaveplan/idempotency`'s `IdempotencyGuard.execute<T>(key, fn, opts?)`
— callers can hand the guard directly to the dispatcher
(`adapter: guard`).
## Usage: Basic
```ts
import { NoopIdempotencyAdapter } from "@suaveplan/api-plugin-notifications";
const adapter = new NoopIdempotencyAdapter();
const result = await adapter.execute("any-key", async () => 42);
// result === 42, fn ran exactly once
```
## Usage: Advanced
Wire `@suaveplan/idempotency`'s `IdempotencyGuard` straight in — the
signatures are structurally compatible:
```ts
import { IdempotencyGuard, MemoryStore } from "@suaveplan/idempotency";
import {
createNotificationDispatcher,
NoopIdempotencyAdapter,
} from "@suaveplan/api-plugin-notifications";
const guard = new IdempotencyGuard({ store: new MemoryStore() });
const dispatcher = createNotificationDispatcher({
channels: [apnChannel, emailChannel],
defaultChannel: "apn",
// The guard satisfies `DispatcherIdempotencyAdapter` structurally.
idempotency: { adapter: guard },
});
// Same key twice → guard runs the closure once and returns the cached value.
await dispatcher.dispatch({
recipient: "u",
body: "hi",
idempotencyKey: "order-42",
});
await dispatcher.dispatch({
recipient: "u",
body: "hi",
idempotencyKey: "order-42",
});
```
For tests where you want a tiny deterministic cache (no `idempotency`
dep), write a 10-line custom adapter:
```ts
class CachingAdapter implements DispatcherIdempotencyAdapter {
private readonly cache = new Map<string, Promise<unknown>>();
async execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
let p = this.cache.get(key) as Promise<T> | undefined;
if (p === undefined) {
p = fn();
this.cache.set(key, p as Promise<unknown>);
}
return p;
}
}
```
## API Reference
- `interface DispatcherIdempotencyAdapter { execute<T>(key, fn): Promise<T> }`
- `class NoopIdempotencyAdapter implements DispatcherIdempotencyAdapter`
## Implementation Notes
- The adapter is consulted only when `dispatch` receives a non-empty
`idempotencyKey`. When the key is absent, the dispatcher bypasses
the adapter entirely.
- The contract is intentionally narrower than `IdempotencyGuard`'s
`execute(key, fn, opts?)` — the `opts` parameter is optional in the
guard, so omitting it from the adapter signature still leaves the
guard usable as a drop-in.
- `Noop` returns whatever the closure resolves to. It does NOT cache;
duplicate dispatches with the same key trigger the underlying
channel twice. That is the intended behaviour for the default — if
you want dedup, plug a real guard.

View File

@@ -0,0 +1,157 @@
# notification-dispatcher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level orchestrator that lets callers send one notification through
whichever delivery channel they pick (APN, email, webhook, in-app)
without knowing anything about the underlying transports. The
dispatcher resolves a `@suaveplan/notification-channel`
`DeliveryChannel` by the dispatcher-facing channel name (`apn`,
`email`, …), validates every input through Zod, optionally threads
the call through a `DispatcherIdempotencyAdapter` for exactly-once
delivery, emits a `DeliveryEvent` on
`@suaveplan/delivery-tracking`'s `DeliveryEventStore` for every
dispatch (when one is configured), and increments two OTel counters
on success / failure paths.
## Features
- `dispatch(input: DispatchInput): Promise<DispatchResult>` — single
surface for multi-channel routing.
- Zod boundary at the edge; invalid input folds into
`NotificationsPluginError(DISPATCH_INPUT_INVALID)` with the
`ZodError` as `cause`.
- Channel selection: explicit `input.channel` wins; otherwise the
configured `defaultChannel` is used. Unknown channels fail fast with
`DISPATCH_CHANNEL_UNAVAILABLE`.
- Channel-returned failures (`DeliveryResult.success === false`) fold
into `DISPATCH_FAILED` carrying `context.channelError`.
- Optional `DispatcherIdempotencyAdapter` — when an `idempotencyKey`
is supplied, dispatches are routed through `adapter.execute(key,
fn)` so duplicate calls return the cached result without hitting
the underlying channel a second time.
- Optional `DeliveryEventStore` — every dispatch emits a
`DeliveryEvent` (`eventType: "sent"` on success, `"bounced"` on
failure) so downstream analytics can query open / click / bounce
rates per `messageId`.
- OTel: `notificationsDispatchedTotal{channel}` on success,
`notificationsDispatchFailedTotal{channel,reason}` on failure.
## Usage: Basic
```ts
import {
createNotificationDispatcher,
ApnDeliveryChannel,
ApnService,
ApnConfigSchema,
} from "@suaveplan/api-plugin-notifications";
const apn = new ApnService({
config: ApnConfigSchema.parse({
token: { key: "k", keyId: "i", teamId: "t" },
defaultTopic: "com.example.app",
}),
});
await apn.initialize();
const dispatcher = createNotificationDispatcher({
channels: [new ApnDeliveryChannel({ apnService: apn })],
defaultChannel: "apn",
});
const result = await dispatcher.dispatch({
recipient: "user-1",
body: "You have mail!",
metadata: { deviceToken: "abc123" },
});
// result.status === "sent", result.channel === "apn"
```
## Usage: Advanced
Wire APN + email channels, an idempotency guard, and delivery
tracking together:
```ts
import {
EmailService,
createDummyTransport,
} from "@suaveplan/emailer";
import {
InMemoryDeliveryEventStore,
} from "@suaveplan/delivery-tracking";
import {
createNotificationDispatcher,
ApnDeliveryChannel,
EmailDeliveryChannel,
} from "@suaveplan/api-plugin-notifications";
const email = new EmailService({ transport: createDummyTransport() });
await email.initialize();
const store = new InMemoryDeliveryEventStore();
const dispatcher = createNotificationDispatcher({
channels: [
new ApnDeliveryChannel({ apnService: apn }),
new EmailDeliveryChannel({ emailService: email }),
],
defaultChannel: "apn",
deliveryStore: store,
tenantId: "tenant-prod",
});
// Send via email, with an idempotency key for safe retries.
const r1 = await dispatcher.dispatch({
recipient: "user@example.com",
channel: "email",
subject: "Order shipped",
body: "Your order is on the way.",
idempotencyKey: "order-42",
});
// Inspect delivery-tracking outcomes for analytics.
const events = await store.getByMessageId(r1.messageId);
// events[0].eventType === "sent"
```
## API Reference
- `class NotificationDispatcher`
- `constructor(options: NotificationDispatcherOptions)` — throws
`DISPATCH_CHANNEL_UNAVAILABLE` when `channels.length === 0`.
- `dispatch(input: DispatchInput): Promise<DispatchResult>` — main
entry point.
- `interface NotificationDispatcherOptions`
- `channels: ReadonlyArray<DeliveryChannel>` (required)
- `defaultChannel: DispatchChannel` (required)
- `deliveryStore?: DeliveryEventStore`
- `tenantId?: string` (default `"default"`)
- `idempotency?: DispatcherIdempotencyAdapter` (default `NoopIdempotencyAdapter`)
- `clock?: Clock`
- `idGenerator?: IdGenerator`
- `function createNotificationDispatcher(options): NotificationDispatcher`
## Implementation Notes
- The dispatcher wraps the upstream `NotificationRouter` rather than
reimplementing routing — every retry, rate-limit, hook the router
ships is available to dispatcher callers for free.
- The router has no `apn` literal in its `NotificationChannel` union;
the dispatcher maps our `apn` channel down to the upstream `push`
literal so the router's bookkeeping works, while keeping the public
`DispatchChannel` namespace specific.
- `dispatcherChannelName` is stamped into `metadata` so the
delivery-channel impl can sanity-check its own routing.
- Delivery events use the dispatcher's `messageId` (which equals the
router-issued `notif_<n>` id), so consumers querying
`DeliveryEventStore.getByMessageId(dispatchResult.messageId)` will
see exactly the events for that dispatch.
- OTel labels are kept low-cardinality: `channel` is one of six
literals; `reason` on the failure counter is either the router's
reported error string or `"router_threw"` / `"router_failed"`.

View File

@@ -0,0 +1,92 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/api-plugin-notifications`. The
module exports a frozen `NOTIFICATIONS_PLUGIN_CODES` map produced by the
`defineCodes("NOTIFICATIONS_PLUGIN", [...])` helper from `@suaveplan/error`,
plus a concrete `NotificationsPluginError` class that extends
`SuaveplanError<NotificationsPluginErrorCode>`. The namespace literal
deliberately differs from `EVENTS` (owned by `@suaveplan/events`) and
`EVENTS_PLUGIN` (owned by `@suaveplan/api-plugin-events`) so the global
registry never silently shadows codes registered by sibling packages.
## Features
- Eighteen stable codes spanning configuration, lifecycle, repository,
device-token, route, and APN-transport failure modes.
- Type-level narrowing via `NotificationsPluginErrorCode` so any
`instanceof NotificationsPluginError` check pins `error.code` to one
of the declared literals.
- Structured `context` and `cause` passthrough — the constructor
forwards every `SuaveplanErrorOptions` field to the base class so
Zod `ZodError`s, raw `Error`s, and arbitrary telemetry context all
travel together.
- A spec-conformance test reflects the registry at load time and
greps the rest of `src/` to assert every code is referenced from a
real `throw new NotificationsPluginError(...)` site.
## Usage: Basic
```ts
import {
NOTIFICATIONS_PLUGIN_CODES,
NotificationsPluginError,
} from "@suaveplan/api-plugin-notifications";
throw new NotificationsPluginError(
NOTIFICATIONS_PLUGIN_CODES.NOTIFICATION_NOT_FOUND,
"Notification not found",
{ context: { id: "notification:abc" } }
);
```
## Usage: Advanced
Catch handlers narrow `error.code` to the registered literals so
branch-by-code routing stays type-safe without string literals leaking:
```ts
try {
await createNotification(...);
} catch (err) {
if (err instanceof NotificationsPluginError) {
switch (err.code) {
case NOTIFICATIONS_PLUGIN_CODES.APN_SEND_FAILED:
case NOTIFICATIONS_PLUGIN_CODES.APN_SERVICE_UNAVAILABLE:
return scheduleRetry(err);
case NOTIFICATIONS_PLUGIN_CODES.UNAUTHENTICATED:
case NOTIFICATIONS_PLUGIN_CODES.FORBIDDEN:
return reject403(err);
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `NOTIFICATIONS_PLUGIN_CODES` — frozen record of literal codes
(`{ INVALID_CONFIG: "NOTIFICATIONS_PLUGIN.INVALID_CONFIG", ... }`).
- `NotificationsPluginErrorCode` — type alias of the value union.
- `NotificationsPluginError(code, message, options?)` — concrete error
class. Stores `code` on both the base `code` field and a redundant
`notificationsPluginCode` field to keep type narrowing crisp when the
base class is generic over the union.
## Implementation Notes
`defineCodes` accepts a namespace literal and a `readonly` tuple of
suffix literals; the helper returns a frozen record keyed by the suffix
with values `"<namespace>.<suffix>"`. Mutating the result is rejected
at runtime by the `Object.freeze` step inside `@suaveplan/error`. The
spec-conformance test depends on the constant identifier
(`NOTIFICATIONS_PLUGIN_CODES.SOMETHING`) appearing literally in source
files, so codes MUST be referenced by their constant name — not the
string literal — at every throw site.

View File

@@ -0,0 +1,85 @@
# `otel/instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrument bundle for `@suaveplan/api-plugin-notifications`.
The module exposes a lazy, memoised factory that resolves a meter
against the current global `MeterProvider` on first call and caches the
resulting `Counter` instances for the lifetime of the process. A
companion `resetNotificationsInstruments()` clears the cache so tests
can install a fresh in-memory `MeterProvider` and re-resolve the meter
against it deterministically.
## Features
- Six counters covering the entire production surface: notification
create / read / delete, push send / fail, and device-token registry.
- Single meter per process — the meter is created with the package
name, so traces grouped by `service.namespace` keep notifications
metrics separable from sibling plugins.
- Reset helper for tests — the test suite installs a fresh
`InMemoryMetricExporter` per file, resets the cache, and reads back
cumulative metrics through `flushTestMetrics()` from
`tests/setup/polyfills.ts`.
- Counter attributes are caller-supplied. The route layer passes
`{ platform: "ios" }` to `pushSentTotal.add(1, attrs)` and
`{ reason: "BadDeviceToken" }` to `pushFailedTotal.add(1, attrs)`.
## Usage: Basic
```ts
import { getNotificationsInstruments } from "@suaveplan/api-plugin-notifications";
const inst = getNotificationsInstruments();
inst.notificationsCreatedTotal.add(1);
inst.pushSentTotal.add(1, { platform: "ios" });
```
## Usage: Advanced
For test code that installs a fresh provider, reset the cache so the
next call rebinds:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
} from "@opentelemetry/sdk-metrics";
import {
getNotificationsInstruments,
resetNotificationsInstruments,
} from "@suaveplan/api-plugin-notifications";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
resetNotificationsInstruments();
const inst = getNotificationsInstruments();
inst.pushFailedTotal.add(1, { reason: "BadDeviceToken" });
```
## API Reference
- `NotificationsInstruments` — readonly interface with six `Counter`
fields.
- `getNotificationsInstruments(): NotificationsInstruments` — lazy
memoised factory. Idempotent within a session.
- `resetNotificationsInstruments(): void` — drop the cache so the next
factory call rebinds against the current global provider.
## Implementation Notes
The lazy-bind pattern is required because the meter resolution copies
the `MeterProvider` reference at meter-create time. If the bundle were
created at module load, every test in the suite would share one meter
bound to whatever provider happened to be installed first, defeating
isolation. The cache resets between tests via the
`afterEach(resetNotificationsInstruments)` hook in
`instruments.test.ts`.

View File

@@ -0,0 +1,89 @@
# `plugin/plugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Plugin descriptor factory for `@suaveplan/api-plugin-notifications`.
Composes everything in the package — Zod-validated options,
SurrealDB-backed `NotificationRepository` and
`DeviceTokenRepository`, the optional `ApnService`, the two route
modules, and the OTel instrument bundle — into a single
`PluginDescriptor<NotificationsPluginConfig>` that hosts mount via
`@suaveplan/api-plugin`'s standard lifecycle.
## Features
- Boundary Zod validation — invalid options throw
`NotificationsPluginError(INVALID_CONFIG)` with the `ZodError`
attached as `cause`.
- Two-phase lifecycle: `onInit` registers repositories and the APN
service in the host's `ServiceRegistry`; `onReady` mounts both
route trees and refuses double-mount with
`ROUTE_PREFIX_COLLISION`.
- Event-bus integration — emits `notifications:initialized` and
`notifications:ready` on the shared `ctx.eventBus` so siblings
observe lifecycle transitions. Route-level events flow through a
single `forwarder` callback that the route modules call via
`emitEvent`.
- Optional APN — `enablePush: false` (default) skips APN
construction; `enablePush: true` without `config.apn` throws
`APN_CONFIG_MISSING` at boot.
- Graceful close — `onClose` shuts down the APN transport (if any)
and clears the repository references so the GC can collect them.
## Usage: Basic
```ts
import { createNotificationsPlugin } from "@suaveplan/api-plugin-notifications";
await api.loadPlugin(createNotificationsPlugin());
```
## Usage: Advanced
Enable push delivery with a token-auth APN config and a custom mount
prefix:
```ts
await api.loadPlugin(
createNotificationsPlugin({
basePath: "/v1/notifications",
pushBasePath: "/v1/push",
enablePush: true,
apn: {
token: {
key: "/keys/AuthKey_ABC.p8",
keyId: "ABC",
teamId: "TEAM",
},
defaultTopic: "com.example.app",
production: true,
},
maxBulkRecipients: 500,
autoCleanOldDays: 14,
})
);
```
## API Reference
- `createNotificationsPlugin(options?: NotificationsPluginConfigInput)` →
`PluginDescriptor<NotificationsPluginConfig>`
- Lifecycle hooks: `onInit(ctx)`, `onReady(ctx)`, `onClose()`.
- Service slots registered: `notificationRepository`,
`deviceTokenRepository`, `apnService` (only when `enablePush`).
- Event-bus emissions: `notifications:initialized`,
`notifications:ready`, plus every event in `NotificationsPluginEvents`.
## Implementation Notes
The plugin name is `"notifications"`. Dependencies are
`["database"]` — `onInit` will refuse to boot without an
`@suaveplan/api-plugin-database`-registered `db` slot. The plugin
calls `ctx.eventBus.emit(event.type, event.payload)` from a single
`forwarder` callback rather than dragging the `eventBus` reference
into every route handler. This matches the api-plugin-events pattern
and means route handlers stay testable in isolation.

View File

@@ -0,0 +1,83 @@
# `repositories/device-token-repository`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
SurrealDB-backed repository for the `device_token` table. Owns every
SurrealQL statement the plugin issues against device-token rows and
pins the placeholder convention to positional `$p0`, `$p1`, … so the
`DatabaseAdapter.query(sql, params: readonly unknown[])`
surface can rewrite positional arrays into `{p0, p1, …}` records
before handing them to the surrealdb.js driver.
## Features
- Eight operations: `register`, `findById`, `findByToken`,
`findActiveForUser`, `deactivate`, `deactivateByToken`,
`touchLastUsed`, `cleanup`.
- Duplicate-token detection — the SurrealDB `UNIQUE` index on `token`
is honoured at the repository layer with the
`DEVICE_TOKEN_DUPLICATE` error code so callers can branch on
duplicate registration without grepping driver messages.
- Soft-delete semantics — `deactivate` flips `is_active = false` and
bumps `updated_at`; `cleanup(daysOld)` actually removes rows that
have been inactive for `daysOld` days OR have not been seen in
`daysOld` days (whichever).
- Last-seen telemetry — `touchLastUsed(token)` updates `last_used` so
retention cleanup can prune abandoned devices.
- Record-id normalisation — both bare and prefixed ids accepted; the
repository normalises on the way down.
## Usage: Basic
```ts
import { DeviceTokenRepository } from "@suaveplan/api-plugin-notifications";
const repo = new DeviceTokenRepository({ db });
const token = await repo.register("user:42", {
deviceToken: "deadbeef…",
deviceType: "ios",
environment: "sandbox",
});
```
## Usage: Advanced
Resolve all active tokens for a user, touch on every successful
delivery, prune inactive devices weekly:
```ts
const tokens = await repo.findActiveForUser("user:42");
for (const t of tokens) {
const res = await apnService.sendNotification({
deviceToken: t.token,
alert: { title: "Hi" },
});
if (res.sent === 1) await repo.touchLastUsed(t.token);
}
const pruned = await repo.cleanup(30);
```
## API Reference
- `DeviceTokenRepository(config: { db: SurrealAdapter })`
- `IDeviceTokenRepository` — interface (every method documented).
- Throws `DEVICE_TOKEN_CREATE_FAILED` on adapter-empty CREATE,
`DEVICE_TOKEN_DUPLICATE` on unique-index violation,
`DEVICE_TOKEN_NOT_FOUND` for malformed rows, `INVALID_CONFIG` on
negative `daysOld`.
## Implementation Notes
`cleanup` interpolates the ISO datetime literal directly into the
SurrealQL string because SurrealDB does not coerce bound string
parameters to `datetime` inside `<` comparisons. The caller is the
only producer of `daysOld`, which is gated to non-negative integers
at the boundary, so injection is not a concern. Duplicate detection
falls back to string matching on the driver's error message because
the SurrealDB JavaScript driver does not currently expose error
codes; the heuristic is conservative — it requires the message to
mention `token`.

View File

@@ -0,0 +1,81 @@
# `repositories/notification-repository`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
SurrealDB-backed repository for the `notification` table. Owns every
SurrealQL statement the plugin issues against notifications and pins
the placeholder convention to positional `$p0`, `$p1`, … so the
`DatabaseAdapter.query(sql, params: readonly unknown[])`
surface can rewrite positional arrays into `{p0, p1, …}` records before
handing them to the surrealdb.js driver.
## Features
- Twelve operations: `create`, `findById`, `findByRecipient`,
`getUnreadCount`, `update`, `markAsRead`, `markManyAsRead`,
`markAllAsRead`, `dismiss`, `delete`, `findGroupedByType`,
`deleteOldRead`.
- Record-id normalisation — both bare ids (`abc`) and prefixed ids
(`notification:abc`) are accepted at the boundary; the repository
prefixes / strips as needed.
- SurrealDB `RecordId` row shapes (`{ tb, id }`) collapse to
string ids inside `transformRecord(...)` so route responses always
serialise as `"notification:abc"`.
- Retention cleanup — `deleteOldRead(daysOld)` deletes read
notifications older than `daysOld` and returns the row count using
`RETURN BEFORE`.
- Stable error codes: `NOTIFICATION_CREATE_FAILED`,
`NOTIFICATION_NOT_FOUND`, `INVALID_CONFIG` (for negative
`daysOld`).
## Usage: Basic
```ts
import { NotificationRepository } from "@suaveplan/api-plugin-notifications";
const repo = new NotificationRepository({ db });
const n = await repo.create({
type: "friend_request",
recipient: "user:42",
content: "sent you a friend request",
});
```
## Usage: Advanced
Query with filters and grouping:
```ts
const unread = await repo.findByRecipient("user:42", "site:main", {
unreadOnly: true,
type: "post_comment",
since: "2026-05-01T00:00:00Z",
limit: 50,
});
const grouped = await repo.findGroupedByType("user:42", "site:main");
const cleaned = await repo.deleteOldRead(30);
```
## API Reference
- `NotificationRepository(config: { db: SurrealAdapter })`
- `INotificationRepository` — interface (every method documented).
- `FindNotificationsOptions` — `{ limit?, offset?, unreadOnly?, type?, since? }`.
- `BulkOperationResult` — `{ count, updated }`.
- `GroupedNotifications` — `Record<string, Notification[]>`.
## Implementation Notes
`markManyAsRead` interpolates the formatted id list directly into the
SurrealQL string rather than parameterising because SurrealDB does
not accept positional record-id arrays inside `IN [...]` clauses. The
ids are normalised through `normalizeId` so injection-style values
never reach the formatted output. `deleteOldRead` interpolates the
ISO date for the same reason — `<datetime>` literals are accepted but
parameterised strings are rejected by SurrealDB's date-coercion. The
caller is the only producer of `daysOld`, which is gated to
non-negative integers at the boundary.

View File

@@ -0,0 +1,78 @@
# `routes/notifications-routes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hono OpenAPI route module exposing the `/notifications/*` surface.
Eleven endpoints cover list / unread-count / create / get / patch /
mark-read / mark-many / mark-all / dismiss / delete and the count
endpoint. The module is constructed by `createNotificationsRoutes(...)`
and mounted by the plugin under the configured `basePath`. Domain
events emit through the host's shared `eventBus` via an injected
`emitEvent` callback so sibling plugins observe every state change.
## Features
- Per-route Zod validation via `@hono/zod-openapi`'s `createRoute`,
using the boundary schemas from `schemas/notification-schemas.ts`.
- Authentication guard — every route reads `c.get("user")` set by the
host's auth middleware. Missing user returns `401` with body
`{ error, code: NOTIFICATIONS_PLUGIN.UNAUTHENTICATED }`.
- Ownership guard — `/get`, `/patch`, `/read`, `/dismiss`, and
`/delete` only operate on notifications whose `recipient` matches
the authenticated user. Mismatch returns `403`.
- OTel emission — `notificationsCreatedTotal{type}`,
`notificationsReadTotal`, and `notificationsDeletedTotal` are
incremented on successful mutations.
- Event-bus forwarding — `notification:created`, `notification:read`,
`notification:dismissed`, `notification:deleted` are forwarded via
the injected `emitEvent` callback.
## Usage: Basic
```ts
import { createNotificationsRoutes } from "@suaveplan/api-plugin-notifications";
const routes = createNotificationsRoutes({
notificationRepository,
emitEvent: (e) => eventBus.emit(e.type, e.payload),
});
app.route("/notifications", routes);
```
## Usage: Advanced
Compose with host-level middleware (auth, rate-limit, audit):
```ts
import { OpenAPIHono } from "@hono/zod-openapi";
const host = new OpenAPIHono();
host.use("/notifications/*", authMiddleware);
host.use("/notifications/*", rateLimit({ max: 100, windowMs: 60_000 }));
host.route(
"/notifications",
createNotificationsRoutes({ notificationRepository, emitEvent })
);
```
## API Reference
- `createNotificationsRoutes(config: NotificationsRoutesConfig)` →
`OpenAPIHono<{ Variables: NotificationsRoutesVariables }>`
- `NotificationsRoutesVariables` — `{ user, siteId? }`.
## Implementation Notes
The ownership check uses bidirectional substring matching
(`recipient.includes(userId) || userId.includes(recipient)`) so the
guard remains permissive across the two record-id forms the upstream
producers emit (`abc` and `user:abc`). This matches the upstream
behaviour, which has the same heuristic. The route layer never
throws — every failure mode resolves to a structured `errorBody(...)`
JSON response with the plugin's stable error-code string so HTTP
clients can branch on `code` rather than parsing the `error`
message.

View File

@@ -0,0 +1,78 @@
# `routes/push-routes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hono OpenAPI route module exposing the `/push/*` surface for device-
token registration and APN delivery. Five endpoints cover register /
unregister / list device tokens plus send / send-bulk push delivery.
The module is constructed by `createPushRoutes(...)` and mounted by
the plugin under the configured `pushBasePath`. Delivery endpoints
short-circuit to `503` when the APN service is not registered
(`enablePush: false`); the registration endpoints remain available so
mobile clients can register tokens against a backend that opts to
process them later (queued / batched).
## Features
- Per-route Zod validation through `@hono/zod-openapi`.
- Authentication guard via `c.get("user")` (host-set).
- Ownership guard on `DELETE /tokens/{id}` — only the registering
user may unregister their own devices; mismatch returns `403`.
- Idempotent unregister — deleting a non-existent device returns
`200` with a "already absent" message, matching the upstream's
retry-safe contract.
- OTel emission — `deviceTokensRegisteredTotal{platform}`,
`pushSentTotal{platform}`, and `pushFailedTotal{reason}` are
incremented from the route handler.
- Event-bus forwarding — `notification:device:registered`,
`notification:device:unregistered`, `notification:push:sent`, and
`notification:push:failed` route through `emitEvent`.
## Usage: Basic
```ts
import { createPushRoutes } from "@suaveplan/api-plugin-notifications";
const routes = createPushRoutes({
deviceTokenRepository,
apnService,
maxBulkRecipients: 500,
emitEvent: (e) => eventBus.emit(e.type, e.payload),
});
app.route("/push", routes);
```
## Usage: Advanced
Disable push delivery while still accepting token registrations:
```ts
const routes = createPushRoutes({
deviceTokenRepository,
apnService: null, // /send and /send-bulk return 503
maxBulkRecipients: 0,
});
```
## API Reference
- `createPushRoutes(config: PushRoutesConfig)` →
`OpenAPIHono<{ Variables: PushRoutesVariables }>`
- `PushRoutesVariables` — `{ user }`.
## Implementation Notes
`POST /send` resolves the destination user's active device tokens via
`DeviceTokenRepository.findActiveForUser(...)`, fans out one APN call
per device, increments OTel counters per result, and emits a single
`notification:push:sent` event per successful device. The bulk
endpoint caps the recipient list by `maxBulkRecipients` so callers
cannot bypass the boundary cap defined in
`SendBulkPushNotificationSchema`. Per-device failures inside a bulk
send do NOT short-circuit the loop — every user gets a delivery
attempt and the aggregate `sent` / `failed` numbers return in the
response.

View File

@@ -0,0 +1,86 @@
# dispatch-schemas
## Purpose
Defines the Zod boundary for the `NotificationDispatcher` surface. The
dispatcher fans a single call out to whichever delivery channel is
selected (APN, email, etc.) — and because that call accepts free-form
metadata to drive channel-specific behaviours, every field must be
validated at the public edge so the dispatcher never sees a malformed
payload. The schemas in this module are the contract every caller must
satisfy; invalid input folds into
`NotificationsPluginError(DISPATCH_INPUT_INVALID)` carrying the
`ZodError` as `cause`.
## Features
- `DispatchChannelSchema` — closed enum of the six delivery channels
the dispatcher knows about (`email`, `sms`, `push`, `in_app`,
`webhook`, `apn`). Matches the
`notification-channel`-`NotificationChannel` union plus the
in-package `apn` literal.
- `DispatchPrioritySchema` — `low`/`normal`/`high`/`urgent`.
- `DispatchInputSchema` — every public dispatcher input. Requires
`recipient` + `body` to be non-empty; everything else is optional
with sensible defaults.
- `DispatchResultSchema` — shape returned by `dispatch`. `id` and
`messageId` carry the same value (the underlying router id), so
callers can pass `result.messageId` straight to
`DeliveryEventStore` queries.
## Usage: Basic
```ts
import { DispatchInputSchema } from "@suaveplan/api-plugin-notifications";
const input = DispatchInputSchema.parse({
recipient: "user@example.com",
body: "Welcome!",
channel: "email",
});
```
## Usage: Advanced
```ts
import {
DispatchInputSchema,
DispatchResultSchema,
} from "@suaveplan/api-plugin-notifications";
// Pre-validate at the route boundary so the dispatcher never sees a
// malformed payload.
const parsed = DispatchInputSchema.safeParse(req.body);
if (!parsed.success) {
return c.json({ error: "DISPATCH_INPUT_INVALID" }, 400);
}
const result = await dispatcher.dispatch({
...parsed.data,
metadata: { deviceTokens: parsed.data.metadata?.deviceTokens ?? [] },
idempotencyKey: req.headers.get("idempotency-key") ?? undefined,
});
// `result` is guaranteed by `DispatchResultSchema`.
DispatchResultSchema.parse(result);
```
## API Reference
- `DispatchChannelSchema`: `ZodEnum<["email","sms","push","in_app","webhook","apn"]>`
- `DispatchPrioritySchema`: `ZodEnum<["low","normal","high","urgent"]>`
- `DispatchInputSchema`: `ZodObject<{ recipient, body, subject?, channel?, metadata?, priority?, idempotencyKey? }>`
- `DispatchResultSchema`: `ZodObject<{ id, messageId, channel, status, sentAt? }>`
- Types: `DispatchChannel`, `DispatchPriority`, `DispatchInput`, `DispatchResult`.
## Implementation Notes
- The schemas live next to the existing notification/push schemas (one
`src/schemas/*.ts` per surface) so the public boundary is a single
import path.
- `metadata` is `Record<string, unknown>` — channels narrow it
internally with their own type guards (e.g. the APN channel checks
for `deviceToken: string` or `deviceTokens: string[]`).
- `DispatchResult.status` is intentionally narrow (`sent`/`delivered`)
because the dispatcher rejects failures by throwing
`DISPATCH_FAILED`; a `failed` status would be dead data.

View File

@@ -0,0 +1,95 @@
# `schemas/notification-schemas`
## Purpose
Zod boundary schemas for the notification entity and every wire surface
the plugin exposes — list / get / create / update / mark-read /
mark-many-as-read / dismiss / delete / unread-count / grouped. The
schemas live inside the plugin because the suaveplan
`@suaveplan/api-schemas` package does not yet ship a `notification`
subpath; co-locating keeps the plugin self-contained until the upstream
package is augmented.
## Features
- Zod 4 default-import style (`import z from "zod"`) per CLAUDE.md.
- Twelve canonical notification types and six target types, both
pinned by `z.enum(...)`.
- Passthrough `NotificationMetadataSchema` so callers may attach
domain-specific fields without forking the schema.
- Query schema with `z.coerce.number()` / `z.coerce.boolean()` so
query-string values arriving as strings parse cleanly.
- Response wrappers (`NotificationListResponseSchema`,
`UnreadCountResponseSchema`, `GroupedNotificationsSchema`,
`BulkOperationResponseSchema`) used by `createNotificationRoutes`.
- Param schema for `/:id` path captures (`NotificationIdParamSchema`).
## Usage: Basic
```ts
import { CreateNotificationSchema } from "@suaveplan/api-plugin-notifications";
const data = CreateNotificationSchema.parse({
type: "friend_request",
recipient: "user:abc",
content: "sent you a friend request",
});
```
## Usage: Advanced
Compose with `@hono/zod-openapi`'s `createRoute` to produce an OpenAPI
schema and request validator in one step:
```ts
import { createRoute } from "@hono/zod-openapi";
import {
CreateNotificationSchema,
NotificationSchema,
} from "@suaveplan/api-plugin-notifications";
createRoute({
method: "post",
path: "/",
request: {
body: {
content: {
"application/json": { schema: CreateNotificationSchema },
},
},
},
responses: {
201: {
description: "Created",
content: {
"application/json": { schema: NotificationSchema },
},
},
},
});
```
## API Reference
- `NotificationTypeSchema` / `NotificationType`
- `TargetTypeSchema` / `TargetType`
- `NotificationMetadataSchema` / `NotificationMetadata`
- `NotificationSchema` / `Notification`
- `CreateNotificationSchema` / `CreateNotification`
- `UpdateNotificationSchema` / `UpdateNotification`
- `NotificationQuerySchema` / `NotificationQuery`
- `NotificationListResponseSchema` / `NotificationListResponse`
- `UnreadCountResponseSchema` / `UnreadCountResponse`
- `GroupedNotificationsSchema` / `GroupedNotifications`
- `MarkManyAsReadSchema` / `MarkManyAsRead`
- `NotificationIdParamSchema` / `NotificationIdParam`
- `BulkOperationResponseSchema` / `BulkOperationResponse`
## Implementation Notes
`recipient`, `actor`, `target_id`, and `site` are typed as `z.string()`
rather than tightening to a SurrealDB record-id regex because tests and
upstream callers pass both bare ids (`abc`) and prefixed ids
(`user:abc`); the repository normalises on the way down. The schema
deliberately accepts both forms at the boundary so handlers can stay
permissive while persistence remains strict.

View File

@@ -0,0 +1,73 @@
# `schemas/options`
## Purpose
Zod schema for plugin options accepted by `createNotificationsPlugin()`
plus the nested APN configuration shape (`ApnConfigSchema`). Every
field carries a default so consumers can call the factory with no
arguments and still receive a fully-resolved
`NotificationsPluginConfig`. Invalid input surfaces a single
`NotificationsPluginError` with `code === INVALID_CONFIG` and the
`ZodError` as `cause`.
## Features
- `basePath` defaults to `/notifications`; `pushBasePath` defaults to
`/push` — both mount independently so hosts can split the surface
behind different auth gates.
- `enablePush` flag gates the APN service registration. When the flag
is `false` the plugin still mounts `/notifications` but the
`/push/*` routes return `503` for delivery endpoints.
- `apn` accepts either certificate / key / pfx pairs or a token
bundle (`{ key, keyId, teamId }`). Boundary validation happens here;
cross-field validation (exactly-one-auth-method) happens later in
the APN service.
- `autoCleanOldDays` controls the optional retention job (`0` disables
cleanup); `maxBulkRecipients` caps the size of `POST /push/send-bulk`.
## Usage: Basic
```ts
import { createNotificationsPlugin } from "@suaveplan/api-plugin-notifications";
const plugin = createNotificationsPlugin({
basePath: "/v1/notifications",
enablePush: true,
apn: {
token: { key: "/keys/AuthKey_ABC.p8", keyId: "ABC", teamId: "TEAM" },
defaultTopic: "com.example.app",
production: true,
},
});
```
## Usage: Advanced
`z.input<typeof NotificationsPluginOptionsSchema>` is exported as
`NotificationsPluginConfigInput` so callers can use it to type their
own deployment-config files:
```ts
import type { NotificationsPluginConfigInput } from "@suaveplan/api-plugin-notifications";
interface DeploymentConfig {
plugins: { notifications: NotificationsPluginConfigInput };
}
```
## API Reference
- `ApnConfigSchema` / `ApnConfig`
- `NotificationsPluginOptionsSchema`
- `NotificationsPluginConfig` — `z.infer<...>`
- `NotificationsPluginConfigInput` — `z.input<...>`
## Implementation Notes
The schema does not enforce "exactly one of cert/key/pfx/token" at the
boundary because some deployments pass `passphrase` alone (when the
key file already lives on disk and is referenced by path). The APN
service's `validateConfig()` method enforces the cross-field rule and
throws `APN_CONFIG_MISSING` / `APN_CONFIG_INVALID` at
`initialize()` time so the error surfaces with a stable code and full
telemetry context.

View File

@@ -0,0 +1,97 @@
# `schemas/push-schemas`
## Purpose
Zod boundary schemas for the `/push/*` route group and the
device-token entity stored in SurrealDB. Schemas describe both the wire
surface (register / unregister / send / send-bulk requests and
responses) and the persisted record shape that
`DeviceTokenRepository` reads and writes.
## Features
- Three constrained enums (`DevicePlatformSchema`,
`DeviceEnvironmentSchema`) so platform and environment can only be
one of the documented literals.
- Defaults on optional fields (`deviceType` defaults to `"ios"`,
`environment` defaults to `"sandbox"`) so callers can pass minimal
payloads.
- Hard bounds on payload sizes (`title` ≤ 200, `body` ≤ 2000,
`userIds.length` ≤ 1000) so the routes reject pathological input at
the boundary before any database work happens.
- Response envelopes that match the route handler's serialisation:
every push response carries `success`, `sent`, `failed`, and (for
the bulk variant) `totalUsers`.
## Usage: Basic
```ts
import {
RegisterDeviceTokenSchema,
SendPushNotificationSchema,
} from "@suaveplan/api-plugin-notifications";
const reg = RegisterDeviceTokenSchema.parse({ deviceToken: "abc123" });
const send = SendPushNotificationSchema.parse({
userId: "user:42",
title: "Hi",
body: "Welcome",
});
```
## Usage: Advanced
Compose with `@hono/zod-openapi` `createRoute` to generate OpenAPI
operation schemas and validators for the `/push/*` group:
```ts
import { createRoute } from "@hono/zod-openapi";
import {
SendBulkPushNotificationSchema,
SendBulkPushResponseSchema,
} from "@suaveplan/api-plugin-notifications";
createRoute({
method: "post",
path: "/send-bulk",
request: {
body: {
content: {
"application/json": {
schema: SendBulkPushNotificationSchema,
},
},
},
},
responses: {
200: {
description: "Bulk send result",
content: {
"application/json": { schema: SendBulkPushResponseSchema },
},
},
},
});
```
## API Reference
- `DevicePlatformSchema` / `DevicePlatform`
- `DeviceEnvironmentSchema` / `DeviceEnvironment`
- `DeviceTokenSchema` / `DeviceToken`
- `RegisterDeviceTokenSchema` / `RegisterDeviceToken`
- `RegisterDeviceTokenResponseSchema` / `RegisterDeviceTokenResponse`
- `UnregisterDeviceTokenResponseSchema` / `UnregisterDeviceTokenResponse`
- `SendPushNotificationSchema` / `SendPushNotification`
- `SendBulkPushNotificationSchema` / `SendBulkPushNotification`
- `SendPushResponseSchema` / `SendPushResponse`
- `SendBulkPushResponseSchema` / `SendBulkPushResponse`
- `DeviceTokenIdParamSchema` / `DeviceTokenIdParam`
## Implementation Notes
The wire-side `deviceToken` field is named with the leading lower-camel
prefix even though the database column is `token`; the route handler
maps between the two. This matches the upstream public surface and
keeps mobile-client payloads readable without forcing every iOS / FCM
client to rename its outbound key.

View File

@@ -0,0 +1,105 @@
# `services/apn-service`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Apple Push Notification delivery service. Validates an `ApnConfig` at
construction-time, builds a pluggable `ApnTransport` (defaulting to
the deterministic `MemoryApnTransport`), translates the public
`PushNotificationPayload` shape into the transport's library-agnostic
notification format, and maps the transport's response into a
`PushNotificationResult` envelope. Per-device delivery failures NEVER
throw — they land in the result's `errors[]` array so callers can
branch on them without try/catch noise.
## Features
- Three stable failure modes: `APN_CONFIG_MISSING` (no auth
material), `APN_CONFIG_INVALID` (partial token auth),
`APN_NOT_INITIALIZED` (send called after `shutdown()`), plus
transport-level codes (`APN_TRANSPORT_INIT_FAILED`,
`APN_TRANSPORT_SHUTDOWN_FAILED`).
- Auto-initialise on first send — `sendNotification(...)` calls
`initialize()` if the transport isn't live yet.
- Bulk send batches in a single transport call so the underlying
HTTP/2 connection is reused.
- `isInitialized()` returns the live state so route handlers can
short-circuit to `503` when push is disabled.
- Default `pushType` resolution mirrors APN's contract: `"alert"` when
the payload carries title or body, `"background"` when
`contentAvailable` is set, otherwise `"alert"`. Callers may
override.
## Usage: Basic
```ts
import {
ApnService,
createMemoryApnTransport,
} from "@suaveplan/api-plugin-notifications";
const service = new ApnService({
config: {
token: { key: "/keys/AuthKey_ABC.p8", keyId: "ABC", teamId: "TEAM" },
production: false,
defaultTopic: "com.example.app",
},
transportFactory: createMemoryApnTransport,
});
await service.initialize();
const result = await service.sendNotification({
deviceToken: "deadbeef",
alert: { title: "Hi", body: "Welcome" },
badge: 1,
});
console.log(result.sent, result.failed);
```
## Usage: Advanced
Inject a custom transport (e.g. a real `node-apn` adapter) and let the
service own the validation + payload translation:
```ts
import { ApnService, type ApnTransport } from "@suaveplan/api-plugin-notifications";
class NodeApnTransport implements ApnTransport {
async initialize() { /* … */ }
async send(n, tokens) { /* … */ }
async shutdown() { /* … */ }
}
const service = new ApnService({
config: parsedApnConfig,
transport: new NodeApnTransport(),
});
const result = await service.sendBulkNotifications(
["tok1", "tok2", "tok3"],
{ alert: { title: "Bulk", body: "Send" } }
);
```
## API Reference
- `ApnService` — class with `initialize` / `sendNotification` /
`sendBulkNotifications` / `shutdown` / `isInitialized`.
- `ApnServiceOptions` — `{ config, transportFactory?, transport? }`.
- `PushNotificationPayload` — single-message payload.
- `PushNotificationFailure` — per-device failure (`deviceToken`,
`error`, optional `status` / `reason`).
- `PushNotificationResult` — `{ sent, failed, errors? }`.
- `createApnService(options)` — thin factory matching the upstream
surface.
## Implementation Notes
The service never imports `@parse/node-apn` directly — that library
is deliberately absent from the catalog (network-touching
adapters are injected by the host). Tests use the
`MemoryApnTransport` with optional failure plans so every error
branch is exercised without touching the network. Production hosts
write a thin `NodeApnTransport` adapter against their existing
`apn.Provider` and pass it in via `options.transport`.

View File

@@ -0,0 +1,62 @@
# `services/registry-augmentation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Module-augmentation file that declares three new service slots on
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
interface: `notificationRepository`, `deviceTokenRepository`, and
`apnService`. Without this augmentation the typed `ctx.getService(...)`
helper would return `unknown` for these keys; with it, downstream
plugins resolve the slots with no casts.
## Features
- Cumulative module augmentation — the file only declares the slots
this plugin owns. Host slots (`app`, `db`, `logger`, `eventBus`,
`clock`) are declared by `@suaveplan/api`'s own augmentation; the
TypeScript compiler merges every declaration in the program into a
single `ServiceRegistry` shape.
- Side-effect import — the package barrel re-imports this module via
`import "./services/registry-augmentation.js"` so any consumer
that pulls a single public symbol from `@suaveplan/api-plugin-notifications`
gets the type-only augmentation automatically.
## Usage: Basic
```ts
import "@suaveplan/api-plugin-notifications";
const repo = ctx.getService("notificationRepository");
// repo is INotificationRepository | undefined — no cast.
```
## Usage: Advanced
When using a downstream plugin that depends on the notifications
plugin's typed registry, the registry import flows transitively
through the side-effect import:
```ts
import { createMyDownstreamPlugin } from "@my-org/api-plugin-downstream";
import "@suaveplan/api-plugin-notifications";
// Downstream plugin's own augmentation merges with this one — both
// `myService` and `notificationRepository` resolve typed.
```
## API Reference
There is no runtime export — the file only declares
`declare module "@suaveplan/api-plugin/service-registry"`.
## Implementation Notes
The file deliberately re-declares the host slots `app` and `db` so
intellisense never claims they are absent in a context that imports
only this plugin. Module augmentation is cumulative, so the
re-declaration merges with `@suaveplan/api`'s own augmentation rather
than overriding it.

View File

@@ -0,0 +1,107 @@
# `transport/apn-transport`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pluggable transport contract for Apple Push Notification delivery. The
plugin never depends on a concrete APN client library; hosts inject an
`ApnTransport` implementation — production wires in
`@parse/node-apn`, tests use the deterministic
`MemoryApnTransport`, and bespoke deployments may target an on-prem
APN gateway. This module owns both the interface
(`apn-transport.types.ts`) and the in-memory default that ships with
the plugin (`MemoryApnTransport`, `createMemoryApnTransport`).
## Features
- Library-agnostic `ApnTransportResponse` shape — mirrors the
upstream `apn.Responses` envelope but never re-exports `node-apn`'s
internal types.
- Per-device failure injection — the memory transport accepts a
`failurePlan: Map<deviceToken, {status, reason, error}>` so tests
reproduce bad-token / unregistered / unavailable APN responses
deterministically without touching the network.
- Send log — every notification + token list is captured so tests can
assert delivery shape, ordering, and TTL.
- Idempotent `initialize()` and `shutdown()` — the memory transport
flips a boolean; a real `node-apn` transport would open / close the
HTTP/2 connection.
## Usage: Basic
```ts
import {
MemoryApnTransport,
createMemoryApnTransport,
} from "@suaveplan/api-plugin-notifications";
const transport = createMemoryApnTransport({
token: { key: "/keys/AuthKey_ABC.p8", keyId: "ABC", teamId: "TEAM" },
production: false,
});
await transport.initialize();
const result = await transport.send(
{ alert: { title: "Hi", body: "Welcome" } },
["deadbeef"]
);
console.log(result.sent.length, result.failed.length);
```
## Usage: Advanced
Inject a failure plan to exercise APN error paths:
```ts
const failures = new Map([
[
"badtoken",
{ status: 410, reason: "Unregistered" },
],
]);
const transport = new MemoryApnTransport(config, failures);
const result = await transport.send(notification, ["good", "badtoken"]);
expect(result.sent).toEqual([{ device: "good" }]);
expect(result.failed[0]?.response?.reason).toBe("Unregistered");
```
Hosts wiring in `node-apn` for production write a thin adapter that
implements `ApnTransport` against their existing `apn.Provider`:
```ts
class NodeApnTransport implements ApnTransport {
constructor(private provider: apn.Provider) {}
async initialize() { /* noop */ }
async send(n, tokens) {
const note = new apn.Notification();
Object.assign(note, n);
const res = await this.provider.send(
note,
Array.isArray(tokens) ? tokens : [tokens]
);
return { sent: res.sent, failed: res.failed };
}
async shutdown() { await this.provider.shutdown(); }
}
```
## API Reference
- `ApnTransport` — interface (initialize / send / shutdown).
- `ApnTransportNotification` — library-agnostic push payload.
- `ApnTransportResponse` — library-agnostic response envelope.
- `ApnTransportFactory` — `(config: ApnConfig) => ApnTransport`.
- `MemoryApnTransport` — concrete deterministic implementation.
- `createMemoryApnTransport` — default factory.
## Implementation Notes
The plugin uses dependency inversion to keep `@parse/node-apn` (and
its native build dependencies) out of the project's build graph. The
suaveplan catalog deliberately omits `@parse/node-apn`; hosts that need
real APN delivery add it as an application dependency and pass in
their own transport. This matches the suaveplan convention for
network-touching adapters and means the plugin's test suite runs
hermetically.

View File

@@ -0,0 +1,76 @@
# `types/types`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type aliases for cross-module wiring: the typed event-bus
payload map (`NotificationsPluginEvents`), the discriminated union of
domain events the routes forward (`NotificationsRouteEvent`), and the
config interfaces handed to the route factories
(`NotificationsRoutesConfig`, `PushRoutesConfig`). These types are
re-exported from the package root so external subscribers gain typed
`eventBus.on(topic, handler)` calls without casts.
## Features
- Eight typed event keys spanning lifecycle, CRUD mutations, push
delivery, and device-token registration.
- Discriminated route-event union — the route layer constructs one of
the eight `{ type, payload }` shapes and the plugin's forwarder
callback turns it into `ctx.eventBus.emit(type, payload)`.
- Module-augmentation contract is documented inline so consumers
reading the public type alias understand that subscribers receive
full payload typing through
`@suaveplan/api-plugin/service-registry`.
## Usage: Basic
```ts
import type {
NotificationsPluginEvents,
NotificationsRouteEvent,
} from "@suaveplan/api-plugin-notifications";
const event: NotificationsRouteEvent = {
type: "notification:read",
payload: { notificationId: "notification:1", recipientId: "user:1" },
};
```
## Usage: Advanced
A sibling plugin subscribing to push-failure events:
```ts
const offFail = ctx.eventBus.on(
"notification:push:failed",
(payload: NotificationsPluginEvents["notification:push:failed"]) => {
if (payload.reason === "BadDeviceToken") {
deactivate(payload.deviceToken);
}
}
);
```
## API Reference
- `NotificationsPluginEvents` — keyed by event name, value is the
payload object.
- `NotificationsRouteEvent` — discriminated union with eight variants.
- `NotificationsRoutesConfig` —
`{ notificationRepository, emitEvent? }`.
- `PushRoutesConfig` —
`{ deviceTokenRepository, apnService, maxBulkRecipients, emitEvent? }`.
## Implementation Notes
The `NotificationsPluginEvents` map deliberately follows the
`area:action` and `area:subarea:action` conventions used by sibling
plugins (`events:initialized`, `event:created`,
`notification:push:sent`). The discriminated union mirrors the map
exactly so the forwarder callback can route to `eventBus.emit` without
runtime type guards — TypeScript narrows on the literal `type` field
at compile time.