Initial Commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# add-api-plugin-notifications
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-notifications`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
**Design**: (none — straightforward migration; see this proposal + spec delta)
|
||||
**Spec delta**: ./specs/api-plugin-notifications/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships the foundational pieces of `@suaveplan/api` (database,
|
||||
search, events, auth, site, etc.) but is missing the
|
||||
**notifications** plugin — in-app notification CRUD, device-token
|
||||
registration, and APN push delivery. Without this plugin, downstream
|
||||
apps that need lock-screen pushes or in-app notification feeds have
|
||||
to hand-roll the surface. Upstream
|
||||
`enterprise/api-plugin-notifications.md` documents the audit (4
|
||||
`*.test.ts` files, two `.spec.ts` Playwright integration files, the
|
||||
APN mock smell, the mock middleware no-op, and the
|
||||
`autoCleanOldDays` abandoned config flag).
|
||||
|
||||
This change brings the package across at full functional parity,
|
||||
adapted to the genesis `@suaveplan/api` plugin shape
|
||||
(`PluginDescriptor`, `ctx.getService(...)`), the genesis
|
||||
`DatabaseAdapter` positional-param surface, and the
|
||||
`ctx.eventBus` cross-plugin emission pattern. The local-emitter
|
||||
wart and the APN mock are both replaced — the APN service now
|
||||
shapes payloads against a **pluggable transport interface**
|
||||
(`ApnTransport`) so the plugin never depends on a concrete APN
|
||||
client library. Production hosts inject a `node-apn` adapter; the
|
||||
plugin ships a deterministic `MemoryApnTransport` that records every
|
||||
send for hermetic tests.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `createNotificationsPlugin(options?)` — factory returning a
|
||||
`PluginDescriptor<NotificationsPluginConfig>` for `@suaveplan/api`.
|
||||
- ADDED: `NotificationsPluginOptionsSchema` / `NotificationsPluginConfig`
|
||||
/ `NotificationsPluginConfigInput` — Zod-validated config
|
||||
(basePath, pushBasePath, enablePush, apn, autoCleanOldDays,
|
||||
maxBulkRecipients).
|
||||
- ADDED: `ApnConfigSchema` / `ApnConfig` — Zod validation of the
|
||||
APN config bundle (cert/key/pfx/token + topic + production +
|
||||
network knobs).
|
||||
- ADDED: `NotificationRepository` + `INotificationRepository` —
|
||||
SurrealDB-backed notification CRUD repository over the genesis
|
||||
`SurrealAdapter` service slot.
|
||||
- ADDED: `DeviceTokenRepository` + `IDeviceTokenRepository` —
|
||||
SurrealDB-backed device-token CRUD repository.
|
||||
- ADDED: `createNotificationsRoutes` — Hono OpenAPI route module
|
||||
with 11 endpoints (list / unread-count / create / get / patch /
|
||||
read / read-many / read-all / dismiss / delete / grouped collapses
|
||||
into list).
|
||||
- ADDED: `createPushRoutes` — Hono OpenAPI route module with 5
|
||||
endpoints (register / unregister / list tokens / send / send-bulk).
|
||||
- ADDED: `ApnService` + `createApnService` — APN delivery service
|
||||
with a pluggable transport.
|
||||
- ADDED: `MemoryApnTransport` + `createMemoryApnTransport` +
|
||||
`ApnTransport` / `ApnTransportNotification` /
|
||||
`ApnTransportResponse` / `ApnTransportFactory` — the transport
|
||||
contract and a deterministic default implementation.
|
||||
- ADDED: `NOTIFICATIONS_PLUGIN_CODES` registry +
|
||||
`NotificationsPluginError` class — 18 stable codes spanning
|
||||
configuration, lifecycle, repository, device-token, route, and APN
|
||||
failure modes.
|
||||
- ADDED: OTel instruments — 6 counters
|
||||
(`notificationsCreatedTotal`, `notificationsReadTotal`,
|
||||
`notificationsDeletedTotal`, `pushSentTotal{platform}`,
|
||||
`pushFailedTotal{reason}`, `deviceTokensRegisteredTotal{platform}`).
|
||||
- ADDED: ServiceRegistry augmentation — `notificationRepository`,
|
||||
`deviceTokenRepository`, `apnService` slots become available to
|
||||
downstream plugins via typed `ctx.getService(...)`.
|
||||
- ADDED: Local Zod schemas for notification and push surfaces
|
||||
(`NotificationSchema`, `CreateNotificationSchema`,
|
||||
`UpdateNotificationSchema`, `NotificationQuerySchema`,
|
||||
`NotificationListResponseSchema`, `MarkManyAsReadSchema`,
|
||||
`DeviceTokenSchema`, `RegisterDeviceTokenSchema`,
|
||||
`SendPushNotificationSchema`, `SendBulkPushNotificationSchema`,
|
||||
etc.) — `@suaveplan/api-schemas` on genesis does not yet ship a
|
||||
`notification` subpath, so the plugin owns the boundary schemas.
|
||||
- ADDED: `surql/tables/notification.surql` +
|
||||
`surql/tables/device_token.surql` — slimmed migrations covering
|
||||
only the columns the repositories read/write. The upstream
|
||||
cross-plugin permission rules, stored helper functions
|
||||
(`fn::notification::*`, `fn::device::token::*`,
|
||||
`fn::push::queue::*`, `fn::push::history::*`), bitwise-typed
|
||||
notification-type constants, and `push_notification_queue` /
|
||||
`push_notification_history` tables are excluded (see below).
|
||||
- ADDED: `NotificationsPluginEvents` event-map keys —
|
||||
`notifications:initialized`, `notifications:ready`,
|
||||
`notification:created`, `notification:read`,
|
||||
`notification:dismissed`, `notification:deleted`,
|
||||
`notification:push:sent`, `notification:push:failed`,
|
||||
`notification:device:registered`,
|
||||
`notification:device:unregistered`.
|
||||
|
||||
## Excluded from this migration
|
||||
|
||||
These items from `enterprise/api-plugin-notifications.md`
|
||||
"Required enterprise-quality improvements" and "Feature-rich
|
||||
enhancements" are deferred because each requires cross-package deps
|
||||
that don't yet exist on genesis (the same scope-out pattern that
|
||||
`add-api-plugin-events` used for `fn::notification`, `fn::bitwise`,
|
||||
`fn::group` references):
|
||||
|
||||
- Real `@parse/node-apn` integration in the production codepath —
|
||||
the catalog deliberately omits `@parse/node-apn`. The migration
|
||||
ships an `ApnTransport` injection contract instead; production
|
||||
hosts add the library to their application dependencies and
|
||||
inject a `node-apn`-backed transport. The deterministic
|
||||
`MemoryApnTransport` ships with the plugin and is the default in
|
||||
tests + dev.
|
||||
- FCM / Web Push / Email / SMS / Slack channels — depend on
|
||||
`@suaveplan/notification-channel`,
|
||||
`@suaveplan/emailer`, `@suaveplan/sms-providers`,
|
||||
`@suaveplan/slack-client` which are not yet on genesis. Deferred
|
||||
to `add-api-plugin-notifications-channels`.
|
||||
- `autoCleanOldDays` scheduled cleanup — `deleteOldRead(daysOld)`
|
||||
is shipped on the repository and is callable; wiring it through
|
||||
`@suaveplan/job-scheduler` on `onReady` waits for the job-scheduler
|
||||
plugin (concurrent migration). Deferred to
|
||||
`add-api-plugin-notifications-cleanup-job`.
|
||||
- Preference center, quiet hours, templates, suppression lists,
|
||||
delivery tracking, batching+backoff queue, rich media, digests,
|
||||
in-app SSE/WS — every one of these requires a sibling plugin that
|
||||
doesn't exist on genesis yet (`@suaveplan/templates`,
|
||||
`@suaveplan/suppression`, `@suaveplan/delivery-tracking`,
|
||||
`@suaveplan/queue`, `@suaveplan/api-plugin-sse`,
|
||||
`@suaveplan/websocket-gateway`, `@suaveplan/datetime`,
|
||||
`@suaveplan/i18n`). Each lands in its own follow-up change.
|
||||
- Auth gating via `@suaveplan/api-plugin-auth` — this plugin reads
|
||||
`c.get("user")` (set by the host's auth middleware) and raises
|
||||
`UNAUTHENTICATED` when missing; explicit `requireAuth()`
|
||||
composition is a host-level concern.
|
||||
- Audit-log emission, idempotency, rate-limit — depend on
|
||||
`@suaveplan/api-plugin-audit`, `@suaveplan/idempotency`,
|
||||
`@suaveplan/api-plugin-rate-limit` (concurrent migrations); will
|
||||
be composed at the host level once they land.
|
||||
- Upstream `push_notification_queue` / `push_notification_history`
|
||||
tables, `fn::push::queue::*`, `fn::push::history::*`,
|
||||
`fn::notification::create` / `should_create` / `friend_request` /
|
||||
`post_like` / `comment` SurrealQL helpers — all reference
|
||||
`record<site>` / `record<post>` / `record<comment>` /
|
||||
`record<group>` types and call `fn::bitwise::has_bits` /
|
||||
`fn::settings::get` / `fn::session::is_root` which only exist
|
||||
inside sibling plugins (`api-plugin-site`,
|
||||
`api-plugin-bitwise`, `api-plugin-groups`). To avoid hand-rolling
|
||||
four downstream plugins as a side-quest, the migration ships a
|
||||
slimmer schema covering only the columns the repositories read /
|
||||
write; queue + history + helper functions migrate when their
|
||||
dependent plugins land.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from
|
||||
`../suaveplan/enterprise/api-plugin-notifications.md` and the upstream
|
||||
source at `../suaveplan/packages/server/api-plugin-notifications/`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Plugin factory `createNotificationsPlugin(config?)` | enterprise.md L17-L29; src/index.ts L64-L130 | reworded — genesis uses `PluginDescriptor` not `Plugin<NotificationConfig>` |
|
||||
| Config schema (basePath, pushBasePath, enablePush, apn, autoCleanOldDays) | enterprise.md L13, L27-L56; src/index.ts L27-L56 | extended with `pushBasePath` and `maxBulkRecipients` for genesis route-mount + bulk cap |
|
||||
| Plugin dependencies: `["database"]` | enterprise.md L18; src/index.ts L75 | verbatim |
|
||||
| `migrationsPath` removed in genesis | enterprise.md L86; src/index.ts L86 | the database plugin owns migration application in genesis |
|
||||
| NotificationRepository: create/findById/findByRecipient/getUnreadCount/update/markAsRead/markManyAsRead/markAllAsRead/dismiss/delete/findGroupedByType/deleteOldRead | src/repository/notification.repository/notification.repository.ts | reworded — typed `db.create/select/update/delete` replaced by positional SurQL (api-plugin-events Path-A pattern); upstream `getById` → `findById`, `getByRecipient` → `findByRecipient`, `getGroupedByType` → `findGroupedByType`, `deleteOldNotifications` → `deleteOldRead` |
|
||||
| DeviceTokenRepository: register/findById/findByToken/findActiveForUser/deactivate/deactivateByToken/touchLastUsed/cleanup | upstream surql/tables/device_token.surql helpers | new — upstream put device-token CRUD in stored functions; genesis pulls them up into a TS repository so the plugin can run without the surql helpers |
|
||||
| createNotificationsRoutes (11 endpoints) | src/routes/notifications/notifications.ts | verbatim — `ctx.eventBus` replaces local `EventEmitter`; `grouped` collapses into list filter |
|
||||
| createPushRoutes (5 endpoints) | src/routes/push/push.ts | reworded — `device_registration` table folded into `device_token`; bulk send capped by `maxBulkRecipients` schema bound |
|
||||
| APNService (initialize/sendNotification/sendBulkNotifications/shutdown/isInitialized) | src/services/apn/apn.service.ts | reworded — `apn.Provider` swapped for an injectable `ApnTransport`; same payload shape, same `validateConfig` rules, same `mapResponse` semantics |
|
||||
| MemoryApnTransport (deterministic in-memory delivery) | n/a (genesis-original) | required because the genesis catalog omits `@parse/node-apn` |
|
||||
| `notification` SurrealQL table | upstream surql/tables/notification.surql | slimmed — see `## Excluded from this migration` |
|
||||
| `device_token` SurrealQL table | upstream surql/tables/device_token.surql | slimmed |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100% test / branch / function / line coverage | Genesis-wide policy (CLAUDE.md "Quality", AGENTS.md §7). |
|
||||
| `defineCodes("NOTIFICATIONS_PLUGIN", [...])` namespace | Genesis convention; avoids collision with `EVENTS` (`@suaveplan/events`) and `EVENTS_PLUGIN` (`@suaveplan/api-plugin-events`). |
|
||||
| OTel instruments via `metrics.getMeter("@suaveplan/api-plugin-notifications")` | Genesis-wide observability policy; emitted from production paths and asserted with a real in-memory `MeterProvider` (AGENTS.md §13). |
|
||||
| Use `ctx.eventBus` from `@suaveplan/api-plugin` context, not a local `EventEmitter` | Fixes the upstream smell where notifications fired into a private `EventEmitter` invisible to siblings. |
|
||||
| Subpath exports per feature module | AGENTS.md §6 — required so consumers can tree-shake. |
|
||||
| Schemas owned locally | `@suaveplan/api-schemas` on genesis lacks a `notification` subpath; the plugin's schemas live inside `src/schemas/` until upstream is augmented. |
|
||||
| Positional `$p0`-style params instead of named `$id` | Genesis `DatabaseAdapter.query(sql, params: readonly unknown[])` wraps params as `{p0: …, p1: …}`. Path-A escape valve documented in CLAUDE.md. |
|
||||
| Injectable `ApnTransport` | Genesis catalog omits `@parse/node-apn` (network-touching libraries are injected by the host). Matches the genesis convention for transport adapters and means the plugin's test suite is hermetic. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/error-codes/error-codes.test.ts` — pins every code is reachable from a real call site.
|
||||
- `src/otel/instruments.test.ts` — real `MeterProvider`, asserts instrument shape + emission.
|
||||
- `src/schemas/notification-schemas.test.ts` + `src/schemas/push-schemas.test.ts` + `src/schemas/options.test.ts` — Zod boundary tests.
|
||||
- `src/repositories/notification-repository/notification-repository.test.ts` — full repository surface against a stub `SurrealAdapter`.
|
||||
- `src/repositories/device-token-repository/device-token-repository.test.ts` — same coverage shape for device-tokens.
|
||||
- `src/transport/apn-transport/apn-transport.test.ts` — `MemoryApnTransport` behaviour including failure plans.
|
||||
- `src/services/apn-service/apn-service.test.ts` — full APN service surface, every error branch, transport-injection and factory pathways.
|
||||
- `src/routes/notifications-routes/notifications-routes.test.ts` — every route, every status code, ownership check, auth guard, event-bus emission, OTel emission.
|
||||
- `src/routes/push-routes/push-routes.test.ts` — register / unregister / list / send / send-bulk against an in-memory APN service.
|
||||
- `src/plugin/plugin.test.ts` — lifecycle: onInit registers repos + APN, double-mount guard, missing-dep failure, emits initialized/ready.
|
||||
- `src/api-plugin-notifications.spec-conformance.test.ts` — pins every public symbol re-exported by the package root.
|
||||
- `tests/integration/full-boot.test.ts` — real SurrealDB testcontainer, applies the two surql migrations, then full plugin boot + CRUD roundtrip on both repositories.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. This is a brand-new package on genesis.
|
||||
Consumers will opt-in via `createNotificationsPlugin()` once they
|
||||
need notification routes.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
None on genesis today — package is additive. Downstream apps that
|
||||
previously had no notifications capability gain access.
|
||||
@@ -0,0 +1,289 @@
|
||||
<!-- 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
|
||||
@@ -0,0 +1,74 @@
|
||||
# Tasks — add-api-plugin-notifications
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-notifications.md` end-to-end
|
||||
- [x] 1.2 Confirm `openspec/specs/api-plugin-notifications/` does not pre-exist
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 0a, 2a, 6a, 7, 12, 13, 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill `## Source mapping` table
|
||||
- [x] 1.6 Draft `specs/api-plugin-notifications/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/error-codes/error-codes.ts` — `defineCodes("NOTIFICATIONS_PLUGIN", [...])` with 18 codes
|
||||
- [x] 2.2 `src/schemas/notification-schemas.ts` — notification entity + wire schemas
|
||||
- [x] 2.3 `src/schemas/push-schemas.ts` — device-token + push wire schemas
|
||||
- [x] 2.4 `src/schemas/options.ts` — `NotificationsPluginOptionsSchema`, `ApnConfigSchema`
|
||||
- [x] 2.5 `src/otel/instruments.ts` — `NotificationsInstruments`, lazy memoised factory
|
||||
- [x] 2.6 `src/services/registry-augmentation.ts` — declare repo + APN slots
|
||||
- [x] 2.7 `src/repositories/notification-repository/notification-repository.ts` — full repository over positional SurQL
|
||||
- [x] 2.8 `src/repositories/device-token-repository/device-token-repository.ts` — full repository over positional SurQL
|
||||
- [x] 2.9 `src/transport/apn-transport/apn-transport.ts` — `MemoryApnTransport` + factory + interface
|
||||
- [x] 2.10 `src/services/apn-service/apn-service.ts` — `ApnService` with pluggable transport
|
||||
- [x] 2.11 `src/routes/notifications-routes/notifications-routes.ts` — 11 endpoints with OTel + event-bus emission
|
||||
- [x] 2.12 `src/routes/push-routes/push-routes.ts` — 5 endpoints
|
||||
- [x] 2.13 `src/plugin/plugin.ts` — factory + onInit / onReady / onClose
|
||||
- [x] 2.14 `src/types/types.ts` — public types
|
||||
- [x] 2.15 `src/index.ts` — explicit named re-exports
|
||||
- [x] 2.16 `surql/tables/notification.surql` + `surql/tables/device_token.surql`
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/error-codes/error-codes.test.ts` — every code referenced from a real throw site
|
||||
- [x] 3.2 `src/schemas/*.test.ts` — Zod boundary positive + negative cases
|
||||
- [x] 3.3 `src/otel/instruments.test.ts` — real `MeterProvider`, asserts emission
|
||||
- [x] 3.4 `src/repositories/notification-repository/notification-repository.test.ts` — full CRUD
|
||||
- [x] 3.5 `src/repositories/device-token-repository/device-token-repository.test.ts` — full CRUD
|
||||
- [x] 3.6 `src/transport/apn-transport/apn-transport.test.ts` — `MemoryApnTransport` paths
|
||||
- [x] 3.7 `src/services/apn-service/apn-service.test.ts` — every error branch
|
||||
- [x] 3.8 `src/routes/notifications-routes/notifications-routes.test.ts` — every route + status code + auth + ownership
|
||||
- [x] 3.9 `src/routes/push-routes/push-routes.test.ts` — every route + status code
|
||||
- [x] 3.10 `src/plugin/plugin.test.ts` — lifecycle, double-mount, missing dep
|
||||
- [x] 3.11 `src/api-plugin-notifications.spec-conformance.test.ts` — pins public surface
|
||||
- [x] 3.12 `tests/integration/full-boot.test.ts` — real SurrealDB, boots plugin + CRUD roundtrip
|
||||
- [x] 3.13 Every `it(...)` carries `{ timeout: 5000 }` (or 30000+ for genuinely async)
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples)
|
||||
- [x] 4.2 `README.md` links every co-located `.md` under `## Modules`
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — green
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-notifications` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-notifications` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-notifications` — 0 errors
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-notifications` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist (AGENTS.md §13)
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` assertions where literal-type claims exist
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary
|
||||
- [x] 6.3 Every code in `NOTIFICATIONS_PLUGIN_CODES` thrown by ≥1 production path
|
||||
- [x] 6.4 OTel instruments emitted from production paths and asserted with real `MeterProvider`
|
||||
- [x] 6.5 Lifecycle dedup tested (double-mount guard, `onClose` clears state)
|
||||
- [x] 6.6 No retroactive tick — every box reflects work done in this change
|
||||
- [x] 6.7 Test files import only from `@suaveplan/testing/runner` (rule 11)
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-notifications/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-api-plugin-notifications/` → `openspec/archive/2026-05-19-add-api-plugin-notifications/`
|
||||
Reference in New Issue
Block a user