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.
|
||||
Reference in New Issue
Block a user