# 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` 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: }`. 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: "

x

", … })` is called - **THEN** `EmailService.send` receives `{ html: "

x

" }` 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(key: string, fn: () => Promise): Promise` 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` 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`