290 lines
13 KiB
Markdown
290 lines
13 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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
|