20 KiB
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 anddefaultChannel === "apn" - THEN the registered APN
DeliveryChannelreceivessend(notification)and the resultchannel === "apn"
Scenario: dispatch routes to an explicit channel when provided
- WHEN
dispatcher.dispatch({ recipient, body, channel: "email" })is called against a dispatcher with bothapnandemailchannels registered - THEN only the email channel receives
send, and the resultchannel === "email"
Scenario: unknown channel raises DISPATCH_CHANNEL_UNAVAILABLE
- WHEN
dispatcher.dispatch({ channel: "sms", … })is called and nosmschannel is registered - THEN the call rejects with
NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)
Scenario: channel-returned failure raises DISPATCH_FAILED
- WHEN the underlying channel's
sendreturns{ success: false, error: "transport failed" } - THEN
dispatchrejects withNotificationsPluginError(DISPATCH_FAILED)whosecontext.channelError === "transport failed"
Scenario: delivery-tracking sees a delivery:sent event on success
- WHEN
dispatchsucceeds and aDeliveryEventStoreis configured - THEN the store records a
DeliveryEventwitheventType === "sent", the dispatcher's tenantId, andmessageId === dispatchResult.id
Scenario: delivery-tracking sees a delivery:bounced event on failure
- WHEN
dispatchfails through a channel failure and aDeliveryEventStoreis configured - THEN the store records a
DeliveryEventwitheventType === "bounced"
Scenario: idempotency adapter dedupes repeated dispatches
- WHEN the dispatcher is configured with an adapter that caches by key and
dispatchis invoked twice with the same key - THEN the underlying channel's
sendis 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.sendNotificationis invoked withpayload.deviceToken === "tok"and the returnedDeliveryResult.success === true
Scenario: deviceTokens array fans out
- WHEN
apnChannel.send({ channel: "push", metadata: { deviceTokens: ["a","b"] }, body: "hi", … })is called - THEN
ApnService.sendBulkNotificationsis invoked with the two-element array and the returnedDeliveryResult.success === true
Scenario: missing deviceToken metadata
- WHEN
apnChannel.sendis called with nodeviceTokenand nodeviceTokens - 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.sendNotificationreturns{ sent: 0, failed: 1, errors: [{ error: "Unregistered" }] } - THEN
apnChannel.sendresolves withsuccess: falseanderror: "Unregistered"
Scenario: canSend gates on channel
- WHEN
apnChannel.canSend(notification)is called - THEN the result is
trueiffnotification.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.sendreceives{ 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
subjectand the channel was constructed withdefaultSubject: "Notification" - THEN
EmailService.sendreceivessubject: "Notification"
Scenario: HTML body via metadata
- WHEN
emailChannel.send({ channel: "email", metadata: { html: true }, body: "<p>x</p>", … })is called - THEN
EmailService.sendreceives{ html: "<p>x</p>" }instead oftext
Scenario: transport failure
- WHEN the underlying
EmailService.sendreturns{ 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 returnsfalse
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
keyis passed twice and the adapter caches by key - THEN the underlying
fnis 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, andNOTIFICATIONS_PLUGIN.EMAIL_SERVICE_UNAVAILABLE
Scenario: every new code reachable from src
- WHEN the spec-conformance scan concatenates every non-test
.tsundersrc/ - 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
notificationsDispatchedTotalandnotificationsDispatchFailedTotalproperties
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
onInitruns withenableDispatcher: true - THEN
ctx.getService("notificationDispatcher")resolves to aNotificationDispatcherinstance
Scenario: emailService slot populated when injected
- WHEN the host calls
createNotificationsPlugin({ services: { emailService } })andonInitruns - THEN
ctx.getService("emailService")resolves to the sameEmailServiceinstance
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"], anddefaults.basePath === "/v1/notifications"
Scenario: invalid options fail fast
- WHEN
createNotificationsPlugin({ basePath: "" } as never)is called - THEN the call throws
NotificationsPluginErrorwithcode === NOTIFICATIONS_PLUGIN_CODES.INVALID_CONFIG
Scenario: onInit registers repositories
- WHEN
onInit(ctx)runs with a realdbservice in the registry - THEN
ctx.getService("notificationRepository")andctx.getService("deviceTokenRepository")resolve to non-null instances
Scenario: onInit fails without database service
- WHEN
onInit(ctx)runs with nodbservice registered - THEN it throws
NotificationsPluginError(SERVICE_NOT_AVAILABLE)
Scenario: onInit registers APN when enabled
- WHEN
onInit(ctx)runs withenablePush === trueand a validapnconfig - THEN
ctx.getService("apnService")resolves to anApnServiceinstance whoseisInitialized()returnstrue
Scenario: onInit fails when enablePush without apn
- WHEN
onInit(ctx)runs withenablePush === trueandapn === undefined - THEN it throws
NotificationsPluginError(APN_CONFIG_MISSING)
Scenario: onInit registers notificationDispatcher when enableDispatcher is on
- WHEN
onInit(ctx)runs withenableDispatcher: true,enablePush: true, and a valid APN config - THEN
ctx.getService("notificationDispatcher")resolves to aNotificationDispatcherwhosedispatch({ channel: "apn", … })succeeds
Scenario: onInit refuses dispatcher with zero channels
- WHEN
onInit(ctx)runs withenableDispatcher: true,enablePush: false, and noemailServiceinjected - THEN it throws
NotificationsPluginError(DISPATCH_CHANNEL_UNAVAILABLE)
Scenario: onReady mounts both route trees
- WHEN
onReady(ctx)runs afteronInit - THEN
ctx.getService("app").routescontains entries whosepathbegins with the configuredbasePathAND with the configuredpushBasePath
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 withenableDispatcher: true - THEN the dispatcher reference is nulled and subsequent
onReadyinvocations against a fresh app raiseSERVICE_NOT_AVAILABLE