Files
2026-05-27 23:29:53 +01:00

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"], 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: 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

  • WHEN onClose() is invoked after a successful boot
  • THEN subsequent onReady invocations against a fresh app raise SERVICE_NOT_AVAILABLE because 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.id is 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…, and START $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 token on 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 no cert, key, pfx, or token
  • THEN the service throws NotificationsPluginError(APN_CONFIG_MISSING)

Scenario: incomplete token raises APN_CONFIG_INVALID

  • WHEN initialize() runs with token: { key, keyId: "", teamId }
  • THEN the service throws NotificationsPluginError(APN_CONFIG_INVALID)

Scenario: per-device failure surfaces in the result

  • WHEN sendNotification is called against a transport whose failurePlan maps bad → { 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 sendNotification is called against a transport whose send throws
  • 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 401 with 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's recipient
  • THEN the response is 403 with code: NOTIFICATIONS_PLUGIN.FORBIDDEN

Scenario: delivery without APN returns 503

  • WHEN POST /push/send is invoked with apnService === null in the route config
  • THEN the response is 503 with code: NOTIFICATIONS_PLUGIN.APN_SERVICE_UNAVAILABLE

Scenario: successful create forwards event-bus event

  • WHEN POST /notifications succeeds
  • THEN the injected emitEvent callback receives { type: "notification:created", payload: { notificationId, recipientId, type } }

Scenario: duplicate device token returns 409

  • WHEN POST /push/tokens is invoked with a token that triggers DEVICE_TOKEN_DUPLICATE
  • THEN the response is 409 with code: NOTIFICATIONS_PLUGIN.DEVICE_TOKEN_DUPLICATE

Scenario: bulk send caps recipients at maxBulkRecipients

  • WHEN POST /push/send-bulk is invoked with userIds longer than maxBulkRecipients
  • THEN the route processes only the first maxBulkRecipients entries; totalUsers reflects 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_CODES is inspected
  • THEN each value starts with "NOTIFICATIONS_PLUGIN."

Scenario: every code reachable from src/

  • WHEN the spec test concatenates every non-test .ts under src/
  • 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 | undefined without a cast