first commit

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

View File

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

View File

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