13 KiB
Capability Spec Delta: api-plugin-notifications (add-api-plugin-notifications)
Package: @suaveplan/api-plugin-notifications
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time.
ADDED Requirements
Requirement: Plugin factory and lifecycle
The package SHALL export createNotificationsPlugin(options?: NotificationsPluginConfigInput) 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.
onReady SHALL mount both route trees and refuse to double-mount.
onClose SHALL shut down the APN transport (if any) 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: 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
- WHEN
onClose()is invoked after a successful boot - THEN subsequent
onReadyinvocations against a fresh app raiseSERVICE_NOT_AVAILABLEbecause the repositories are cleared
Requirement: NotificationRepository contract
The package SHALL export a NotificationRepository class implementing
INotificationRepository against the genesis SurrealAdapter. Every
statement SHALL use positional placeholders ($p0, $p1, …) matching
the DatabaseAdapter.query(sql, params: readonly unknown[]) surface.
Twelve operations SHALL be present: create, findById,
findByRecipient, getUnreadCount, update, markAsRead,
markManyAsRead, markAllAsRead, dismiss, delete,
findGroupedByType, deleteOldRead. Record-id normalisation
SHALL accept both bare and prefixed ids; SurrealDB RecordId row
shapes SHALL collapse to string ids inside transformRecord.
Verified by: src/repositories/notification-repository/notification-repository.test.ts.
Scenario: create returns transformed row
- WHEN
repo.create({ type, recipient, content })returns a fresh notification id - THEN the returned
Notification.idis a string, even when the adapter returned a{ tb, id }RecordId shape
Scenario: empty adapter row raises NOTIFICATION_CREATE_FAILED
- WHEN the adapter returns
[]on CREATE - THEN the repository throws
NotificationsPluginError(NOTIFICATION_CREATE_FAILED)
Scenario: findByRecipient builds the SurrealQL filter
- WHEN
findByRecipient(userId, siteId, opts)is called with every option - THEN the SurQL string contains
WHERE recipient = type::thing('user', $p0),AND site = type::thing('site', $p…),AND read = false,AND type = $p…,AND created_at > $p…,LIMIT $p…, andSTART $p…
Scenario: deleteOldRead rejects negative input
- WHEN
deleteOldRead(-1)is invoked - THEN the repository throws
NotificationsPluginError(INVALID_CONFIG)
Requirement: DeviceTokenRepository contract
The package SHALL export a DeviceTokenRepository class implementing
IDeviceTokenRepository. Eight operations SHALL be present:
register, findById, findByToken, findActiveForUser,
deactivate, deactivateByToken, touchLastUsed, cleanup.
Duplicate-token detection SHALL fold the SurrealDB UNIQUE-index
violation into DEVICE_TOKEN_DUPLICATE. Other adapter failures SHALL
fold into DEVICE_TOKEN_CREATE_FAILED.
Verified by: src/repositories/device-token-repository/device-token-repository.test.ts.
Scenario: duplicate token raises DEVICE_TOKEN_DUPLICATE
- WHEN the adapter throws an error mentioning
tokenon CREATE - THEN the repository throws
NotificationsPluginError(DEVICE_TOKEN_DUPLICATE)
Scenario: generic create failure raises DEVICE_TOKEN_CREATE_FAILED
- WHEN the adapter throws an unrelated error on CREATE
- THEN the repository throws
NotificationsPluginError(DEVICE_TOKEN_CREATE_FAILED)
Scenario: cleanup rejects negative daysOld
- WHEN
cleanup(-1)is invoked - THEN the repository throws
NotificationsPluginError(INVALID_CONFIG)
Requirement: ApnService with pluggable transport
The package SHALL export an ApnService class that validates its
config, builds an ApnTransport (default:
createMemoryApnTransport), and exposes
initialize / sendNotification / sendBulkNotifications /
shutdown / isInitialized. Per-device failures SHALL appear inside
the returned PushNotificationResult.errors array; the service SHALL
NOT throw for per-device delivery failures. Transport-init errors
SHALL fold into APN_TRANSPORT_INIT_FAILED; transport-shutdown
errors fold into APN_TRANSPORT_SHUTDOWN_FAILED. The
MemoryApnTransport ships as the deterministic default; hosts inject
their own transport for production @parse/node-apn delivery.
Verified by: src/services/apn-service/apn-service.test.ts,
src/transport/apn-transport/apn-transport.test.ts.
Scenario: missing auth material raises APN_CONFIG_MISSING
- WHEN
initialize()runs with nocert,key,pfx, ortoken - THEN the service throws
NotificationsPluginError(APN_CONFIG_MISSING)
Scenario: incomplete token raises APN_CONFIG_INVALID
- WHEN
initialize()runs withtoken: { key, keyId: "", teamId } - THEN the service throws
NotificationsPluginError(APN_CONFIG_INVALID)
Scenario: per-device failure surfaces in the result
- WHEN
sendNotificationis called against a transport whosefailurePlanmapsbad→{ status: 410, reason: "Unregistered" } - THEN the call resolves with
{ sent: 0, failed: 1, errors: [{ status: 410, reason: "Unregistered" }] }— no throw
Scenario: transport-thrown error folds into a synthetic failure
- WHEN
sendNotificationis called against a transport whosesendthrows - THEN the call resolves with
{ sent: 0, failed: 1, errors: [{ error: <message> }] }— no throw
Scenario: bulk send empty list returns 0/0
- WHEN
sendBulkNotifications([], payload)is invoked - THEN the call resolves with
{ sent: 0, failed: 0 }without contacting the transport
Requirement: Hono OpenAPI route groups
The package SHALL export createNotificationsRoutes(config) and
createPushRoutes(config) that return OpenAPIHono route bundles.
Notifications routes SHALL cover 11 endpoints (list, unread-count,
create, get, patch, mark-read, mark-many, mark-all, dismiss, delete,
grouped-by-type). Push routes SHALL cover 5 endpoints (register
token, unregister token, list tokens, send single, send bulk).
Every route SHALL require auth via c.get("user") and return
401 { error, code: NOTIFICATIONS_PLUGIN.UNAUTHENTICATED } when
missing. Mutating routes SHALL verify the notification belongs to
the authenticated user; mismatches return 403 { code: FORBIDDEN }.
Delivery endpoints SHALL return 503 when apnService === null
(i.e. enablePush === false). Every successful mutation SHALL
forward a NotificationsRouteEvent to the host's ctx.eventBus
through the injected emitEvent callback.
Verified by: src/routes/notifications-routes/notifications-routes.test.ts,
src/routes/push-routes/push-routes.test.ts.
Scenario: missing user returns 401
- WHEN any route is hit without
c.get("user")set - THEN the response is
401with body{ error: "Authentication required", code: "NOTIFICATIONS_PLUGIN.UNAUTHENTICATED" }
Scenario: ownership mismatch returns 403
- WHEN
PATCH /notifications/{id}is invoked by a user whose id does not match the notification'srecipient - THEN the response is
403withcode: NOTIFICATIONS_PLUGIN.FORBIDDEN
Scenario: delivery without APN returns 503
- WHEN
POST /push/sendis invoked withapnService === nullin the route config - THEN the response is
503withcode: NOTIFICATIONS_PLUGIN.APN_SERVICE_UNAVAILABLE
Scenario: successful create forwards event-bus event
- WHEN
POST /notificationssucceeds - THEN the injected
emitEventcallback receives{ type: "notification:created", payload: { notificationId, recipientId, type } }
Scenario: duplicate device token returns 409
- WHEN
POST /push/tokensis invoked with a token that triggersDEVICE_TOKEN_DUPLICATE - THEN the response is
409withcode: NOTIFICATIONS_PLUGIN.DEVICE_TOKEN_DUPLICATE
Scenario: bulk send caps recipients at maxBulkRecipients
- WHEN
POST /push/send-bulkis invoked withuserIdslonger thanmaxBulkRecipients - THEN the route processes only the first
maxBulkRecipientsentries;totalUsersreflects the cap
Requirement: Error-code registry
The package SHALL export NOTIFICATIONS_PLUGIN_CODES (frozen
record built by defineCodes("NOTIFICATIONS_PLUGIN", [...])) and a
NotificationsPluginError class extending SuaveplanError. Every
code SHALL be reachable from at least one production source file;
the spec-conformance test SHALL grep the source tree to assert
reachability.
Verified by: src/error-codes/error-codes.test.ts.
Scenario: namespace literal is NOTIFICATIONS_PLUGIN
- WHEN every value of
NOTIFICATIONS_PLUGIN_CODESis inspected - THEN each value starts with
"NOTIFICATIONS_PLUGIN."
Scenario: every code reachable from src/
- WHEN the spec test concatenates every non-test
.tsundersrc/ - THEN the concatenation contains
NOTIFICATIONS_PLUGIN_CODES.<KEY>for every key
Requirement: OpenTelemetry instruments
The package SHALL expose getNotificationsInstruments() /
resetNotificationsInstruments() returning six counters
(notificationsCreatedTotal, notificationsReadTotal,
notificationsDeletedTotal, pushSentTotal, pushFailedTotal,
deviceTokensRegisteredTotal). Each counter SHALL be emitted from
at least one production code path and SHALL be observable through
an InMemoryMetricExporter in the test suite.
Verified by: src/otel/instruments.test.ts.
Scenario: bundle is memoised
- WHEN
getNotificationsInstruments()is called twice without a reset - THEN both calls return the same object reference
Scenario: counters emit on flush
- WHEN counters are incremented and
flushTestMetrics()is called - THEN the exporter snapshot contains every counter name
Requirement: Service-registry augmentation
The package SHALL augment
@suaveplan/api-plugin/service-registry's ServiceRegistry
interface with three slots: notificationRepository
(INotificationRepository), deviceTokenRepository
(IDeviceTokenRepository), and apnService (ApnService). The
package barrel SHALL side-effect import the augmentation so
consumers that import any public symbol pick up the type widening
automatically.
Verified by: src/api-plugin-notifications.spec-conformance.test.ts.
Scenario: registry slots typed
- WHEN a downstream consumer calls
ctx.getService("notificationRepository")against the augmented registry - THEN the result type narrows to
INotificationRepository | undefinedwithout a cast