Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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/`