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.
|
||||
@@ -0,0 +1,367 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-notifications (wire-notification-channels)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-notifications`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: NotificationDispatcher orchestrator
|
||||
|
||||
The package SHALL export a `NotificationDispatcher` class that
|
||||
resolves `@suaveplan/notification-channel`'s `DeliveryChannel`
|
||||
instances by the dispatcher-facing channel name and exposes a
|
||||
`dispatch(input: DispatchInput): Promise<DispatchResult>` surface.
|
||||
The dispatcher SHALL fan a single input out to one of the registered
|
||||
delivery channels (defaulting to the configured `defaultChannel`),
|
||||
record an OTel counter increment per dispatch
|
||||
(`notificationsDispatchedTotal` on success,
|
||||
`notificationsDispatchFailedTotal` on failure), and forward a
|
||||
`delivery:sent` or `delivery:bounced` event through the configured
|
||||
`DeliveryEventStore` (when one is supplied). Channel selection
|
||||
failures SHALL surface as
|
||||
`NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`. Underlying
|
||||
transport failures returned by the router as a `failed` status SHALL
|
||||
surface as `NotificationsPluginError(DISPATCH_FAILED)` carrying the
|
||||
router error as `cause`. Per-dispatch idempotency SHALL be applied
|
||||
through an injected `DispatcherIdempotencyAdapter` whose default
|
||||
implementation (`NoopIdempotencyAdapter`) is the identity.
|
||||
|
||||
**Verified by:** `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to the default channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch routes to an explicit channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_CHANNEL_UNAVAILABLE for an unknown channel"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch raises DISPATCH_FAILED when the channel returns failed"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:sent through the configured store"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch records delivery:bounced when the channel fails"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchedTotal on success"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"dispatch emits notificationsDispatchFailedTotal on failure"`.
|
||||
|
||||
#### Scenario: dispatch routes to the default channel when channel is unspecified
|
||||
|
||||
- **WHEN** `dispatcher.dispatch({ recipient, body })` is called and `defaultChannel === "apn"`
|
||||
- **THEN** the registered APN `DeliveryChannel` receives `send(notification)` and the result `channel === "apn"`
|
||||
|
||||
#### Scenario: dispatch routes to an explicit channel when provided
|
||||
|
||||
- **WHEN** `dispatcher.dispatch({ recipient, body, channel: "email" })` is called against a dispatcher with both `apn` and `email` channels registered
|
||||
- **THEN** only the email channel receives `send`, and the result `channel === "email"`
|
||||
|
||||
#### Scenario: unknown channel raises DISPATCH_CHANNEL_UNAVAILABLE
|
||||
|
||||
- **WHEN** `dispatcher.dispatch({ channel: "sms", … })` is called and no `sms` channel is registered
|
||||
- **THEN** the call rejects with `NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: channel-returned failure raises DISPATCH_FAILED
|
||||
|
||||
- **WHEN** the underlying channel's `send` returns `{ success: false, error: "transport failed" }`
|
||||
- **THEN** `dispatch` rejects with `NotificationsPluginError(DISPATCH_FAILED)` whose `context.channelError === "transport failed"`
|
||||
|
||||
#### Scenario: delivery-tracking sees a delivery:sent event on success
|
||||
|
||||
- **WHEN** `dispatch` succeeds and a `DeliveryEventStore` is configured
|
||||
- **THEN** the store records a `DeliveryEvent` with `eventType === "sent"`, the dispatcher's tenantId, and `messageId === dispatchResult.id`
|
||||
|
||||
#### Scenario: delivery-tracking sees a delivery:bounced event on failure
|
||||
|
||||
- **WHEN** `dispatch` fails through a channel failure and a `DeliveryEventStore` is configured
|
||||
- **THEN** the store records a `DeliveryEvent` with `eventType === "bounced"`
|
||||
|
||||
#### Scenario: idempotency adapter dedupes repeated dispatches
|
||||
|
||||
- **WHEN** the dispatcher is configured with an adapter that caches by key and `dispatch` is invoked twice with the same key
|
||||
- **THEN** the underlying channel's `send` is called only once
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ApnDeliveryChannel adapter
|
||||
|
||||
The package SHALL export an `ApnDeliveryChannel` class implementing
|
||||
`@suaveplan/notification-channel`'s `DeliveryChannel` interface
|
||||
against the in-package `ApnService`. The channel SHALL be registered
|
||||
under `name === "apn"` and SHALL accept the device token via
|
||||
`Notification.metadata.deviceToken` (string) or
|
||||
`Notification.metadata.deviceTokens` (string array, for fan-out).
|
||||
Missing-token inputs SHALL return
|
||||
`{ success: false, error: "deviceToken metadata required" }`. APN
|
||||
per-device failures SHALL fold into a single `DeliveryResult` whose
|
||||
`success` reflects whether at least one delivery succeeded.
|
||||
|
||||
**Verified by:** `src/channels/apn-channel/apn-channel.test.ts::"sends through ApnService for a single deviceToken"`, `src/channels/apn-channel/apn-channel.test.ts::"fans out to multiple deviceTokens via metadata.deviceTokens"`, `src/channels/apn-channel/apn-channel.test.ts::"returns success false when deviceToken metadata is missing"`, `src/channels/apn-channel/apn-channel.test.ts::"folds APN per-device failures into the DeliveryResult"`, `src/channels/apn-channel/apn-channel.test.ts::"reports canSend true only for push channel notifications"`.
|
||||
|
||||
#### Scenario: single deviceToken in metadata
|
||||
|
||||
- **WHEN** `apnChannel.send({ channel: "push", metadata: { deviceToken: "tok" }, body: "hi", … })` is called
|
||||
- **THEN** `ApnService.sendNotification` is invoked with `payload.deviceToken === "tok"` and the returned `DeliveryResult.success === true`
|
||||
|
||||
#### Scenario: deviceTokens array fans out
|
||||
|
||||
- **WHEN** `apnChannel.send({ channel: "push", metadata: { deviceTokens: ["a","b"] }, body: "hi", … })` is called
|
||||
- **THEN** `ApnService.sendBulkNotifications` is invoked with the two-element array and the returned `DeliveryResult.success === true`
|
||||
|
||||
#### Scenario: missing deviceToken metadata
|
||||
|
||||
- **WHEN** `apnChannel.send` is called with no `deviceToken` and no `deviceTokens`
|
||||
- **THEN** the call resolves with `{ success: false, channel: "apn", error: "deviceToken metadata required" }`
|
||||
|
||||
#### Scenario: APN per-device failure surfaces as DeliveryResult.error
|
||||
|
||||
- **WHEN** the underlying `ApnService.sendNotification` returns `{ sent: 0, failed: 1, errors: [{ error: "Unregistered" }] }`
|
||||
- **THEN** `apnChannel.send` resolves with `success: false` and `error: "Unregistered"`
|
||||
|
||||
#### Scenario: canSend gates on channel
|
||||
|
||||
- **WHEN** `apnChannel.canSend(notification)` is called
|
||||
- **THEN** the result is `true` iff `notification.channel === "push"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EmailDeliveryChannel adapter
|
||||
|
||||
The package SHALL export an `EmailDeliveryChannel` class implementing
|
||||
`@suaveplan/notification-channel`'s `DeliveryChannel` interface
|
||||
against `@suaveplan/emailer`'s `EmailService`. The channel SHALL be
|
||||
registered under `name === "email"` and SHALL map
|
||||
`Notification.recipient` → `EmailMessage.to`, `Notification.subject`
|
||||
→ `EmailMessage.subject` (defaulting to the configured
|
||||
`defaultSubject` when missing), `Notification.body` →
|
||||
`EmailMessage.text` (or `html` when `metadata.html === true`).
|
||||
Transport failures returned by `EmailService.send` SHALL surface as
|
||||
`{ success: false, error: <error message> }`. The channel SHALL throw
|
||||
`NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)` at construction
|
||||
when `emailService` is `null`.
|
||||
|
||||
**Verified by:** `src/channels/email-channel/email-channel.test.ts::"sends through EmailService with subject + text"`, `src/channels/email-channel/email-channel.test.ts::"falls back to defaultSubject when notification has no subject"`, `src/channels/email-channel/email-channel.test.ts::"sends HTML body when metadata.html is true"`, `src/channels/email-channel/email-channel.test.ts::"folds transport failure into DeliveryResult.error"`, `src/channels/email-channel/email-channel.test.ts::"throws EMAIL_SERVICE_UNAVAILABLE when constructed with null service"`, `src/channels/email-channel/email-channel.test.ts::"canSend gates on channel === email"`.
|
||||
|
||||
#### Scenario: success path with explicit subject
|
||||
|
||||
- **WHEN** `emailChannel.send({ channel: "email", recipient: "x@y.z", subject: "S", body: "B", … })` is called
|
||||
- **THEN** `EmailService.send` receives `{ to: "x@y.z", subject: "S", text: "B" }` and the channel returns `{ success: true, channel: "email" }`
|
||||
|
||||
#### Scenario: default subject fallback
|
||||
|
||||
- **WHEN** the notification has no `subject` and the channel was constructed with `defaultSubject: "Notification"`
|
||||
- **THEN** `EmailService.send` receives `subject: "Notification"`
|
||||
|
||||
#### Scenario: HTML body via metadata
|
||||
|
||||
- **WHEN** `emailChannel.send({ channel: "email", metadata: { html: true }, body: "<p>x</p>", … })` is called
|
||||
- **THEN** `EmailService.send` receives `{ html: "<p>x</p>" }` instead of `text`
|
||||
|
||||
#### Scenario: transport failure
|
||||
|
||||
- **WHEN** the underlying `EmailService.send` returns `{ success: false, error: new Error("smtp dead"), … }`
|
||||
- **THEN** the channel returns `{ success: false, error: "smtp dead" }`
|
||||
|
||||
#### Scenario: null service refused at construction
|
||||
|
||||
- **WHEN** `new EmailDeliveryChannel({ emailService: null, … })` is invoked
|
||||
- **THEN** the constructor throws `NotificationsPluginError(EMAIL_SERVICE_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: canSend gating
|
||||
|
||||
- **WHEN** `emailChannel.canSend({ channel: "email", … })` versus `{ channel: "push", … }`
|
||||
- **THEN** the first returns `true`, the second returns `false`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: DispatcherIdempotencyAdapter contract
|
||||
|
||||
The package SHALL export a `DispatcherIdempotencyAdapter` interface
|
||||
exposing exactly one method
|
||||
`execute<T>(key: string, fn: () => Promise<T>): Promise<T>` and a
|
||||
default `NoopIdempotencyAdapter` class that simply calls `fn()`.
|
||||
Callers SHALL be able to plug `@suaveplan/idempotency`'s
|
||||
`IdempotencyGuard` (whose `execute(key, fn, opts?)` signature is
|
||||
structurally compatible) without the package importing the guard at
|
||||
runtime. The dispatcher SHALL call `adapter.execute(key, () =>
|
||||
underlyingDispatch())` when a `key` is derivable from the input,
|
||||
short-circuiting duplicate dispatches with the adapter's cached
|
||||
return value.
|
||||
|
||||
**Verified by:** `src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"NoopIdempotencyAdapter calls fn exactly once"`, `src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts::"a caching adapter dedupes repeated executions"`, `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts::"idempotency adapter dedupes repeated dispatches"`.
|
||||
|
||||
#### Scenario: noop adapter is the identity
|
||||
|
||||
- **WHEN** `new NoopIdempotencyAdapter().execute("k", fn)` is invoked
|
||||
- **THEN** `fn()` is called exactly once and its result is returned
|
||||
|
||||
#### Scenario: caching adapter dedupes
|
||||
|
||||
- **WHEN** the same `key` is passed twice and the adapter caches by key
|
||||
- **THEN** the underlying `fn` is invoked only once and both calls return the same result
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dispatch input/result schemas
|
||||
|
||||
The package SHALL export `DispatchInputSchema` and
|
||||
`DispatchResultSchema` (Zod) plus matching `DispatchInput` /
|
||||
`DispatchResult` types. `DispatchInputSchema` SHALL accept
|
||||
`recipient` (non-empty string), `body` (non-empty string), optional
|
||||
`channel` (one of the `notification-channel` channel literals),
|
||||
optional `subject`, optional `metadata` (record),
|
||||
optional `priority`, and an optional `idempotencyKey`. Invalid input
|
||||
SHALL produce
|
||||
`NotificationsPluginError(DISPATCH_INPUT_INVALID)` carrying the
|
||||
`ZodError` as `cause`.
|
||||
|
||||
**Verified by:** `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty recipient"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema rejects empty body"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts a minimal payload"`, `src/schemas/dispatch-schemas.test.ts::"DispatchInputSchema accepts idempotencyKey"`, `src/schemas/dispatch-schemas.test.ts::"DispatchResultSchema accepts canonical shape"`.
|
||||
|
||||
#### Scenario: empty recipient rejected
|
||||
|
||||
- **WHEN** `DispatchInputSchema.safeParse({ recipient: "", body: "x" })` is invoked
|
||||
- **THEN** the result `success === false`
|
||||
|
||||
#### Scenario: minimal payload accepted
|
||||
|
||||
- **WHEN** `DispatchInputSchema.safeParse({ recipient: "u", body: "b" })` is invoked
|
||||
- **THEN** the result `success === true`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: New error codes wired into production paths
|
||||
|
||||
The registry SHALL gain four codes — `DISPATCH_FAILED`,
|
||||
`DISPATCH_CHANNEL_UNAVAILABLE`, `DISPATCH_INPUT_INVALID`,
|
||||
`EMAIL_SERVICE_UNAVAILABLE` — and every one SHALL be thrown from at
|
||||
least one production source under `src/`. The
|
||||
`error-codes.test.ts` reachability scan SHALL include the four new
|
||||
codes.
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every code reachable from src"`.
|
||||
|
||||
#### Scenario: registry exposes the four new codes
|
||||
|
||||
- **WHEN** `Object.values(NOTIFICATIONS_PLUGIN_CODES)` is inspected
|
||||
- **THEN** the array contains `NOTIFICATIONS_PLUGIN.DISPATCH_FAILED`, `NOTIFICATIONS_PLUGIN.DISPATCH_CHANNEL_UNAVAILABLE`, `NOTIFICATIONS_PLUGIN.DISPATCH_INPUT_INVALID`, and `NOTIFICATIONS_PLUGIN.EMAIL_SERVICE_UNAVAILABLE`
|
||||
|
||||
#### Scenario: every new code reachable from src
|
||||
|
||||
- **WHEN** the spec-conformance scan concatenates every non-test `.ts` under `src/`
|
||||
- **THEN** the concatenation contains `NOTIFICATIONS_PLUGIN_CODES.DISPATCH_FAILED`, `…DISPATCH_CHANNEL_UNAVAILABLE`, `…DISPATCH_INPUT_INVALID`, and `…EMAIL_SERVICE_UNAVAILABLE`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: New OTel counters
|
||||
|
||||
`getNotificationsInstruments()` SHALL expose two additional counters:
|
||||
`notificationsDispatchedTotal` (incremented by `NotificationDispatcher`
|
||||
on a successful dispatch, with `channel` attribute) and
|
||||
`notificationsDispatchFailedTotal` (incremented on a failed dispatch,
|
||||
with `channel` + `reason` attributes). Both SHALL be observable
|
||||
through an `InMemoryMetricExporter` in the OTel test.
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"exposes dispatcher counters"`, `src/otel/instruments.test.ts::"dispatcher counters emit on flush"`.
|
||||
|
||||
#### Scenario: instrument bundle exposes the two new counters
|
||||
|
||||
- **WHEN** `getNotificationsInstruments()` is called
|
||||
- **THEN** the returned object has `notificationsDispatchedTotal` and `notificationsDispatchFailedTotal` properties
|
||||
|
||||
#### Scenario: counters appear in the exporter snapshot
|
||||
|
||||
- **WHEN** both counters are incremented and `flushTestMetrics()` is called
|
||||
- **THEN** the exporter snapshot includes both metric names
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Service-registry augmentation for dispatcher + email
|
||||
|
||||
The package SHALL extend the existing `ServiceRegistry` module
|
||||
augmentation with two optional slots:
|
||||
`notificationDispatcher` (`NotificationDispatcher`) and
|
||||
`emailService` (`EmailService` from `@suaveplan/emailer`). The slots
|
||||
SHALL only be populated when the plugin's `enableDispatcher` option
|
||||
is `true` (dispatcher) or when the host injects an `EmailService`
|
||||
(email).
|
||||
|
||||
**Verified by:** `src/api-plugin-notifications.spec-conformance.test.ts::"registry slots include dispatcher and emailService"`, `src/plugin/plugin.test.ts::"onInit registers notificationDispatcher when enableDispatcher is on"`.
|
||||
|
||||
#### Scenario: dispatcher slot populated when enabled
|
||||
|
||||
- **WHEN** `onInit` runs with `enableDispatcher: true`
|
||||
- **THEN** `ctx.getService("notificationDispatcher")` resolves to a `NotificationDispatcher` instance
|
||||
|
||||
#### Scenario: emailService slot populated when injected
|
||||
|
||||
- **WHEN** the host calls `createNotificationsPlugin({ services: { emailService } })` and `onInit` runs
|
||||
- **THEN** `ctx.getService("emailService")` resolves to the same `EmailService` instance
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Plugin factory and lifecycle
|
||||
|
||||
The package SHALL export `createNotificationsPlugin(options?:
|
||||
NotificationsPluginConfigInput & { services?: { emailService?: EmailService } })`
|
||||
returning a `PluginDescriptor<NotificationsPluginConfig>` that mounts
|
||||
both the `/notifications` and `/push` route groups against
|
||||
`@suaveplan/api`. The factory SHALL Zod-validate input via
|
||||
`NotificationsPluginOptionsSchema` and throw
|
||||
`NotificationsPluginError(INVALID_CONFIG)` carrying the `ZodError`
|
||||
as `cause` on invalid input. `onInit` SHALL register
|
||||
`notificationRepository`, `deviceTokenRepository`, and (when
|
||||
`enablePush`) `apnService` in the host's `ServiceRegistry`. When the
|
||||
caller supplies `services.emailService`, `onInit` SHALL register it
|
||||
under the `emailService` slot. When the config has `enableDispatcher:
|
||||
true`, `onInit` SHALL construct a `NotificationDispatcher` wired with
|
||||
every available channel (`apn` if `apnService` is registered, `email`
|
||||
if `emailService` is registered) and register it under
|
||||
`notificationDispatcher`; the dispatcher SHALL refuse to construct
|
||||
when zero channels are available
|
||||
(`NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`).
|
||||
`onReady` SHALL mount both route trees and refuse to double-mount.
|
||||
`onClose` SHALL shut down the APN transport (if any), null the
|
||||
dispatcher reference, and clear runtime state.
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts`, `src/schemas/options.test.ts`, `src/api-plugin-notifications.spec-conformance.test.ts`.
|
||||
|
||||
#### Scenario: valid options produce a descriptor
|
||||
|
||||
- **WHEN** `createNotificationsPlugin({ basePath: "/v1/notifications" })` is called
|
||||
- **THEN** the descriptor has `name === "notifications"`, `dependencies === ["database"]`, and `defaults.basePath === "/v1/notifications"`
|
||||
|
||||
#### Scenario: invalid options fail fast
|
||||
|
||||
- **WHEN** `createNotificationsPlugin({ basePath: "" } as never)` is called
|
||||
- **THEN** the call throws `NotificationsPluginError` with `code === NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG`
|
||||
|
||||
#### Scenario: onInit registers repositories
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with a real `db` service in the registry
|
||||
- **THEN** `ctx.getService("notificationRepository")` and `ctx.getService("deviceTokenRepository")` resolve to non-null instances
|
||||
|
||||
#### Scenario: onInit fails without database service
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with no `db` service registered
|
||||
- **THEN** it throws `NotificationsPluginError(SERVICE_NOT_AVAILABLE)`
|
||||
|
||||
#### Scenario: onInit registers APN when enabled
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enablePush === true` and a valid `apn` config
|
||||
- **THEN** `ctx.getService("apnService")` resolves to an `ApnService` instance whose `isInitialized()` returns `true`
|
||||
|
||||
#### Scenario: onInit fails when enablePush without apn
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enablePush === true` and `apn === undefined`
|
||||
- **THEN** it throws `NotificationsPluginError(APN_CONFIG_MISSING)`
|
||||
|
||||
#### Scenario: onInit registers notificationDispatcher when enableDispatcher is on
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enableDispatcher: true`, `enablePush: true`, and a valid APN config
|
||||
- **THEN** `ctx.getService("notificationDispatcher")` resolves to a `NotificationDispatcher` whose `dispatch({ channel: "apn", … })` succeeds
|
||||
|
||||
#### Scenario: onInit refuses dispatcher with zero channels
|
||||
|
||||
- **WHEN** `onInit(ctx)` runs with `enableDispatcher: true`, `enablePush: false`, and no `emailService` injected
|
||||
- **THEN** it throws `NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)`
|
||||
|
||||
#### Scenario: onReady mounts both route trees
|
||||
|
||||
- **WHEN** `onReady(ctx)` runs after `onInit`
|
||||
- **THEN** `ctx.getService("app").routes` contains entries whose `path` begins with the configured `basePath` AND with the configured `pushBasePath`
|
||||
|
||||
#### Scenario: onReady refuses to double-mount
|
||||
|
||||
- **WHEN** `onReady(ctx)` is invoked twice against the same app instance
|
||||
- **THEN** the second call throws `NotificationsPluginError(ROUTE_PREFIX_COLLISION)`
|
||||
|
||||
#### Scenario: onClose clears repositories AND dispatcher
|
||||
|
||||
- **WHEN** `onClose()` is invoked after a successful boot with `enableDispatcher: true`
|
||||
- **THEN** the dispatcher reference is nulled and subsequent `onReady` invocations against a fresh app raise `SERVICE_NOT_AVAILABLE`
|
||||
@@ -0,0 +1,72 @@
|
||||
# Tasks — wire-notification-channels
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. No retroactive ticks.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `openspec/archive/2026-05-19-add-api-plugin-notifications/proposal.md` end-to-end
|
||||
- [ ] 1.2 Read `openspec/specs/api-plugin-notifications/spec.md` (existing canonical spec)
|
||||
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 6, 13, 14
|
||||
- [ ] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [ ] 1.5 Fill in `## Source mapping` table — every requirement cites archived proposal lines or has a "no source line" justification
|
||||
- [ ] 1.6 Draft `specs/api-plugin-notifications/spec.md` with `## ADDED Requirements` (dispatcher + channels + tracking) and `## MODIFIED Requirements` (plugin lifecycle, options schema)
|
||||
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [ ] 2.1 Add `@suaveplan/notification-channel`, `@suaveplan/emailer`, `@suaveplan/delivery-tracking` to `package.json` `dependencies` via `workspace:^`
|
||||
- [ ] 2.2 Add new error codes (`DISPATCH_FAILED`, `DISPATCH_CHANNEL_UNAVAILABLE`, `DISPATCH_INPUT_INVALID`, `EMAIL_SERVICE_UNAVAILABLE`) to `src/error-codes/error-codes.ts`
|
||||
- [ ] 2.3 Add `notificationsDispatchedTotal` + `notificationsDispatchFailedTotal` OTel counters
|
||||
- [ ] 2.4 Create `src/channels/apn-channel/` module — `ApnDeliveryChannel` implementing `DeliveryChannel`, with sibling `.md`
|
||||
- [ ] 2.5 Create `src/channels/email-channel/` module — `EmailDeliveryChannel` implementing `DeliveryChannel`, with sibling `.md`
|
||||
- [ ] 2.6 Create `src/dispatcher/notification-dispatcher/` module — wraps `NotificationRouter`, emits delivery events, optional idempotency adapter, with sibling `.md`
|
||||
- [ ] 2.7 Create `src/dispatcher/idempotency-adapter/` module — `DispatcherIdempotencyAdapter` interface + `NoopIdempotencyAdapter` impl, with sibling `.md`
|
||||
- [ ] 2.8 Create `src/schemas/dispatch-schemas.ts` — Zod `DispatchInputSchema` + `DispatchResultSchema`, with sibling `.md`
|
||||
- [ ] 2.9 Extend `src/schemas/options.ts` with `enableDispatcher`, `defaultChannel`, `delivery` fields; existing fields unchanged
|
||||
- [ ] 2.10 Extend `src/services/registry-augmentation.ts` with `notificationDispatcher`, `emailService` slots
|
||||
- [ ] 2.11 Extend `src/plugin/plugin.ts` — `onInit` registers `emailService` (if injected) + `notificationDispatcher` (if `enableDispatcher`); `onClose` clears the dispatcher
|
||||
- [ ] 2.12 Extend `src/index.ts` to re-export every new public symbol; add per-feature subpath exports in `package.json`
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [ ] 3.1 `src/channels/apn-channel/apn-channel.test.ts` — every behavior (success, per-device failure, missing-token rejection, bulk fan-out via `metadata.deviceTokens`)
|
||||
- [ ] 3.2 `src/channels/email-channel/email-channel.test.ts` — success, missing subject, transport failure
|
||||
- [ ] 3.3 `src/dispatcher/notification-dispatcher/notification-dispatcher.test.ts` — end-to-end dispatcher: success path, channel-disabled, transport failure, delivery-tracking emission, idempotency dedup, default-channel fallback, OTel emission
|
||||
- [ ] 3.4 `src/dispatcher/idempotency-adapter/idempotency-adapter.test.ts` — `NoopIdempotencyAdapter` and a fake guard adapter
|
||||
- [ ] 3.5 `src/schemas/dispatch-schemas.test.ts` — Zod boundary tests
|
||||
- [ ] 3.6 Extend `src/schemas/options.test.ts` to cover the three new fields
|
||||
- [ ] 3.7 Extend `src/plugin/plugin.test.ts` to cover dispatcher registration + cleanup
|
||||
- [ ] 3.8 Extend `src/error-codes/error-codes.test.ts` to pin reachability of the four new codes
|
||||
- [ ] 3.9 Extend `src/otel/instruments.test.ts` to pin the two new counters
|
||||
- [ ] 3.10 Extend `src/api-plugin-notifications.spec-conformance.test.ts` to pin every new public export
|
||||
- [ ] 3.11 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [ ] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 4.2 README updated with a `## Dispatcher` section linking the new modules
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bunx biome check src/` — Biome 0 errors from the package folder
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-notifications` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-notifications` — 100/100/100/100 istanbul
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-notifications` — exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-notifications` — green
|
||||
|
||||
## 6. Pre-archive 9-row checklist (AGENTS.md §13)
|
||||
|
||||
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary (DispatchInput, options extension)
|
||||
- [ ] 6.3 Every error code in `error-codes.ts` thrown by at least one production path (incl. the four new codes)
|
||||
- [ ] 6.4 Strict-mode behaviors tested both on and off where applicable
|
||||
- [ ] 6.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
|
||||
- [ ] 6.6 Lifecycle dedup tested (idempotency adapter exercised)
|
||||
- [ ] 6.7 No banned patterns — no `vi.mock`, no `export *` in feature barrels, no `// biome-ignore`, test files only import from `@suaveplan/testing/runner`
|
||||
- [ ] 6.8 Sibling `.md` ≥200 words for every implementation file
|
||||
- [ ] 6.9 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold `## ADDED` + `## MODIFIED` Requirements into `openspec/specs/api-plugin-notifications/spec.md`
|
||||
- [ ] 7.2 Move `openspec/changes/wire-notification-channels/` → `openspec/archive/2026-05-19-wire-notification-channels/`
|
||||
Reference in New Issue
Block a user