Initial Commit
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user