192 lines
9.9 KiB
Markdown
192 lines
9.9 KiB
Markdown
# wire-notification-channels
|
|
|
|
**Package**: `@suaveplan/api-plugin-notifications`
|
|
**Status**: PROPOSED
|
|
**Type**: feature
|
|
**Spec delta**: ./specs/api-plugin-notifications/spec.md
|
|
|
|
## Why
|
|
|
|
The initial migration (`add-api-plugin-notifications`, archived
|
|
2026-05-19) explicitly scoped out four classes of delivery wiring
|
|
because their dependencies had not yet landed on genesis. Quote from
|
|
the archived proposal (`openspec/archive/2026-05-19-add-api-plugin-notifications/proposal.md`
|
|
L97-L138, `### Excluded from this migration`):
|
|
|
|
- FCM / Web Push / Email / SMS / Slack channels — depend on
|
|
`@suaveplan/notification-channel`, `@suaveplan/emailer`, etc.
|
|
- Audit-log emission, idempotency, rate-limit middleware — depend on
|
|
`@suaveplan/api-plugin-audit`, `@suaveplan/idempotency`,
|
|
`@suaveplan/api-plugin-rate-limit`.
|
|
- Delivery tracking — depends on `@suaveplan/delivery-tracking`.
|
|
|
|
Three of those deps have since shipped on `genesis`:
|
|
`@suaveplan/notification-channel` (267 tests, full router + transports),
|
|
`@suaveplan/emailer` (136 tests, three transports), and
|
|
`@suaveplan/delivery-tracking` (71 tests, channel-agnostic event
|
|
tracker). `@suaveplan/idempotency` was already shipped at the time of
|
|
the initial migration. Now that the deps exist, this change recovers
|
|
the **dispatcher** slice of the excluded scope so the plugin can route
|
|
a notification across multiple channels instead of being APN-only at
|
|
the route layer. Audit and rate-limit middleware composition remain
|
|
host-app concerns (the original proposal documented them as such) and
|
|
are not in scope here.
|
|
|
|
This is a `MODIFIED-Requirements` follow-up — the package
|
|
(`@suaveplan/api-plugin-notifications`) already shipped at commit
|
|
`eca398680` and is archived. No new package is created; the existing
|
|
package gains a thin dispatcher layer on top of its APN service.
|
|
|
|
## What changes
|
|
|
|
- ADDED: `NotificationDispatcher` class — wraps
|
|
`notification-channel`'s `NotificationRouter` and exposes a single
|
|
`dispatch(input)` surface that fans out to the registered channels
|
|
with delivery-tracking emission.
|
|
- ADDED: `createNotificationDispatcher(options)` factory — builds a
|
|
dispatcher pre-wired with the APN and email channels (when the
|
|
caller supplies an `ApnService` / `EmailService`), an optional
|
|
delivery-event store, an optional idempotency guard, and an
|
|
injectable clock.
|
|
- ADDED: `ApnDeliveryChannel` class — implements
|
|
`notification-channel`'s `DeliveryChannel` interface against the
|
|
in-package `ApnService`. Registered under channel name `apn`.
|
|
Maps the APN payload shape from a generic `Notification.body` +
|
|
`metadata.deviceToken` (or `metadata.deviceTokens` for fan-out).
|
|
- ADDED: `EmailDeliveryChannel` class — implements `DeliveryChannel`
|
|
against `@suaveplan/emailer`'s `EmailService`. Registered under
|
|
channel name `email`. Maps `Notification.recipient` →
|
|
`EmailMessage.to`, `Notification.subject` → `subject`,
|
|
`Notification.body` → `text` (HTML body when
|
|
`metadata.html === true`).
|
|
- ADDED: `DispatchInput` / `DispatchResult` types — Zod-validated at
|
|
the public boundary; channel selection mirrors
|
|
`NotificationInput.channel` and falls back to the dispatcher's
|
|
default channel.
|
|
- ADDED: `DispatcherIdempotencyAdapter` interface — narrow contract
|
|
(`execute<T>(key, fn)`) that lets callers plug
|
|
`@suaveplan/idempotency`'s `IdempotencyGuard` (or any equivalent)
|
|
without the package taking a hard runtime dep on the guard. Default
|
|
is the in-package `NoopIdempotencyAdapter` that just calls the
|
|
closure.
|
|
- ADDED: `NoopIdempotencyAdapter` — the default implementation that
|
|
executes the closure without any dedup. Exported so consumers can
|
|
reuse it in tests.
|
|
- ADDED: `NOTIFICATIONS_PLUGIN_CODES`
|
|
entries `DISPATCH_FAILED`, `DISPATCH_CHANNEL_UNAVAILABLE`,
|
|
`DISPATCH_INPUT_INVALID`, `EMAIL_SERVICE_UNAVAILABLE` — surfaced
|
|
from the new dispatcher / email channel.
|
|
- ADDED: `notificationsDispatchedTotal` and `notificationsDispatchFailedTotal`
|
|
OTel counters in `getNotificationsInstruments()`.
|
|
- ADDED: `services/registry-augmentation.ts` slots
|
|
`notificationDispatcher` (`NotificationDispatcher`) and
|
|
`emailService` (`EmailService`) — both optional, registered only
|
|
when the caller supplies them through new plugin options.
|
|
- ADDED: `NotificationsPluginOptionsSchema` extends with
|
|
`enableDispatcher: boolean (default false)`, `defaultChannel`
|
|
(`NotificationChannel` from notification-channel, default `apn`),
|
|
and a nested `delivery` block (`store`, `tenantId`,
|
|
`idempotency.adapter`, `idempotency.keyFrom(input)`).
|
|
- MODIFIED: `createNotificationsPlugin` `onInit` — when the host
|
|
injects an `EmailService` via the new `services.emailService`
|
|
option, register it under `emailService`. When `enableDispatcher`
|
|
is on, instantiate a `NotificationDispatcher` from the configured
|
|
channels (`apn` if `apnService` is present, `email` if
|
|
`emailService` is present) and register it under
|
|
`notificationDispatcher`. `onClose` clears the dispatcher too.
|
|
- ADDED: Subpath exports under `./dispatcher/notification-dispatcher`,
|
|
`./channels/apn-channel`, `./channels/email-channel`,
|
|
`./schemas/dispatch-schemas`.
|
|
|
|
## Out of scope
|
|
|
|
- Rewriting the existing route layer to call the dispatcher. The
|
|
routes still call `apnService` directly — adding a dispatcher
|
|
surface is additive and consumers opt in. (Cross-cutting route
|
|
refactor is a separate change.)
|
|
- FCM / Web Push / SMS / Slack delivery channels — no upstream
|
|
transport exists on genesis yet
|
|
(`@suaveplan/sms-providers`, `@suaveplan/slack-client` remain
|
|
pending). Each lands in a follow-up change once its transport
|
|
package ships.
|
|
- Audit-log middleware composition — host-app concern per the
|
|
archived proposal (L135-L138). Not modified here.
|
|
- Rate-limit middleware composition — same. Hosts compose
|
|
`@suaveplan/api-plugin-rate-limit` at the app layer.
|
|
- Schema migrations or queue/history tables — the slimmed schema
|
|
from the initial migration is unchanged.
|
|
|
|
## Source mapping
|
|
|
|
This change derives from
|
|
`../suaveplan/enterprise/api-plugin-notifications.md` "Required
|
|
enterprise-quality improvements" (the now-unblocked rows) and from
|
|
the archived genesis proposal at
|
|
`openspec/archive/2026-05-19-add-api-plugin-notifications/proposal.md`.
|
|
|
|
| Requirement | Source lines | Notes |
|
|
|---|---|---|
|
|
| Multi-channel dispatcher routing notifications to email/push/sms/webhook | archived proposal L113-L117 (`### Excluded — FCM / Web Push / Email / SMS / Slack channels`) | recovered now that `@suaveplan/notification-channel` ships |
|
|
| Email channel via `@suaveplan/emailer` | archived proposal L113-L117 (same row) | concrete email channel binding |
|
|
| Delivery-tracking integration | archived proposal L124-L130 (`Preference center, … delivery tracking, …`) | wired via `@suaveplan/delivery-tracking`'s `DeliveryEventStore` |
|
|
| Optional idempotency on dispatch | archived proposal L135-L138 (`Audit-log emission, idempotency, rate-limit`) | adapter contract so the package never hard-deps on a guard |
|
|
| APN delivery as one channel among several | enterprise/api-plugin-notifications.md "Cross-channel routing" section; archived proposal L34-L33 | wraps existing `ApnService` as a `DeliveryChannel` |
|
|
|
|
### Requirements with no source line
|
|
|
|
| Requirement | Justification |
|
|
|---|---|
|
|
| `DispatcherIdempotencyAdapter` interface (instead of a hard `IdempotencyGuard` dep) | Genesis convention — keep `api-plugin-notifications` from gaining a runtime dep on `@suaveplan/idempotency`. Callers that want dedup wire their own guard through the adapter. |
|
|
| `notificationsDispatchedTotal` + `notificationsDispatchFailedTotal` OTel counters | Genesis-wide observability policy (CLAUDE.md "Quality", AGENTS.md §13) — every new code path emits a counter. |
|
|
| `enableDispatcher` opt-in flag | Backwards compatibility — the existing routes/services keep working unchanged when the flag is off. |
|
|
|
|
## Verification approach
|
|
|
|
- `src/dispatcher/notification-dispatcher.test.ts` — dispatcher
|
|
surface end-to-end: success, channel-disabled, transport failure,
|
|
delivery-tracking emission, idempotency dedup, default-channel
|
|
fallback, multiple-channel fan-out, OTel emission with real
|
|
`MeterProvider`.
|
|
- `src/channels/apn-channel.test.ts` — APN `DeliveryChannel` against
|
|
the in-package `MemoryApnTransport`-backed `ApnService`: success
|
|
path, per-device failure, missing-token rejection, bulk recipient
|
|
fan-out via `metadata.deviceTokens`.
|
|
- `src/channels/email-channel.test.ts` — Email `DeliveryChannel`
|
|
against a `DummyTransport`-backed `EmailService`: success, missing
|
|
subject, transport failure → `DeliveryResult.error`.
|
|
- `src/schemas/dispatch-schemas.test.ts` — Zod boundary tests for
|
|
`DispatchInput` validation.
|
|
- `src/schemas/options.test.ts` — MODIFIED to cover the new
|
|
`enableDispatcher` / `defaultChannel` / `delivery` fields.
|
|
- `src/plugin/plugin.test.ts` — MODIFIED to cover the dispatcher
|
|
registration path (`onInit` registers `notificationDispatcher` when
|
|
`enableDispatcher` is on; `onClose` clears it).
|
|
- `src/api-plugin-notifications.spec-conformance.test.ts` — MODIFIED
|
|
to pin every new public symbol exported by the package barrel.
|
|
- `src/error-codes/error-codes.test.ts` — MODIFIED to pin reachability
|
|
of the four new codes.
|
|
- `src/otel/instruments.test.ts` — MODIFIED to pin the two new counters.
|
|
|
|
## Migration impact
|
|
|
|
None to existing consumers. The dispatcher and the new channels are
|
|
opt-in:
|
|
|
|
- `createNotificationsPlugin()` with no options behaves identically
|
|
(no dispatcher registered, routes still call `apnService` directly).
|
|
- Existing imports (`NotificationRepository`, `ApnService`,
|
|
`createPushRoutes`, etc.) are unchanged.
|
|
|
|
### Affected dependents
|
|
|
|
None on `genesis` today —
|
|
`@suaveplan/api-plugin-notifications` had no downstream consumers at
|
|
the time of writing. Future consumers that opt in to the dispatcher
|
|
gain multi-channel routing without changing call sites for the
|
|
existing routes.
|
|
|
|
## Impact
|
|
|
|
Additive — no removed surface, no signature changes to existing
|
|
public symbols.
|