first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user