first commit
This commit is contained in:
109
docs/api-stack/api/api-plugin-notifications/README.md
Normal file
109
docs/api-stack/api/api-plugin-notifications/README.md
Normal 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.
|
||||
|
||||
@@ -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>`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user